/* Digital Dream AI — product shell styles. Every value comes from styles/tokens.css.
   Round r2026-08-22-03: the shell is the approved v2 pastel-lilac twilight, and the Home + dream-capture
   surfaces are built from the reference set at the 390x844 viewport. */
*, *::before, *::after { box-sizing: border-box; }
/* THE DOCUMENT IS NOT A SCROLL CONTAINER (r2026-08-24k, DF-94). Four rounds tried to make the content fit so that no
   scrollbar would appear, and a scrollbar kept appearing, because "it fits" is a property of CONTENT and the owner's
   requirement is a property of the SHELL. As long as the document box may grow — min-height lets it — any screen that
   overruns by one pixel, in any locale, at any text zoom, on any screen built in a later round, turns the whole page
   into a scroller. No amount of fitting closes that hole; only removing the growth does.
   So the shell is now a fixed frame: html and body are exactly the visible height and `overflow: clip` — which,
   unlike hidden, creates no scroll container at all, so there is nothing for a scrollbar to attach to and nothing
   that can be scrolled programmatically either. The document can never scroll again, on any screen, present or future.
   Where does an overrun go? Into ONE declared region: .screen scrolls internally (see the .screen rule). That is the
   app-shell arrangement every native app uses — a fixed frame with a scrolling content area — and it is what keeps
   this from being a clipping bug: a 100%-height box with hidden overflow traps keyboard users, and clipped content
   fails WCAG 1.4.10 outright. Scrolling one region in one axis does not.
   Which screens may show that inner scroll is NOT decided here and NOT decided by this stylesheet's author: it is
   declared per screen in src/config/screen-fit.js and carried into the markup as data-fit, and [TS-117] fails when a
   screen exists without a declaration. The default is `fit`. */
html { height: 100%; overflow: clip; background: var(--dd-backdrop-base); }
body {
  /* height, not min-height: a floor is exactly what lets the box grow, and growth is the defect above. The full-height
     chain (html { height: 100% }) resolves against the space that actually exists — scrollbar excluded, and there is
     no scrollbar now — which on a phone equals the small viewport: nothing clipped, no address bar over content.
     Never a viewport unit here (DF-84): a dynamic viewport unit INCLUDES the space a classic scrollbar occupies, so
     asking for 100dvh on Windows asks for 15px more than exists, the scrollbar appears to grant them, and its own
     width keeps the request true forever.
     minmax(0, 1fr) rather than 1fr, and minmax(0, auto) rather than auto: a grid track's automatic minimum is its
     min-content size, so with a plain `1fr auto` either row could refuse to shrink, push past the fixed body, and be
     clipped — the one failure mode this arrangement must not have. A zero minimum on both is what lets the inner
     scroll region take the overflow instead. Position: relative so the QA entry can be docked to the shell's foot
     without adding a row to it (see .console-toggle). */
  margin: 0; height: 100%; overflow: clip; display: grid; grid-template-rows: minmax(0, 1fr) minmax(0, auto);
  position: relative; color: var(--dd-text);
  font-family: var(--dd-font-body); font-size: var(--dd-fs-body); line-height: var(--dd-lh-body);
  /* the canvas lives on html (and in the first-paint block, which TS-39 pins). Once html carries a background, a
     background on body paints in the root stacking context OVER every negative-z-index layer — which is what made
     the reference washes and the diagonal sheet invisible. body is transparent from here on. */
  background: transparent;
  -webkit-font-smoothing: antialiased; text-wrap: pretty;
}
[hidden] { display: none !important; }
/* DF-32: the icon/gradient sprite must stay renderable. display:none (the [hidden] attribute) on an outermost svg
   removes its subtree from rendering, and a paint-server reference into such a subtree behaves as if the target
   did not exist — the Home crescent lost its url(#moon-grad) fill and vanished. Zero size hides the sprite without
   detaching its defs. */
.sprite { position: absolute; width: 0; height: 0; overflow: hidden; }
.visually-hidden { position: absolute; width: 1px; height: 1px; margin: -1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; border: 0; }
/* Atmosphere — 1:1 with the reference's own two washes. They are ROTATED ellipses
   (washA: centre 330,165 radii 210x180 rotate 135deg; washB: centre 72,704 radii 180x165 rotate -35deg), which a
   CSS radial-gradient cannot express — the previous approximation used unrotated percentage ellipses. Each wash is
   now its own rotated layer with the reference's radii, colours and stops, anchored to the corner it belongs to.
   Both sit on a fixed, pointer-transparent layer behind everything: no layout box, no shift (M-WV-03). */
body::before {
  content: ""; position: fixed; inset: 0; z-index: -2; pointer-events: none;
  background: linear-gradient(160deg, var(--dd-backdrop-base) 0%, var(--dd-backdrop-sheet) 100%);
}

/* Anchored to the CONTENT COLUMN, not to the window. `right: -150px` is the reference's own offset from the right
   edge of a 390-wide screen, and on a phone that is still exactly what these rules compute; on a desktop window the
   same offset is measured from the right edge of the design band (50% + --dd-stage-edge), so the atmosphere keeps its
   distance from the column instead of drifting 400 px away to the window edge. Percentages, not vw: for a fixed
   element a percentage resolves against the initial containing block and therefore ignores a classic scrollbar,
   which vw does not. The radii grow with a wide viewport so the wash still reads as atmosphere on a monitor, and the
   max() keeps the reference value at every width up to the point where the reference itself would look sparse. */
.wash { position: fixed; z-index: -1; pointer-events: none; border-radius: 50%; }
.wash--a { width: max(420px, 36vw); height: max(360px, 31vw); right: 50%; margin-right: calc(var(--dd-stage-edge) * -1 - 150px); top: -15px; transform: rotate(135deg);
  background: radial-gradient(closest-side, var(--dd-wash-a) 0%, transparent 100%); }
.wash--b { width: max(360px, 31vw); height: max(330px, 28vw); left: 50%; margin-left: calc(var(--dd-stage-edge) * -1 - 108px); bottom: -25px; transform: rotate(-35deg);
  background: radial-gradient(closest-side, var(--dd-wash-b) 0%, transparent 100%); }
/* the controlled star field: pearl-gold, dusty blue, lilac and blush, never rainbow confetti */
/* the controlled star field, 1:1 with the reference: the box is the reference's own 294..390 x 0..130 region,
   pinned to the top-right corner so every shape keeps its exact offset from that corner at any viewport */

.skip { position: absolute; left: var(--dd-s4); top: -100px; padding: var(--dd-s3) var(--dd-s4); background: var(--dd-surface); color: var(--dd-text); border: 1px solid var(--dd-border); border-radius: var(--dd-r1); z-index: 1000; }
.skip:focus-visible { top: var(--dd-s4); }
:focus-visible { outline: 3px solid var(--dd-focus); outline-offset: 3px; }
/* a heading is focused programmatically so a screen reader lands on the new screen; it is never in the tab order,
   so it must not paint a keyboard ring */
[tabindex="-1"]:focus { outline: none; }

/* screen: one grid cell, every state stacks in the same area so swaps never move layout */
.screen {
  /* THE ONE SCROLL REGION IN THE PRODUCT. min-height: 0 so the screen takes the row body gives it rather than a
     viewport of its own (DF-73); `overflow: clip auto` so an overrun is absorbed HERE instead of by the document.
     The axes are deliberately different, and `clip` on the inline axis is not an oversight: leaving it `visible`
     beside a scrolling block axis would compute it to `auto`, which is a horizontal SCROLLBAR the moment a focus ring
     (outline-offset: 3px on a full-width control) reaches past the column. Authored `clip` computes here to a
     non-scrollable sealed axis (engines may report it as hidden with no scrollable overflow in the used value —
     measured in Chromium; the outcome, no horizontal scrollbar ever, is identical and is what [TS-98] asserts). The
     screen's own side padding is what gives the focus ring room inside the sealed axis, which is why the scroller is
     the screen and not the column inside it.
     A `fit` screen reaching this scroll is a DEFECT the ramp should have absorbed, and [TS-118] measures it. A `flow`
     screen (a legal document — long by nature, declared as such by the owner in src/config/screen-fit.js) reaching it
     is the intended reading behaviour. Same mechanism, two meanings, one declaration deciding which.
     overscroll-behavior-y: contain stops a phone from chaining a rubber-band from this region to the shell. */
  display: grid; place-items: start center; min-height: 0;
  /* ONE ROW, AND IT MAY NOT EXCEED THE SCREEN (r2026-08-24k). Every state lives in grid-area 1/1, so this grid has a
     single row — and left implicit it is sized `auto`, i.e. max-content. With content taller than the screen there is
     no free space to stretch into, so the row grew to the content, the grid area became as tall as the overflow, and
     `max-height: 100%` on the state resolved against that same overflowing area: a percentage of the very thing it
     was meant to be limiting. The cap silently did nothing, so the flexible rows inside the capture screens never
     came under any pressure to shrink — which is why the writing field sat at its natural four rows at every window
     height while the ramp squeezed the margins around it. Measured: row 565.6px inside a 520.3px content box.
     minmax(0, 1fr) makes the row exactly the screen's own content height AND definite, so the cap resolves against a
     real number and the flex columns below can do their work. Every screen added later inherits this. */
  grid-template-rows: minmax(0, 1fr);
  overflow: clip auto; overscroll-behavior-y: contain; scroll-padding-top: var(--dd-s6);
  padding: max(var(--dd-fit-pad-top), env(safe-area-inset-top)) max(var(--dd-fit-pad-side), env(safe-area-inset-right))
           max(var(--dd-fit-pad-bottom), env(safe-area-inset-bottom)) max(var(--dd-fit-pad-side), env(safe-area-inset-left));
}
/* ---- FIT LAYER ---------------------------------------------------------------------------------------------------
   Each value is the reference value MINUS a declared share of the height the viewport is missing against the 844
   design (--dd-short, styles/tokens.css), floored by a token. At 844 and taller --dd-short is 0px, so every one of
   them resolves to the reference number and the 1:1 geometry below is untouched — the reference is not approximated,
   it is the top of the ramp. The shares add up to slightly more than 1, so the column shortens a little faster than
   the window and the first screen keeps slack instead of finding the fold. Type is absent on purpose (see the tokens
   file): space and decoration yield, letterforms do not. Every floor is a token or a fraction of one, so no new
   number enters the stylesheet as a size of its own. */
.screen {
  --dd-fit-pad-top: max(var(--dd-s3), calc(var(--dd-s7) - var(--dd-short) * 0.1));
  /* the floor is not a taste number: the QA entry is anchored at bottom:--dd-s4 and is --dd-control-min tall, so the
     reservation may never fall below --dd-qa-dock (styles/tokens.css), which is derived from exactly those two values.
     Below it the instrument would sit on the last product row — measured at 360x640 before the floor existed: -14.3px
     of clearance, i.e. an overlap. A floor derived from the thing it makes room for cannot drift out of agreement with
     it, and a build without the QA console sets --dd-qa-dock to 0px and gets those 80px back for the design. */
  --dd-fit-pad-bottom: max(var(--dd-qa-dock), calc(var(--dd-s7) + var(--dd-control-h) - var(--dd-short) * 0.24));
  --dd-fit-pad-side: min(var(--dd-s5), 6.2vw);
  --dd-fit-brand-moon: max(var(--dd-s2), calc(var(--dd-gap-brand-moon) - var(--dd-short) * 0.07));
  --dd-fit-moon-title: max(var(--dd-s3), calc(var(--dd-gap-moon-title) - var(--dd-short) * 0.08));
  --dd-fit-title-lede: max(var(--dd-s1), calc(var(--dd-gap-title-lede) - var(--dd-short) * 0.01));
  --dd-fit-lede-mic: max(var(--dd-s3), calc(var(--dd-gap-lede-mic) - var(--dd-short) * 0.12));
  --dd-fit-mic-action: max(0px, calc(var(--dd-gap-mic-action) - var(--dd-short) * 0.01));
  --dd-fit-action-cta: max(var(--dd-s2), calc(var(--dd-gap-action-cta) - var(--dd-short) * 0.08));
  --dd-fit-cta-footnote: max(var(--dd-s2), calc(var(--dd-gap-cta-footnote) - var(--dd-short) * 0.08));
  /* floored at 0.45 rather than 0.55 (r2026-08-24g): the disc is the tallest single row on the microphone-unavailable
     screen, whose copy runs to three lines in the embedded-frame case, and 63px still carries the crescent legibly.
     Home never reaches this floor — it fits well above it. */
  --dd-fit-moon: max(calc(var(--dd-moon-box) * 0.45), calc(var(--dd-moon-box) - var(--dd-short) * 0.2));
  --dd-fit-moon-loader: max(calc(var(--dd-moon-loader-w) * 0.6), calc(var(--dd-moon-loader-w) - var(--dd-short) * 0.357));
  --dd-fit-mic: max(calc(var(--dd-mic-ring) * 0.55), calc(var(--dd-mic-ring) - var(--dd-short) * 0.18));
  --dd-fit-mic-core: max(calc(var(--dd-mic-core) * 0.67), calc(var(--dd-mic-core) - var(--dd-short) * 0.13));
  /* the emblem GLYPH is a fraction of the disc, not a size of its own: 84 of the reference's 140 is 0.6, so writing it
     as a percentage keeps the reference proportion at every disc size and needs no ramp of its own. Sizing it in px
     was the bug behind this screen scrolling — and shrinking it independently would have left a small glyph rattling
     inside a large disc. */
  --dd-fit-control: max(var(--dd-control-min), calc(var(--dd-control-h) - var(--dd-short) * 0.02));
  --dd-fit-cta: max(var(--dd-control-min), calc(var(--dd-cta-h) - var(--dd-short) * 0.03));
  --dd-fit-gap: max(var(--dd-s2), calc(var(--dd-s4) - var(--dd-short) * 0.03));
  --dd-fit-card-pad: max(var(--dd-s3), calc(var(--dd-s5) - var(--dd-short) * 0.05));
  /* the capture screens carry their own reference-pinned margins (02_/03_/05_ of the handoff), each an exact
     measurement rather than a scale step. One shared trim keeps them proportional to each other while the column
     compresses: every one of them subtracts the SAME length and keeps its own floor, so their relative rhythm holds.
     The recording disc and the writing area shrink with it — 116 px and 168 px are generous, and the floors leave the
     disc at 72 px (half again the touch floor) and the field at three full lines of its own reading size. */
  /* r2026-08-24k: the share was 0.06, which gave each capture offset ~18px at a 619-tall window and left the three
     capture screens ~50px short — a ramp that compensates for half of what is missing is a ramp that still scrolls.
     0.11 is the measured share that closes it with every floor still intact, and at the design height it is still 0. */
  --dd-fit-trim: calc(var(--dd-short) * 0.11);
  --dd-fit-stop: max(calc(var(--dd-control-min) * 1.5), calc(116px - var(--dd-short) * 0.16));
}
/* PER-SCREEN RAMP OFFSET (DF-80). The ramp above is driven by the viewport and the design height, and it cannot see
   how many LINES a screen's copy took: type stays in rem on purpose (a viewport-unit font-size cannot answer a
   text-only zoom, WCAG 1.4.4), so the one quantity that varies with the words is the one the stylesheet is blind to.
   The microphone-unavailable screen is where that shows: its headline takes two lines in every locale where the
   reference takes one, and three in the embedded-frame variant, so it kept scrolling while Home — same fit layer —
   fitted. Its ramp therefore starts two title lines earlier. Declared per screen and not measured at runtime: the
   startup budget has 44 bytes of headroom ([TS-81], M-ST-13), and a measuring compensator is 2 kB of it. A screen
   added later that overruns its reference declares its own offset here, in one line, next to this reasoning.
   :has() is the right selector because the offset belongs to the SCREEN's ramp, which is computed on .screen. */
.screen:has(> #mic-denied:not([hidden])) { --dd-short: max(0px, calc(var(--dd-fit-h) + var(--dd-title-box) * 3 - 100svh)); }

/* F-02, F-03 (browser-proven at 320 px / German / 200% text / a 240-character unbroken token): a grid item's
   automatic minimum size is its min-content width, so one long localised word or one unbreakable user string could
   push the whole column past the viewport. Zero-min on every shared item plus a declared wrapping policy for text
   roles keeps the page one-dimensional without shrinking type or forking a locale. */
.state, .state > *, .card, .card > *, .action, .footnote, .doclist, .doclist__item, .doclist__btn { min-width: 0; }
.title, .lede, .body, .action__label, .action__hint, .card__note, .notice p, .footnote p, .transcript, .brand__word { overflow-wrap: anywhere; }
.state { grid-area: 1 / 1; width: var(--dd-content-w); display: grid; gap: var(--dd-fit-gap); align-content: start; }
/* WIDTH BUYS HEIGHT (DF-88). The column is capped at the reference phone measure, 342px. On a viewport that is SHORT
   and WIDE — an embedded preview, a phone in landscape, a tablet in landscape — that cap is the reason the page
   scrolls: the height is scarce, the width is sitting unused, and a headline that would take one line at 500px takes
   three at 342. The ramp cannot see that, because line count is text and type stays in rem (WCAG 1.4.4).
   So where height is scarce and width is available, the column takes the width. 34rem is a reading measure, not a
   guess: it keeps the line length inside the 45-75 character band typography has used for a century, which is why it
   is also the right cap on a desktop window. The reference is untouched: at 390 the 100% branch still wins at 342,
   and above 800px of height nothing changes at all. */
@media (max-height: 800px) and (min-width: 480px) {
  .screen { --dd-content-w: min(100%, var(--dd-measure)); }
}

/* loader */
/* First-load motion policy (DF-18, TS-39): the loader asset is painted with the document, so on a fast start its
   animation would begin and end inside a window nobody can read — the flash the owner reported. src/app.js releases
   the animation only after loaderAnimateAfterMs (src/config/ui-timing.js); until then the crescent is a still,
   branded first screen. Pausing costs nothing: no animation frames are produced while the state is paused. */
html:not([data-loader="animate"]) .moon-loader * { animation-play-state: paused; }
/* Paused at the first keyframe the stars sit at 0.12 opacity, which reads as an empty sky. The still screen uses the
   same value the reduced-motion screen uses, so the calm frame is legible and both static views look identical. */
html:not([data-loader="animate"]) .moon-loader .ml-star { opacity: 0.7; }
/* DF-29: the loader crescent and the Home emblem are the same moon, so they must be the same moon on screen. The
   canvas width comes from --dd-moon-loader-w (derived from --dd-moon-box), and this padding puts the crescent's
   centre exactly where Home puts its emblem's centre: the brand line, the grid gap and half the emblem box, minus
   the distance from the top of the loader svg to the crescent centre. The moon therefore stays put through the
   handover instead of jumping and resizing. */
.state--loader { justify-items: center; z-index: 1;
  padding-top: calc(var(--dd-brand-box) + var(--dd-fit-brand-moon) + var(--dd-fit-moon) / 2 - var(--dd-fit-moon-loader) * 0.3667); }
/* DF-26, DF-28: the loader is a splash over the shell, not one of the stacked states. Every state view lives in the
   same grid cell (grid-area: 1 / 1), and the declared 3 s window keeps the loader on screen while the shell already
   dispatched the skeleton, so the owner saw the skeleton's disc and boxes through the crescent. The rule is
   structural rather than a list of state names: while #loader is not [hidden], every sibling state view is invisible
   — including screens added in later rounds — and the loader stacks above them. visibility keeps their boxes, so
   the handover to the skeleton or to Home still shifts nothing (M-WV-03). */
#loader:not([hidden]) ~ .state { visibility: hidden; }
/* Handover exception (DF-33, DF-34): while the splash dissolves onto Home, ONLY Home may paint under it. The
   skeleton must never become visible here — that is DF-28 (a bare disc showing through the splash) coming back, so
   the exception names #content and nothing else. The splash stops intercepting taps the moment it starts, so the
   mic is live immediately. */
#loader.is-handoff:not([hidden]) ~ #content { visibility: visible; }
#loader.is-handoff { pointer-events: none; }
/* DF-25: the loader is inline SVG, so the browser painted it as it parsed — the halo disc, then a full moon, then
   the crescent — which read as three different objects assembling. Modules are deferred, so src/app.js runs exactly
   when parsing is done: it writes data-loader, and that is the one moment the finished drawing may appear. Before it
   the shell shows the painted canvas and the label, so nothing about first paint regresses. */
/* F-04: the loader visual was width-driven only, so compact-height landscape (568x320) left ~11 px between the
   label and the edge. The canvas is now bounded by the available height too. Geometry is untouched — one canonical
   viewBox, one aspect ratio; only the wrapper's budget changes, and the handover measures live rects, so the
   landing stays exact at any size. */
.moon-loader { width: min(var(--dd-fit-moon-loader), 52svh); height: auto; display: block; opacity: 0; transform: translateX(var(--dd-moon-loader-nudge)); }
/* THE INFINITY MARK IS NOT PAINTED (owner order r2026-08-24s). The approved asset draws a lemniscate trace around
   the moon and the owner read it as an infinity sign, which says "endless" on a screen whose whole promise is that
   it will be over in a moment. The GEOMETRY stays in the asset — it is the approved drawing and [TS-28] holds it
   byte-for-byte, timings included — and the product simply does not show it. What carries the wait instead is the
   sky the app already speaks in: the seven stars, each on its own rhythm, so the splash breathes rather than loops.
   Removing the path from the asset would have meant editing an approved file to change a product decision; hiding
   it says exactly what happened and can be undone in one line. */
.moon-loader > g[mask] { display: none; }
/* THE CUT IS THE CANVAS (DF-150). The approved asset carves the crescent by overpainting the lit field with a disc
   whose fill is the LIGHT canvas literal (#FAF7FF) — correct on the day canvas it was drawn for, and on the night
   canvas a near-white circle beside the crescent: the cutter stopped cutting and became a second moon, which is the
   "crooked separation" the owner reported. The asset stays byte-identical ([TS-28]); the PAINT belongs here, where
   every other loader colour already lives, and it is the backdrop token, so the cut is the canvas in both
   appearances and the crescent is the only moon on the splash. */
.moon-loader .ml-disc { fill: var(--dd-backdrop-base); }
/* THE SPLASH SKY. Positions are hand-placed rather than random because the splash is one composition seen for three
   seconds: a scatter that reads as a night sky beside the moon, never over the wordmark or the label. Two motions
   only — a breath (opacity and scale) and a spark (a brief swell) — with a different duration and a negative delay
   on every child, so no two are ever in step and the first frame is already mid-cycle instead of all-on. */
.loader__sky { position: absolute; inset: 0; pointer-events: none; z-index: 0; }
.loader__sky span { position: absolute; width: 2px; height: 2px; margin: -1px 0 0 -1px; border-radius: 50%;
  background: var(--dd-star-3); opacity: 0.2; animation: dd-splash-breathe 5.4s ease-in-out infinite; }
.loader__sky span:nth-child(1) { left: 18%; top: 22%; animation-duration: 4.6s; animation-delay: -0.4s; }
.loader__sky span:nth-child(2) { left: 31%; top: 13%; animation-duration: 6.1s; animation-delay: -2.2s; background: var(--dd-star-1); }
.loader__sky span:nth-child(3) { left: 72%; top: 17%; animation-duration: 5.2s; animation-delay: -3.4s; }
.loader__sky span:nth-child(4) { left: 84%; top: 29%; animation-duration: 4.1s; animation-delay: -1.1s; background: var(--dd-star-7); }
.loader__sky span:nth-child(5) { left: 12%; top: 41%; animation-duration: 6.8s; animation-delay: -0.9s; }
.loader__sky span:nth-child(6) { left: 88%; top: 47%; animation-duration: 5.7s; animation-delay: -4.0s; background: var(--dd-star-5); }
.loader__sky span:nth-child(7) { left: 24%; top: 62%; animation-duration: 4.9s; animation-delay: -2.8s; }
.loader__sky span:nth-child(8) { left: 78%; top: 68%; animation-duration: 6.3s; animation-delay: -1.7s; background: var(--dd-star-2); }
.loader__sky span:nth-child(9) { left: 46%; top: 8%; animation-duration: 5.0s; animation-delay: -3.9s; }
.loader__sky .spark { width: 3px; height: 3px; margin: -1.5px 0 0 -1.5px; background: var(--dd-star-4);
  animation-name: dd-splash-spark; }
.loader__sky .spark:nth-child(10) { left: 62%; top: 33%; animation-duration: 7.2s; animation-delay: -1.4s; }
.loader__sky .spark:nth-child(11) { left: 27%; top: 34%; animation-duration: 8.6s; animation-delay: -5.1s; }
.loader__sky .spark:nth-child(12) { left: 69%; top: 57%; animation-duration: 9.4s; animation-delay: -3.2s; }
@keyframes dd-splash-breathe { 0%, 100% { opacity: 0.14; transform: scale(0.7); } 50% { opacity: 0.72; transform: scale(1.15); } }
/* the spark is mostly OFF: a point that flares for a fifth of its cycle and rests for the rest of it, which is what
   makes it an event rather than a pulse. */
@keyframes dd-splash-spark {
  0%, 72%, 100% { opacity: 0.10; transform: scale(0.6); }
  80% { opacity: 1; transform: scale(1.7); }
  88% { opacity: 0.35; transform: scale(1); }
}
/* the stars, out of step. One keyframe set, seven phases: the same trick the Home sky uses, and the reason a
   still frame of the splash never looks like a row of identical dots. The 4.8 s cycle is the approved value. */
.moon-loader .ml-s1 { animation-delay: 0s; animation-duration: 4.8s; }
.moon-loader .ml-s2 { animation-delay: -0.7s; animation-duration: 5.6s; }
.moon-loader .ml-s3 { animation-delay: -1.5s; animation-duration: 4.1s; }
.moon-loader .ml-s4 { animation-delay: -2.2s; animation-duration: 6.3s; }
.moon-loader .ml-s5 { animation-delay: -3.1s; animation-duration: 4.8s; }
.moon-loader .ml-s6 { animation-delay: -0.4s; animation-duration: 5.2s; }
.moon-loader .ml-s7 { animation-delay: -2.6s; animation-duration: 3.7s; }
html[data-loader] .moon-loader { opacity: 1; transition: opacity var(--dd-dur-trans) var(--dd-ease-out); }
/* Moon-loader motion and paint. It used to be duplicated inside both the asset and its inline copy; one place is
   the rule (SINGLE_CODEBASE_RULES), so the shipped SVG is geometry only and every timing lives here:
   6.2s / 7s / 5.4s internal drift, 4.8s stars, 3.2s trace travel, 13.5s reveal — the approved values, unchanged. */
@keyframes ml-b1 { 0%, 100% { transform: translate(0, 0) scale(1); } 33% { transform: translate(26px, 18px) scale(1.08); } 66% { transform: translate(-14px, 34px) scale(0.95); } }
@keyframes ml-b2 { 0%, 100% { transform: translate(0, 0) scale(1.05); } 50% { transform: translate(-30px, -24px) scale(0.9); } }
@keyframes ml-b3 { 0%, 100% { transform: translate(0, 0); } 50% { transform: translate(24px, -30px); } }
@keyframes ml-twinkle { 0%, 100% { opacity: 0.12; transform: scale(0.7); } 50% { opacity: 0.95; transform: scale(1.25); } }
@keyframes ml-travel { from { stroke-dashoffset: 0; } to { stroke-dashoffset: -72; } }
@keyframes ml-reveal { from { stroke-dashoffset: 100; } to { stroke-dashoffset: 0; } }
.moon-loader .ml-b1 { animation: ml-b1 6.2s ease-in-out infinite; transform-origin: center; }
.moon-loader .ml-b2 { animation: ml-b2 7s ease-in-out infinite; transform-origin: center; }
.moon-loader .ml-b3 { animation: ml-b3 5.4s ease-in-out infinite; transform-origin: center; }
.moon-loader .ml-star { transform-box: fill-box; transform-origin: center; animation: ml-twinkle 4.8s ease-in-out infinite; }
.moon-loader .ml-s2 { animation-delay: 0.8s; }
.moon-loader .ml-s3 { animation-delay: 1.7s; }
.moon-loader .ml-s4 { animation-delay: 2.6s; }
.moon-loader .ml-s5 { animation-delay: 3.3s; }
/* the traced path reads dusty blue → lilac → blush → pearl-gold so its direction is legible; it stays an
   indeterminate startup indicator and never reports a percentage */
.moon-loader .ml-trace { fill: none; stroke: var(--dd-accent); stroke: url(#ml-trace-gradient) var(--dd-accent); stroke-width: 1.4; stroke-linecap: round; stroke-linejoin: round; stroke-dasharray: 6 12; opacity: 0.85; animation: ml-travel 3.2s linear infinite; }
.moon-loader .ml-reveal-path { animation: ml-reveal 13.5s cubic-bezier(0.18, 0.68, 0.22, 1) forwards; }
/* PROCESSING INDICATOR — the lemniscate without the moon (r2026-08-24).
   It reuses the ml-travel motion declared above rather than adding a second travelling-dash rule: one motion
   vocabulary, one place to change it. The gradient is the loader's, so the mark reads as the same object in a
   smaller role. Reduced motion stops the dashes and leaves the path drawn — the shape still says "working", which is
   what a still frame must do when motion is refused.
   The at-rule keyword is deliberately NOT written in this comment: [TS-42] locates blocks by scanning for it in raw
   text, so naming it in prose made the scanner read the next ordinary rule as a keyframe body and report a false
   hit on margin. Fourth occurrence of that mechanism this round — see DF-74. */
.job { display: flex; flex-direction: column; align-items: center; gap: var(--dd-s1); margin: 0 0 var(--dd-s2); }
.job__mark { width: 96px; height: 42px; display: block; }
.job__trace { fill: none; stroke: var(--dd-accent); stroke: url(#ml-trace-gradient) var(--dd-accent); stroke-width: 3; stroke-linecap: round; stroke-linejoin: round; stroke-dasharray: 6 12; opacity: 0.9; animation: ml-travel 3.2s linear infinite; }
.job__stage, .job__elapsed { margin: 0; text-align: center; }

/* RESERVED TEXT REGIONS (r2026-08-24c). Every region below swaps one message for another WITHOUT the user doing
   anything — a transcription finishes, a network drops, a job fails — so the space is reserved up front rather than
   letting the card grow under the reader's eyes. Claude Design measured one of these at 22.4 px on en-US/390.
   The line counts are NOT chosen here: src/config/text-regions.js computes them from the longest string each region
   can display across every production locale, and [TS-96] recomputes them and fails if a translation outgrows one.
   `lh` is the element's own line-height, so the reservation follows the type scale instead of freezing a pixel. */
#job-stage     { min-height: 3lh; }
#error-body    { min-height: 3lh; }
#review-pending { min-height: 3lh; }
/* r2026-08-24j. The recording card's note is one slot carrying three messages (ordinary, sound-not-arriving,
   persistence-degraded) and two of them arrive with no user action at all, so the slot reserves the longest.
   #record-ended is written once per recording, together with the duration inside it, and reserves the sentence's own
   wrapping so the box is right wherever it is shown. Both counts come from src/config/text-regions.js. */
#record-note   { min-height: 2lh; }
#record-ended  { min-height: 2lh; }
.job__elapsed { color: var(--dd-text-muted); }
/* a finished or failed job is not "working": the motion stops, so stillness carries meaning instead of decorating */
.job[data-job-stage="completed"] .job__trace, .job[data-job-stage="failed"] .job__trace,
.job[data-job-stage="insufficient"] .job__trace { animation: none; opacity: 0.35; }
/* r2026-08-24b — the 46 px jump Claude Design reported. Acceptance points 1 and 4 looked contradictory: "must not
   shift the card" against "the mark is hidden when the job ends". They are not contradictory; `display: none` was
   simply the wrong way to hide.
   The deciding rule is WHO caused the change. Published CLS guidance treats a shift that follows a user action as
   acceptable and a shift that happens automatically as one to design out by reserving the space up front. A
   transcription finishing is not a user action — it lands on its own, while the user is reading — so the region
   reserves its height and the mark is hidden with `visibility`, which keeps the box.
   The empty 46 px that remains is the correct trade, not a leftover: the same guidance says to include regions for
   system messages in the layout from first paint even when they are empty. A stable card is worth more than a
   reclaimed strip the user never asked for. */
.job[data-job-stage="completed"] .job__mark, .job[data-job-stage="failed"] .job__mark { visibility: hidden; }

.loader__label { margin: 0; color: var(--dd-text-muted); font-size: var(--dd-fs-body); text-align: center; }

/* skeleton (.sk) — the same boxes Home paints, so the swap never shifts layout (TS-08).
   The sweep is a transform on an overlay, not an animated background-position: a moving background repaints
   every box on the main thread every frame, which on a mid-range Android is main-thread work spent on a
   placeholder (WV-TBT/WV-INP lever "long tasks, main-thread work during input"). A translated overlay is
   composited, so the skeleton costs no main-thread paint while the shell is still starting. */
.sk { position: relative; overflow: hidden; border-radius: var(--dd-r1); background: var(--dd-surface-2); }
.sk::after { content: ""; position: absolute; inset: 0; transform: translateX(-100%);
  background: linear-gradient(90deg, transparent 0%, var(--dd-surface) 50%, transparent 100%);
  animation: dd-sk-sweep 1.4s linear infinite; }
.sk--brand { height: var(--dd-brand-box); width: 96px; }
.sk--moon { height: var(--dd-moon-box); width: var(--dd-moon-box); border-radius: 50%; justify-self: center; }
/* F-01: the title and the lede placeholders reserve height by laying out the real localized string in the real
   typography, invisibly — no fixed line count. Every other role keeps its declared control geometry, which does not
   depend on text. */
.sk--title { min-height: var(--dd-title-box); width: 100%; }
/* On-demand document placeholder (S1/S2). It reuses .sk, so it inherits the one sweep, the one radius and both
   reduced-motion stops; only the row geometry is new, and it mirrors the document's own roles. */
.legal__pending { display: grid; gap: var(--dd-s2); }
/* DF-66: the document opens with its return control, so the placeholder reserves that box first. Without it the
   title started 68 px too high and the whole document jumped down on arrival — the shift a skeleton exists to
   prevent. The height is the shared control token, and the 8 px margin plus the 8 px grid gap is the 16 px the real
   control leaves under itself. The width is proportional, not a pixel count: the label is translated. */
.sk--doc-action { min-height: var(--dd-control-h); width: 45%; margin-bottom: var(--dd-s2); }
/* DF-66: measured on the installed tree, all eight shipped documents render their title on exactly two lines at the
   content width, so two line boxes is the reservation — the same construction #mic-denied .title uses. The height
   comes from --dd-fs-title-compact, the property the heading itself is sized by, rather than a repeated number. */
.sk--doc-title { min-height: calc(var(--dd-fs-title-compact) * var(--dd-lh-title) * 2); width: 88%; }
.sk--doc-meta { min-height: var(--dd-lede-box); width: 40%; }
.sk--doc-line { min-height: var(--dd-lede-box); width: 100%; }
.sk--doc-line-short { width: 62%; }
.sk--lede { min-height: var(--dd-lede-box); width: 100%; }
/* the ghost must carry EVERY property that affects line breaking, or it reserves a different number of lines than
   the role it stands for — overflow-wrap included (it changes both min-content size and the break opportunities) */
.sk__ghost { display: block; color: transparent; visibility: hidden; overflow-wrap: anywhere; }
.sk--title .sk__ghost { font-family: var(--dd-font-display); font-size: var(--dd-fs-title); line-height: var(--dd-lh-title); font-weight: 760; letter-spacing: -0.01em; text-wrap: balance; }
.sk--lede .sk__ghost { font-size: var(--dd-fs-lead); line-height: var(--dd-lh-body); }
.sk--mic { height: var(--dd-fit-mic); width: var(--dd-fit-mic); border-radius: 50%; justify-self: center; }
.sk--button { height: var(--dd-fit-control); width: 100%; border-radius: var(--dd-pill); }
@keyframes dd-sk-sweep { to { transform: translateX(100%); } }

/* type */
/* every product paragraph carries its own rhythm through the grid gap, never through UA margins — the
   skeleton predicts the same boxes, so a stray 1em margin would shift the swap (TS-08) */
.brand, .action, .topbar, .card__head, .card__meta, .card__note, .assure, .notice__head { margin: 0; }
/* DF-45: the mark's viewBox (22 44 16 17) already carries the reference's own vertical relation to the wordmark, so
   the box is anchored to the text baseline rather than to the centre of the line box. Centred, the star landed 3.9 px
   below where 01_HOME.svg draws it (the reference puts its centre 5 px above the DREAM baseline, centring left it
   1.1 px above). Both margins are the reference's own numbers, not tuning: the viewBox bottom (y 61) sits 1 px below
   the DREAM baseline (y 60), and its top (y 44) sits 4 px above the column top (y 48) — the reference really does let
   the violet dot (y 47..51) rise past the column edge. Together they put the mark's box at 44..61 and the wordmark
   baseline at 60, which is the reference verbatim. The wordmark drops to line-height 1 for the same reason: with the
   body's 1.6 the half-leading above the cap made the TEXT the tallest thing over the baseline, so it — not the
   reference geometry — decided where the line sat, and the whole lockup rode 2 px low. */
/* min-height, not height: at a 200% text-only zoom the wordmark needs a second line (measured: it left the viewport
   at 390px before flex-wrap), and a fixed height would clip what wrapping just saved. At 100% the content is exactly
   --dd-brand-box tall, so the reference row is unchanged. */
.brand { display: flex; align-items: baseline; gap: 6px; min-height: var(--dd-brand-box); flex-wrap: wrap; }
.brand__mark { width: 16px; height: 17px; flex: none; align-self: baseline; margin: -4px 0 -1px; }
.brand__dot { fill: #8e79d4; opacity: 0.65; }
.brand__word { font-size: 11px; font-weight: 700; letter-spacing: 2.5px; color: var(--dd-lockup-ink); text-transform: uppercase; line-height: 1; }
.title { margin: 0; font-family: var(--dd-font-display); font-size: var(--dd-fs-title); line-height: var(--dd-lh-title); font-weight: 760; min-height: var(--dd-title-box); text-wrap: balance; letter-spacing: -0.01em; }
/* every capture screen's title is 30 px and left-aligned in the references; Home's is 32 px and centred */
.state--capture .title, .state--error .title { font-size: var(--dd-fs-title-capture); }
/* DF-47: Home and its skeleton carry the Home subtitle size. Declared as a custom-property override on the subtree
   rather than a font-size rule, so everything derived from it follows in lockstep — .lede, --dd-lede-box (the
   min-height reservation) and the skeleton's own ghost — and the swap cannot start reserving a different box from
   the one the content fills. */
.state--content, .state--skeleton { --dd-fs-lead: var(--dd-fs-lead-home); --dd-fs-title: var(--dd-fs-title-home); }
.lede { margin: 0; min-height: var(--dd-lede-box); font-size: var(--dd-fs-lead); color: var(--dd-text-muted); }
.body { margin: 0; min-height: var(--dd-body-box); color: var(--dd-text-muted); max-width: 65ch; }
/* HOME + SKELETON vertical rhythm — 1:1 with uploads/01_HOME.svg (see the gap tokens). One gap value for the whole
   column was the F-01 cause: the skeleton and Home agreed with each other but neither agreed with the reference. */
.state--content, .state--skeleton { gap: 0; }
/* r2026-08-24p: the CAPTURE half of these pairs is DELETED, not overridden. The capture surface is now the approved
   390x844 artboard and the stage block below places every element by its own reference coordinate, so a fluid
   margin on the same element is a second opinion about the same pixel — and the one that wins on specificity is not
   always the one you expect (it cost 13.2 px on the microphone before it was found). What remains here is the
   SKELETON, which still uses the fluid model because it stands in for the surface before the surface exists. */
.sk--moon { margin-top: var(--dd-fit-brand-moon); }
.sk--title { margin-top: var(--dd-fit-moon-title); }
.sk--lede { margin-top: var(--dd-fit-title-lede); }
.sk--mic { margin-top: var(--dd-fit-lede-mic); }
.sk--button { margin-top: calc(var(--dd-fit-mic-action) + var(--dd-fit-action-cta) + var(--dd-fs-body) * var(--dd-lh-body) + var(--dd-fs-small) * var(--dd-lh-body) + 3px); }

/* Home emblem — 1:1 with uploads/01_HOME.svg. Every value below is the reference's own; nothing is styled by taste. */
.moon { width: var(--dd-fit-moon); height: var(--dd-fit-moon); display: block; justify-self: center; overflow: visible; }
.moon__halo { fill: var(--dd-brand-halo); opacity: 0.44; }
.moon__cut { fill: var(--dd-moon-cut); }
.moon__shadow { flood-color: var(--dd-brand-plum); flood-opacity: 0.16; }
/* gradient stops take stop-color, not fill: with `fill` the stop falls back to black and the moon paints as a dark
   disc — the artefact this round removed everywhere else */
.moon__sheen { fill: var(--dd-sheen); }
.g-s1 { stop-color: var(--dd-brand-blue); }
.g-s2 { stop-color: var(--dd-brand-violet); }
.g-s3 { stop-color: var(--dd-brand-blush); }
.g-s4 { stop-color: var(--dd-brand-gold-light); }
.sky__gold { fill: var(--dd-brand-gold); }
.sky__lilac { fill: var(--dd-accent); opacity: 0.42; }
.sky__blue { fill: var(--dd-brand-blue-deep); opacity: 0.45; }
.sky__blush { fill: var(--dd-brand-blush); opacity: 0.72; }
.moon .sky__lilac { opacity: 0.5; }
.moon .sky__blue { opacity: 0.55; }
/* the mic outline, 1:1 with the reference: stroke #2D2142 at 2.6, no fill. The `.title` fragment welded to this
   selector in an earlier round made it match nothing, so the glyph fell back to a solid black fill. */
.btn__glyph { fill: none; stroke: currentColor; stroke-width: 2.6; stroke-linecap: round; stroke-linejoin: round; }
/* the big mic draws at the reference stroke 3, in white on the gradient disc */
.mic__glyph { fill: none; stroke: #ffffff; stroke-width: 3; stroke-linecap: round; stroke-linejoin: round; }
/* The JS-disabled fallback must never paint while scripting is on. Browsers hide <noscript> themselves, but its
   content is raw text and print/PNG/PPTX rasterisers have been seen to lay it out anyway; this states the intent. */
noscript { display: none; }
.btn__glyph { stroke-width: 3; }
.emblem__mic { fill: none; color: var(--dd-text-muted); stroke: currentColor; stroke-width: 2.6; stroke-linecap: round; stroke-linejoin: round; }
.emblem__slash { stroke: var(--dd-error-mark); stroke-width: 3; stroke-linecap: round; }
.iconbtn svg { fill: none; stroke: currentColor; stroke-width: 2.4; stroke-linecap: round; stroke-linejoin: round; color: var(--dd-text); }

/* controls */
.btn { min-height: var(--dd-fit-control); padding: 0 var(--dd-s5); border: 0; border-radius: var(--dd-pill);
  font: inherit; font-weight: 700; cursor: pointer; touch-action: manipulation; color: var(--dd-on-primary);
  transition: opacity var(--dd-dur-micro) var(--dd-ease-out), transform var(--dd-dur-micro) var(--dd-ease-out); }
/* Shadow — 1:1 with the reference filter: feDropShadow dy=10 stdDeviation=15 means a CSS blur radius of 30 (twice
   the deviation), not 15. Every disc and card the reference filters carries exactly this. */
.btn--primary { min-height: var(--dd-fit-cta); width: 100%; background: linear-gradient(90deg, var(--dd-primary) 0%, var(--dd-primary-2) 100%); box-shadow: 0 10px 30px var(--dd-shadow-soft); }
.btn--secondary { width: 100%; background: var(--dd-surface-2); border: 1px solid var(--dd-border); display: inline-flex; align-items: center; justify-content: center; gap: var(--dd-s2); font-weight: 750; }
.btn--ghost { background: transparent; color: var(--dd-text); border: 1px solid var(--dd-border); }
.btn__glyph { width: 18px; height: 26px; flex: none; }
.btn:active { transform: scale(0.98); }
/* DF-55: an <a> wearing .btn is not a <button>. It brings a UA underline, aligns its label to the inline start and,
   being a block box rather than a button's centred anonymous one, drops the label at the TOP of the pill instead of
   its middle — the embedded escape action was the first anchor in the product to wear .btn and painted underlined,
   left-aligned and 33 px high in a 56 px pill. Scoped to a.btn so the pixel-verified <button> geometry is untouched. */
a.btn { display: inline-flex; align-items: center; justify-content: center; text-align: center; text-decoration: none; }
.btn[disabled] { opacity: 0.5; cursor: not-allowed; }
.iconbtn { width: var(--dd-control-min); height: var(--dd-control-min); min-height: var(--dd-control-min); padding: 0; border-radius: 50%;
  background: var(--dd-surface-2); border: 1px solid var(--dd-border); display: grid; place-items: center; cursor: pointer; }
.iconbtn svg { width: 20px; height: 20px; }
/* ONE HEADER ON EVERY SCREEN (owner order r2026-08-24w8). Screen 1 puts the lockup at the top centre with a corner
   control on each side; every other screen used to open with a bare back button on the left and, on the denied
   screen, a second lockup under it — which is what collided in the owner's screenshot. The header is now one row of
   fixed height on all of them: the way back sits where the moon sits on screen 1 (so the moon IS replaced by the way
   back), the lockup is centred in the same row, and the settings glyph arrives in that corner from the shell bar.
   Three columns of the control's own width, so the centre column is centred against the screen, not the leftover. */
.topbar { display: grid; grid-template-columns: 48px 1fr 48px; align-items: center;
  min-height: var(--dd-control-min); margin: 0; }
.topbar .brand--head { grid-column: 2; justify-content: center; }
/* THE SETTINGS GLYPH BELONGS TO THE ROW IT COMPLETES (DF-166, second pass). It was a shell control with the artboard's
   own coordinate (top: 46px) inside a viewport-fixed bar, so on a flow screen it hung 34 px below the other two
   controls and, once the screen scrolled, stayed pinned to the window while the header moved: measured 73 px apart.
   It is now the third child of each header, in the third track — one row, three controls, one scroll. */
.topbar .headbtn { grid-column: 3; justify-self: end; width: 48px; height: 48px; min-height: 48px; padding: 0;
  border: 0; background: transparent; border-radius: 50%; display: grid; place-items: center; cursor: pointer; }
.topbar .headbtn .sliders { width: 48px; height: 48px; }
.topbar .backbtn { grid-column: 1; justify-self: start; }
/* a header lockup is a span (a <p> may not nest in a <p>), so it carries the brand row's own box */
.brand--head { display: inline-flex; align-items: center; gap: 6px; }

/* Home: one dominant voice action, one secondary text route */
/* SCREEN_01: halo r70 at 12%, disc r58 with radialGradient cx45% cy30% r72% (#B7A5FF->#8A6CF6 dark,
   #A08CF6->#7761EB light), drop shadow dy10 blur14 at 16%, no border anywhere */
.mic { width: var(--dd-fit-mic); height: var(--dd-fit-mic); min-height: var(--dd-fit-mic); padding: 0; border-radius: 50%;
  background: color-mix(in srgb, var(--dd-mic-halo) 12%, transparent); border: 0;
  display: grid; place-items: center; cursor: pointer; justify-self: center; }
.mic__core { width: var(--dd-fit-mic-core); height: var(--dd-fit-mic-core); border-radius: 50%; display: grid; place-items: center;
  background: radial-gradient(72% 72% at 45% 30%, var(--dd-mic-core-1) 0%, var(--dd-mic-core-2) 100%);
  box-shadow: 0 10px 14px rgba(0, 0, 0, 0.16); }
.mic__glyph { width: 32px; height: 46px; }
.action { display: grid; gap: 3px; justify-items: center; }
.action__label { font-weight: 700; }
.action__hint { font-size: var(--dd-fs-small); color: var(--dd-text-muted); }
.footnote { display: grid; gap: 13px; justify-items: center; }
.footnote hr { width: 148px; height: 1px; margin: 0; border: 0; background: var(--dd-border); }
.footnote p { margin: 0; font-size: var(--dd-fs-micro); color: var(--dd-text-muted); text-align: center; }

/* card: the near-solid reading and input surface. Long text never sits on the gradient. */
.card { background: var(--dd-surface); border: 1px solid var(--dd-border); border-radius: var(--dd-r3);
  padding: var(--dd-fit-card-pad); display: grid; gap: var(--dd-fit-gap); box-shadow: 0 10px 30px var(--dd-shadow-soft); }
.card__head { display: flex; align-items: center; gap: var(--dd-s2); }
.card__eyebrow { font-size: var(--dd-fs-eyebrow-card); font-weight: 800; letter-spacing: var(--dd-track-eyebrow-card); color: var(--dd-text-muted); text-transform: uppercase; }
.card__head .card__eyebrow, .card__head .state-label { margin-right: auto; }
.state-label { font-size: var(--dd-fs-small); font-weight: 700; }
.timer { font-family: var(--dd-font-mono); font-size: var(--dd-fs-small); color: var(--dd-text-muted); font-variant-numeric: tabular-nums; }
.dot { width: 10px; height: 10px; border-radius: 50%; flex: none; background: var(--dd-brand-gold); }
.card__meta { display: flex; justify-content: space-between; gap: var(--dd-s3); border-top: 1px solid var(--dd-border); padding-top: var(--dd-s3);
  font-size: var(--dd-fs-micro); color: var(--dd-text-muted); }
.card__note { margin: 0; font-size: var(--dd-fs-micro); color: var(--dd-text-muted); text-align: center; }

/* the waveform bars: 5 px on a 14 px step — the reference's own geometry */
.wave { display: flex; align-items: center; justify-content: center; gap: 9px; height: 76px; }
/* THE BARS ARE THE SIGNAL (V8 AC-CAP-05/06, r2026-08-24n). No keyframe, no pattern, no colour-only state: the
   height IS the energy, written per frame as an inline scaleY off this 8 px base by DD.ui.setBars from the real RMS.
   8 px is therefore both the silence baseline and the scale denominator — changing it here without changing
   BAR_BASE_PX in src/ui/components.js is the defect the verifier caught (a 68 px base ran the model at 8.5x).
   The superseded rule set — a 68 px base, the muted ink, .wave__bar.is-live, @keyframes dd-wave and the --h/--i
   custom properties — is deleted rather than overridden, because an overridden rule is a rule that comes back. */
.wave__bar { width: 4px; height: 8px; border-radius: 2px; background: var(--dd-wave);
  transform-origin: center; transform: scaleY(1); }
.stop { width: var(--dd-fit-stop); height: var(--dd-fit-stop); min-height: var(--dd-fit-stop); padding: 0; border-radius: 50%; border: 0; cursor: pointer; justify-self: center;
  background: linear-gradient(90deg, var(--dd-mic-core-1) 0%, var(--dd-mic-core-2) 100%); box-shadow: 0 10px 30px var(--dd-shadow-soft);
  display: grid; place-items: center; }
.stop__glyph { width: 30px; height: 30px; border-radius: 7px; background: var(--dd-text); }

/* transcript + text capture */
/* the transcript is read at 17 px in the reference; the text-capture field is written at 16 px */
.transcript { width: 100%; min-height: max(calc(var(--dd-fs-read) * var(--dd-lh-read) * 3), calc(168px - var(--dd-short) * 0.14)); padding: 0; margin: 0; font: inherit; font-size: var(--dd-fs-read); line-height: var(--dd-lh-read);
  color: var(--dd-text); background: transparent; border: 0; resize: none; }
#capture-text .transcript { font-size: var(--dd-fs-body); }
.transcript::placeholder { color: var(--dd-text-muted); }
.transcript:focus-visible { outline-offset: 6px; }
.assure { display: flex; align-items: center; gap: var(--dd-s2); font-size: var(--dd-fs-micro); color: var(--dd-text-muted); }
.assure .dot { background: var(--dd-success); }

/* CAPTURE + DENIED vertical rhythm and geometry — 1:1 with uploads/02..05. Reference y positions: back button 48,
   title baseline 145 (30 px), lede 176 (15 px), card 226 on Recording and 218 on Transcript/Text, the primary CTA
   635 (56 px), the secondary 707 (52 px). Card heights are the reference's (252 / 320 / 356) so the actions below
   them land where the reference puts them. */
/* THE TEXT SCREENS ON THE 8 SCALE (owner order r2026-08-27: "the spacing on the screens where text is entered is
   bad"). These were optical one-offs - 23.5 / 2.4 / 33.75 px - carried over from measuring the artboard by eye. The
   board is a fixed 390x844 with --dd-short pinned to 0, so there is nothing left for a fractional ramp to absorb:
   the rhythm is the 8 scale Material 3 states, which is also what makes the three text screens look like each other. */
.state--capture { gap: 0; }
.state--capture .title { margin-top: var(--dd-s5); }
.state--capture .lede { margin-top: var(--dd-s2); }
.state--capture .card { margin-top: var(--dd-s6); }
/* DF-46: the card is a grid AND carries the reference's min-height, so `align-content` defaulted to stretch and the
   252 px floor was divided among the interior rows by the browser rather than declared by the design. The head row
   inflated from 22.4 to 39.3 px, which pushed the status dot and the two labels 6.2 px BELOW the reference while
   pulling the waveform 11.6 px ABOVE it (bar centre 344.4 against the reference's 356) — the interior drifted in two
   directions at once, and every position moved again whenever the min-height or the note's wrapping changed.
   The rows are packed at the top now and each one carries the reference's own distance, exactly as every other
   capture offset in this file is declared. Reference (02_CAPTURE_RECORDING.svg): card content top 251, dot 251..261
   sitting ON the 14 px labels' baseline (261), waveform bars centred on 356, note baseline 432. */
#capture-record .card { margin-top: max(var(--dd-s3), calc(41.75px - var(--dd-fit-trim))); min-height: 252px; gap: 0; align-content: start; }
/* The 252px above is the reference's own card interior and stays declared, exactly as [TS-41] requires — at the
   design height nothing here moves. This is the fit ramp reaching the last fixed block on the recording screen: with
   the card pinned, a 667-tall window pushed Cancel 59px below the fold, and a control the user needs to leave a
   recording is not something to put behind a scroll. The floor is three CTA heights, so the waveform, the timer and
   the note keep a readable box. */
@supports (height: 1dvh) { #capture-record .card { min-height: auto; flex-basis: max(calc(var(--dd-cta-h) * 3), calc(252px - var(--dd-short) * 0.25)); } }
/* the reference draws the dot's bottom exactly on the label baseline — a relation, not a coordinate, so it survives
   any font. line-height:1 for the same reason as .brand__word (DF-45): a row the reference positions in absolute
   coordinates must not carry the body's half-leading, or the text metrics decide where the row sits. */
#capture-record .card__head { align-items: baseline; line-height: 1; margin-left: -4px; gap: 11px; }
#capture-record .wave { margin-top: max(var(--dd-s4), calc(53px - var(--dd-fit-trim))); }
#capture-record .card__note { margin-top: max(var(--dd-s2), calc(23.8px - var(--dd-fit-trim))); }
/* THE RECORDING IS STILL RUNNING IN ALL THREE STATES (r2026-08-24j), so none of them is a different screen and none
   of them may read as a failure. data-rec is on the card, and only three things answer to it.
   muted — sound is not arriving. The waveform STOPS: bars that keep moving while no audio arrives are a lie about
     system state, the same reason the job trace stops when a job ends. They stay in place at a low uniform height, so
     the card keeps its geometry and stillness is what carries the meaning. The dot drops to the muted ink rather than
     an error colour, because the audio may come back and nothing has failed. The timer is de-emphasised, not frozen
     and not hidden: it is honest wall time, but while sound is not arriving it is not the number that matters, and
     the captured length is stated from durationMs when the recording ends.
   at_risk — persistence is degrading. Nothing changes visually at all: the recording is running normally and the
     only new fact is in the words. An alarm here would be an alarm about something that has not happened.
   Reduced motion is already handled globally for .wave__bar; this rule is about meaning, not preference. */
#record-card[data-rec="muted"] .dot { background: var(--dd-text-muted); }
#record-card[data-rec="muted"] .timer { opacity: 0.55; }
/* r2026-08-24n: no transform here. The painter writes scaleY inline every frame, so a stylesheet transform would
   silently lose — and it does not need to fight: no sound means energy 0, which settles the bars to the 8 px
   baseline on their own. Stillness is already the truth; this rule only takes the bars down in emphasis. */
#record-card[data-rec="muted"] .wave__bar { opacity: 0.32; }
/* THE LEAVE QUESTION. It takes the place of the stop disc, its label and Cancel — the block the user was reaching
   for — instead of being added below them, because a confirmation the user has to scroll to find is not one, and the
   card above it must stay visible: the recording is still running and the waveform is the proof of that. The swap is
   caused by the user's own tap, which is the one kind of movement this stylesheet accepts. */
#capture-record[data-leave="open"] .stop,
#capture-record[data-leave="open"] .action,
#capture-record[data-leave="open"] #btn-cancel { display: none; }
#record-leave { margin-top: max(var(--dd-s3), calc(29px - var(--dd-fit-trim))); text-align: left; }
#record-leave .actions { display: grid; gap: var(--dd-s2); }
#record-leave .body { margin: 0; }
/* WHILE THE QUESTION IS OPEN THE CARD GIVES UP ITS FLOOR (r2026-08-24k). The card's 252px interior is the reference's
   own and is right when the card is the only thing on the screen. It is not right when a three-answer question is
   underneath it: the question is what the user is now being asked to read, and a floor that outranks it is a floor
   that pushes the answers past the fold — the owner's second screenshot exactly. The waveform, the timer and the note
   stay visible, because the recording is still running and they are the proof of it; they simply stop reserving space
   they are not using. The trade is stated rather than tuned: the question outranks the reference geometry for as long
   as it is on screen, and the reference returns the moment it closes. */
/* while the question is open the card releases its reference BASIS and sits at its own content — which now ramps,
   because the wave below gives up height too. min-height stays automatic: the content itself is the floor. */
#capture-record[data-leave="open"] .card { flex-basis: auto; min-height: auto; }
#capture-record[data-leave="open"] .wave { margin-top: max(var(--dd-s2), calc(53px - var(--dd-fit-trim) * 3));
  /* the bars are decoration proving the recording runs; while the question needs the room they give up height too,
     floored at half — stillness of SIZE, not absence. Without this, min-content is fixed and the card cannot shrink. */
  height: max(38px, calc(76px - var(--dd-fit-trim) * 2)); }
#capture-record[data-leave="open"] .card__note { margin-top: max(var(--dd-s1), calc(23.8px - var(--dd-fit-trim) * 3)); }
/* THE WRITING AREA IS THE FLEXIBLE ROW (r2026-08-24k). A card whose children are all min-content can only ramp by the
   shares its parts were given; a card with one growable child adapts to ANY height by construction, which is what
   "height-adaptive" has to mean for a screen whose content the user types. Flex rather than grid for one reason: only
   flex expresses "this child takes whatever is left, and gives back whatever is needed" without the parent having to
   know how many siblings are currently visible — and on these two cards the sibling count changes at runtime (the job
   indicator, the saved line, the interruption line all come and go).
   The floor is the reading floor, not zero: two full lines at the transcript's own reading size. Below that the screen
   has stopped being a writing surface, and the screen's inner scroll takes over rather than the field collapsing. */
/* THE COLUMN IS CONSTRAINED, AND ONE ROW ABSORBS (r2026-08-24k). This is the piece that makes "height-adaptive" true
   rather than approximated, and it was the thing missing from four rounds of ramp work: a flexible child can only
   shrink if something is PUSHING on it. The state was a grid with align-content: start, so every row sat at its own
   content height, no row was under pressure, and the writing field stayed at its natural four rows however short the
   window got — the ramp squeezed the margins around it and the field itself never moved.
   So on the three tall capture screens the state is capped at the height it has been given and becomes a flex column:
   every row keeps its intrinsic height (flex: none) except ONE, which takes what is left and gives back what is
   needed. On the two writing screens that row is the card holding the text; on the recording screen it is the card
   holding the waveform. The floors inside them are reading floors (two full lines of the transcript's own reading
   size), so the screen adapts by showing less text at once — which is what a writing surface should do — and only
   past the floor does the screen's own scroll take over.
   Flex and not grid, for the same reason as the cards themselves: only flex says "take the remainder" without the
   parent knowing how many of its children are visible right now, and on these screens that count changes at runtime
   (the job indicator, the interruption line, the retry button, the review panel). */
#capture-transcript, #capture-text, #capture-record { max-height: 100%; display: flex; flex-direction: column; }
#capture-transcript > *, #capture-text > *, #capture-record > * { flex: none; }
/* grow 0: slack on a tall viewport stays at the foot of the column as the reference composes it; the card's
   generosity comes from its ramped BASIS, not from swallowing whatever is left. shrink 1: under pressure it gives
   height back, and the automatic min-height floor (min-content) is where that stops. */
#capture-transcript > .card, #capture-text > .card, #capture-record > #record-card { flex: 0 1 auto; }

/* DF-128: one row per fragment, and the row is the thing the screen asks the user to check. The transcript reads
   as body copy because that is what it is; a state line reads as meta, because it is about the transcript and not
   the transcript itself. The retry sits in the row it belongs to, not at the screen level, so a night with two
   fragments can retry the one that failed. */
/* ONE ROW PER DREAM, AND THE ROW SAYS WHICH DREAM IT IS (owner order r2026-08-26: "there are no dream numbers and
   there is no delete button"). The number is the row's own eyebrow - a fragment the user cannot name is a fragment
   they cannot talk about - and the delete control belongs to the row, not to the screen, so a night with three
   fragments can drop the one that was the radio in the kitchen. The confirmation is inline in the same row: a modal
   over a decoration-free review would be a second surface for a one-line decision. */
.review__list { list-style: none; margin: var(--dd-s3) 0 0; padding: 0; display: grid; gap: var(--dd-s3); }
.review__row { display: grid; gap: var(--dd-s2); padding-top: var(--dd-s3); border-top: 1px solid var(--dd-border); }
.review__head { display: flex; align-items: center; justify-content: space-between; gap: var(--dd-s2); }
.review__no { font-family: var(--dd-font-mono); font-size: var(--dd-fs-small); letter-spacing: 0.08em;
  text-transform: uppercase; color: var(--dd-text-muted); }
.review__del { min-height: var(--dd-control-min); padding: 0 var(--dd-s3); border: 0; background: transparent;
  color: var(--dd-text-muted); font-family: inherit; font-size: var(--dd-fs-small); cursor: pointer;
  border-radius: var(--dd-pill); }
.review__del:hover { color: var(--dd-error); }
.review__del:focus-visible { outline: 2px solid var(--dd-focus); outline-offset: 2px; color: var(--dd-error); }
.review__confirm { display: grid; gap: var(--dd-s2); }
.review__confirm .review__note { color: var(--dd-text); }
.review__confirmrow { display: flex; flex-wrap: wrap; gap: var(--dd-s2); }
.review__confirmrow .btn { flex: 1 1 auto; }
.review__text { color: var(--dd-text); font-size: var(--dd-fs-body); line-height: 1.45; text-wrap: pretty; }
.review__note { color: var(--dd-text-muted); font-size: var(--dd-fs-small); }
.review__row[data-state="failed"] .review__note, .review__row[data-state="failed_retryable"] .review__note { color: var(--dd-error); }
.review__retry { justify-self: start; }

/* THE NIGHT REVIEW REPLACES THE EDITOR, for the same reason the leave question replaces the stop block. The panel is
   355px of confirmation — how many fragments became how many dreams, and one choice about it — and stacking it under
   a transcript editor that is already using the whole screen cannot fit any phone. It also does not need to: a user
   answering "did this get grouped correctly?" is not editing words at that moment. So while the panel is open the
   editor and its assurance line stand down, and they come straight back when it closes. Declared here as a trade
   rather than absorbed by a scroll, because src/config/screen-fit.js says this screen fits. */
/* DF-116. The trade listed .card and .assure and stopped there, so #btn-interpret and #btn-again stayed mounted
   under a panel that already carries its own two actions: four buttons where the screen is composed for two.
   The whole CTA pair stands down with the editor and comes back with it. (.assure is gone from the markup this
   round; the selector is kept off the list rather than aimed at nothing.) */
#capture-transcript:has(> #night-review:not([hidden])) > .card,
#capture-transcript:has(> #night-review:not([hidden])) > #btn-interpret,
#capture-transcript:has(> #night-review:not([hidden])) > #btn-again { display: none; }
/* DF-117 — THE SAME CLASS AS DF-46, THIRTY LINES BELOW, LEFT UNFIXED HERE. `min-height: 0` REPLACES the automatic
   min-content floor, so the panel was licensed to shrink below its own contents and its .actions row rendered
   OUTSIDE it, on top of the buttons that followed. That is the button the owner photographed lying under
   "Interpret my dream". Generosity belongs on flex-basis; the floor stays automatic, and automatic is min-content. */
#capture-transcript > #night-review { flex: 0 0 auto; }
/* THE REVIEW PANEL HAS A RHYTHM OF ITS OWN (owner screenshot r2026-08-26: "the buttons are piled on each other,
   you cannot tell what the text is"). Every paragraph in this product has margin: 0 by declaration, and this panel
   declared no gap - so the title, the count, the grouping line, the pending line and the list were touching, and
   the action pair sat straight on the last row. One grid, one gap, and the pair gets the CTA's own distance. */
.night-review { display: grid; gap: var(--dd-s2); align-content: start; }
/* the reserves in this panel were three text lines EACH, empty or not: two 60-70 px holes in a column of short
   lines, which is most of what made the owner's screenshot unreadable. The count keeps one line (it is one line in
   every language), the pending line keeps two - enough for the longest of the five translations - so the list below
   still cannot move when the line arrives or leaves. */
.night-review #review-count { min-height: 1lh; }
.night-review #review-pending { min-height: 2lh; }
.night-review .title--sub { margin-bottom: calc(var(--dd-s1) * -1); }
.night-review .actions { display: grid; gap: var(--dd-s3); margin-top: var(--dd-s4); }
/* the escape from a long wait sits in the screen's own rhythm, not against the title above it */
#capture-transcript > #btn-job-type { margin-top: var(--dd-s4); }

#capture-transcript .card, #capture-text .card { display: flex; flex-direction: column; }
#capture-transcript .card > *, #capture-text .card > * { flex: none; }
#capture-transcript .transcript, #capture-text .transcript { flex: 1 1 auto;
  min-height: max(calc(var(--dd-fs-read) * var(--dd-lh-read) * 2), calc(152px - var(--dd-short) * 0.3)); }
/* THE FLOOR THAT WAS A LICENCE (r2026-08-24k, caught in verification). An explicit min-height on a flex item
   REPLACES the automatic min-height:auto (min-content) floor — so a ramped floor that dips below the card's own
   min-content stops being a floor and becomes permission to crush the card while its children spill onto the page
   background: the field 35px and the meta row 77px outside the card, on exactly the screen the owner photographed.
   min-content cannot be an arm of max() (intrinsic keywords are not valid inside CSS math), so the generous size
   moves to FLEX-BASIS instead: the basis is the card's preferred size — the reference interior, ramped — while
   min-height stays automatic, and the automatic floor is min-content. Prefer the reference, shrink under pressure,
   never below the content itself. The same statement, with the two jobs on the two properties that mean them. */
#capture-transcript .card { flex-basis: max(calc(var(--dd-cta-h) * 2), calc(320px - var(--dd-short) * 0.45)); }
/* the reference sets this title on two lines; reserving both keeps everything below it on the reference's y */
#mic-denied .title { min-height: calc(var(--dd-fs-title-capture) * var(--dd-lh-title) * 2); }
/* DF-46 (second occurrence): the text card is the one other place the floor really is taller than the content \u2014 88.2 px
   of slack that `align-content: stretch` was spreading through the gaps. Measured: the transcript card's content is
   447 px against a 320 px floor, so it has no slack and is deliberately left alone. Here the slack goes where a
   writing surface's spare height belongs \u2014 into the writing area \u2014 by making the textarea the only flexible row.
   Two rows, not three: the field's <label> is .visually-hidden, which is position:absolute and therefore not a grid
   item at all — a three-row template handed the slack to the meta line instead of the field.
   This is the canonical pattern, not a reference measurement: 04_CAPTURE_TEXT is not in the package, so the card box
   (356 px, recorded) is held and only the distribution inside it is decided here. */
#capture-text .card { flex-basis: max(calc(var(--dd-cta-h) * 2), calc(356px - var(--dd-short) * 0.62)); }
#capture-record .stop { margin-top: max(var(--dd-s4), calc(53px - var(--dd-fit-trim))); }
#capture-record .action { margin-top: max(var(--dd-s1), calc(12px - var(--dd-fit-trim))); }
#capture-record .btn--secondary { margin-top: max(var(--dd-s2), calc(29px - var(--dd-fit-trim))); }
/* THE THREE MARGINS THAT WERE NEVER IN THE FIT SYSTEM (r2026-08-24k, DF-96). These four rules carried plain fixed
   lengths — 25px, 50px, 61px and the transcript card's own floor — while every other length on these screens gives up
   a declared share of the missing height. 136px of margin on the two tallest screens in the product was therefore
   outside the ramp entirely, which is why the owner's screenshots of the transcript and text screens still scrolled
   after four rounds of fitting: the ramp was compensating correctly and 136px of the column simply never listened.
   They are now trims like every other capture offset, each with its own floor, and the shared --dd-fit-trim keeps
   their rhythm proportional to each other as the column compresses. At the design height every one of them still
   resolves to its reference number — the reference is the top of the ramp, not an approximation of it. */
/* DF-118. Descendant, not child: #btn-review-ok and #btn-review-change live inside #night-review > .actions and
   were collecting the SCREEN's CTA rhythm — 50px and 24px of margin inside a row whose spacing is its own gap.
   Scoped to direct children, which is what "the screen's buttons" always meant. */
#capture-transcript > .btn--primary { margin-top: max(var(--dd-s3), calc(50px - var(--dd-fit-trim) * 2)); }
#capture-text .btn--primary { margin-top: max(var(--dd-s3), calc(61px - var(--dd-fit-trim) * 2)); }
#capture-transcript > .btn--secondary, #capture-text > .btn--secondary, #mic-denied > .btn--secondary { margin-top: var(--dd-s4); }
#mic-denied { gap: 0; }
#mic-denied .title { margin-top: max(var(--dd-s3), calc(43.5px - var(--dd-fit-trim))); }
#mic-denied .lede { margin-top: max(var(--dd-s2), calc(18.75px - var(--dd-fit-trim))); }
#mic-denied .emblem { margin-top: max(var(--dd-s2), calc(27.75px - var(--dd-fit-trim))); }
#mic-denied .notice { margin-top: max(var(--dd-s2), calc(27px - var(--dd-fit-trim))); }
#mic-denied .notice { min-height: max(calc(var(--dd-cta-h) * 1.2), calc(112px - var(--dd-short) * 0.14)); gap: 13px; align-content: start; }
#mic-denied .btn--primary { margin-top: max(var(--dd-s2), calc(68px - var(--dd-short) * 0.16)); }
/* the denied emblem is a 140 px disc on the surface-2 fill with the same soft shadow as every other reference disc */
.emblem { width: var(--dd-fit-moon); height: var(--dd-fit-moon); border-radius: 50%; justify-self: center;
  background: var(--dd-surface-2); border: 1px solid var(--dd-border); box-shadow: 0 10px 30px var(--dd-shadow-soft);
  display: grid; place-items: center; }
/* the two micro marks the references draw as rings, not as filled dots */
.assure__mark { width: 12px; height: 12px; flex: none; }
.assure__disc { fill: var(--dd-success); }
.assure__tick { fill: none; stroke: var(--dd-on-accent); stroke-width: 1.4; }
.notice__mark { width: 16px; height: 16px; flex: none; }
.notice__ring { fill: var(--dd-error-soft); stroke: var(--dd-error-mark); }
.notice__minus { stroke: var(--dd-error-mark); stroke-width: 1.8; stroke-linecap: round; }
.emblem svg { width: 60%; height: 60%; }
.notice { display: grid; gap: var(--dd-s2); }
.notice__head { display: flex; align-items: center; gap: var(--dd-s2); font-weight: 700; font-size: var(--dd-fs-small); }
.notice p { margin: 0; font-size: var(--dd-fs-small); color: var(--dd-text-muted); }

/* Continuity across the handover: the splash moons dissolve INTO the kruglyak Home already has, which is therefore
   the one thing that stays — so it must not take part in Home's fade, or it blinks out exactly when it should read
   as continuous. Home fades in around it. */
.state--content { animation: dd-fade var(--dd-dur-trans) var(--dd-ease-out); }
/* during the splash handover the arrival is driven per element by src/app.js; the CSS fade would stack on top of it.
   NOT `animation: none` \u2014 that is what made the whole screen blink once, right after the preloader. Suppressing an
   animation by removing its NAME destroys the animation; restoring the name CREATES A NEW ONE, which starts again at
   opacity 0. So the sequence was: Home fades in under the splash \u2192 is-arriving cancels that fade \u2192 the handover ends,
   is-arriving comes off, and a SECOND fade is born from zero, on the finished screen the user is already looking at.
   Measured in a rendered page: computed opacity of #content was exactly 0 on the frame after the class was removed.
   Zeroing the DURATION keeps one animation alive from first paint to last. It is already finished (its current time
   is the whole splash), fill-mode none leaves the base opacity: 1, and when the duration comes back the animation is
   still finished \u2014 nothing to replay. A later arrival, when the state goes display:none and back, builds a fresh
   animation by itself and fades in as designed.
   The class: an entrance animation is armed ONCE per arrival. Anything that switches animation-name off and on again
   while the element stays on screen re-arms it, and the user sees a flash of the empty page. */
.state--content.is-arriving { animation-duration: 0s; }
.state--error, .state--capture { animation: dd-fade var(--dd-dur-trans) var(--dd-ease-out); }
@keyframes dd-fade { from { opacity: 0; } to { opacity: 1; } }

/* error */
.state--error .title { color: var(--dd-text); }
.error__icon { display: inline-block; width: 1em; height: 1em; margin-right: var(--dd-s2); vertical-align: -0.1em; fill: var(--dd-error-mark); }
.error__body { color: var(--dd-error); }

/* probe log (emulated flow, QA surface inside the console) */
.probe { display: grid; gap: var(--dd-s2); }
.probe__row { display: grid; grid-template-columns: auto 1fr; gap: var(--dd-s3); align-items: baseline; padding: var(--dd-s3) var(--dd-s4); background: var(--dd-surface); border: 1px solid var(--dd-border); border-radius: var(--dd-r2); }
.tag { font-size: var(--dd-fs-small); font-weight: 600; padding: 2px var(--dd-s2); border-radius: var(--dd-pill); border: 1px solid var(--dd-border); color: var(--dd-text-muted); white-space: nowrap; }
.tag--emulated { color: var(--dd-accent); }
.tag--ok { color: var(--dd-success); }
.tag--fail { color: var(--dd-error); }
.probe__transcript { width: 100%; min-height: calc(var(--dd-fs-body) * var(--dd-lh-body) * 3); padding: var(--dd-s3) var(--dd-s4); font: inherit; color: var(--dd-text); background: var(--dd-surface-2); border: 1px solid var(--dd-border); border-radius: var(--dd-r1); resize: vertical; }
label { display: block; font-size: var(--dd-fs-small); color: var(--dd-text-muted); margin-bottom: var(--dd-s1); }

/* foundation console (QA surface, not product UI) */
/* F-05: the QA entry point used to be a fixed button, which at 200% text intersected the primary action on three
   fixtures. It now flows at the end of the document — the same decision DF-06 made for the console sheet itself:
   a QA surface never overlays a product action.
   r2026-08-24f: it is anchored inside the screen's own reserved bottom padding instead of adding a row to the
   document. --dd-fit-pad-bottom has always reserved `--dd-s7 + --dd-control-h` at the foot of every screen — that
   reservation existed FOR this control, and while the button also flowed after <main> the space was counted twice and
   the first screen scrolled by 72px on every viewport. Absolute, not fixed, is what keeps F-05 fixed: the anchor is
   the document's own bottom, so when content grows past the fold (200% text) the button moves down with it instead of
   sitting on top of the primary action. Product content can never reach it — it lives in padding, which by definition
   follows the content. */
.console-toggle { position: absolute; right: max(var(--dd-s4), env(safe-area-inset-right)); bottom: max(var(--dd-s4), env(safe-area-inset-bottom));
  margin: 0; min-height: var(--dd-control-min); background: var(--dd-surface); }
/* the console lives in the shell's second row, which is minmax(0, auto) \u2014 a zero minimum, so a long QA sheet shrinks\n   the track instead of pushing the fixed shell open and being clipped. It therefore has to carry its own scroll: the\n   inline axis is sealed for the same reason .screen seals it (a focus ring must not create a horizontal scrollbar). */\n.console { position: relative; width: 100%; overflow: clip auto; padding: var(--dd-s4) max(var(--dd-s4), env(safe-area-inset-right)) calc(var(--dd-s7) + var(--dd-control-h)) max(var(--dd-s4), env(safe-area-inset-left));
  background: var(--dd-surface-2); border-top: 1px solid var(--dd-border); z-index: 90; display: grid; gap: var(--dd-s4); }
.console h2 { margin: 0; font-size: var(--dd-fs-body); }
.console__group { display: grid; gap: var(--dd-s2); }
.console__row { display: flex; flex-wrap: wrap; gap: var(--dd-s2); }
.console .btn { min-height: var(--dd-control-min); padding: 0 var(--dd-s4); }
select, input[type="checkbox"] { font: inherit; min-height: var(--dd-control-min); color: var(--dd-text); background: var(--dd-surface); border: 1px solid var(--dd-border); border-radius: var(--dd-r1); padding: 0 var(--dd-s3); }
input[type="checkbox"] { width: 24px; min-height: 24px; accent-color: var(--dd-accent); }
.statuslist { margin: 0; padding: 0; list-style: none; display: grid; gap: var(--dd-s1); font-size: var(--dd-fs-small); }
.statuslist li { display: flex; justify-content: space-between; gap: var(--dd-s3); }
.eventlog { margin: 0; padding: 0; list-style: none; font-size: var(--dd-fs-small); font-variant-numeric: tabular-nums; color: var(--dd-text-muted); max-height: 12rem; overflow: auto; }

/* THE LEGAL HUB HAS THREE STATES (r2026-08-24z, DF-141 deferral). The list arrives with a module now, so the surface
   must be honest about the wait and about a failure - and must not move when either resolves.
   The ghost is [visibility:hidden] rather than display:none whenever it is not the active state, so its box is kept
   and the ready list lands exactly where the ghost stood (the standing anti-shift rule). Only the failure state
   removes it, because there the failure line takes the same room. */
/* one cell, three tenants. The ghost, the failure line and the real list are stacked in the SAME grid cell, so the
   box is whatever the tallest of them needs and nothing below it moves when the module lands or fails. An absolutely
   positioned ghost was tried first and reserved nothing: out of flow is out of the height calculation. */
.hubstack { display: grid; }
.hubstack > * { grid-area: 1 / 1; }
.sk--doc-row { display: block; min-height: var(--dd-doc-row); width: 100%; border-radius: var(--dd-r3); }
#legal-index[data-hub="ready"] .doclist--ghost, #legal-index[data-hub="failed"] .doclist--ghost { visibility: hidden; }
#legal-index[data-hub="loading"] #legal-list, #legal-index[data-hub="failed"] #legal-list { visibility: hidden; }
/* a half-interactive list is worse than none: while it is not ready, nothing in it can be reached or focused */
#legal-index[data-hub="loading"] #legal-list, #legal-index[data-hub="failed"] #legal-list { pointer-events: none; }
#legal-index[data-hub="loading"] #legal-list .doclist__btn, #legal-index[data-hub="failed"] #legal-list .doclist__btn { visibility: hidden; }
/* the failure line takes the reserved box rather than adding one: loading, ready and failed are the same height, so
   the account card below never moves whatever the module does (measured 0.0 px at 390/360/320 and in landscape) */
.hub-failed { display: none; align-self: start; margin: var(--dd-s3) 0 0; }
#legal-index[data-hub="failed"] .hub-failed { display: block; }
#legal-index[data-hub="failed"] .doclist--ghost { visibility: hidden; }
/* reduced motion: OS setting or the QA override on <html data-motion="reduced"> */
@media (prefers-reduced-motion: reduce) {
  .sk, .state--content, .state--error, .state--capture, .btn { animation: none !important; transition: none !important; }
  .sk::after { animation: none !important; opacity: 0 !important; }
  .wave__bar { animation: none !important; }
  .clouds__drift, .loader__sky span { animation: none !important; }
  .moon-loader .ml-b1, .moon-loader .ml-b2, .moon-loader .ml-b3, .moon-loader .ml-star, .moon-loader .ml-trace, .moon-loader .ml-reveal-path, .job__trace { animation: none !important; }
  .moon-loader .ml-reveal-path { stroke-dashoffset: 0 !important; }
  html[data-loader] .moon-loader { transition: none !important; }
  .moon-loader .ml-star { opacity: 0.7 !important; }
}
html[data-motion="reduced"] .sk, html[data-motion="reduced"] .state--content, html[data-motion="reduced"] .state--error, html[data-motion="reduced"] .state--capture, html[data-motion="reduced"] .btn { animation: none !important; transition: none !important; }
html[data-motion="reduced"] .sk::after { animation: none !important; opacity: 0 !important; }
html[data-motion="reduced"] .wave__bar { animation: none !important; }
html[data-motion="reduced"] .clouds__drift, html[data-motion="reduced"] .loader__sky span { animation: none !important; }
html[data-motion="reduced"] .moon-loader .ml-b1, html[data-motion="reduced"] .moon-loader .ml-b2, html[data-motion="reduced"] .moon-loader .ml-b3,
html[data-motion="reduced"] .moon-loader .ml-star, html[data-motion="reduced"] .moon-loader .ml-trace, html[data-motion="reduced"] .moon-loader .ml-reveal-path { animation: none !important; }
html[data-motion="reduced"] .moon-loader .ml-reveal-path { stroke-dashoffset: 0 !important; }
/* parity: the processing mark is listed in the media-query block above, so the QA override must stop it too —
   an override that leaves one animation running makes every reduced-motion review of this screen a false pass */
html[data-motion="reduced"] .job__trace { animation: none !important; }
html[data-motion="reduced"][data-loader] .moon-loader { transition: none !important; }
html[data-motion="reduced"] .moon-loader .ml-star { opacity: 0.7 !important; }

/* r2026-08-26: the 768 px side padding tested the WINDOW and applied inside the 390-wide board, where it pushed
   every screen 48 px off the artboard's own origin (verifier). A board is never 768 px wide; the rule is deleted
   rather than overridden, because an overridden rule is a rule that comes back. */

/* A DECLARED `flow` SCREEN READS AS ONE (r2026-08-24k, src/config/screen-fit.js). The mechanism is the same inner
   scroll every screen has — what differs is that here it is the intended reading behaviour rather than the fallback,
   so the region is allowed to reserve its scrollbar instead of having one appear and reflow the text mid-read.
   scrollbar-gutter applies to the scroll container, which is .screen, so the rule is written on the screen that
   CONTAINS a flow state rather than on the state itself. Only for flow: on a fit screen a reserved gutter would be
   permanent empty space kept for a scrollbar that must never appear. */
.screen:has(> [data-fit="flow"]:not([hidden])) { scrollbar-gutter: stable; }

/* ---- legal hub + account (r2026-08-22-05, V4 §3/§10): tokens only, 48 px controls, no colour-only state ---- */
/* position + z-index, for the same reason .footer carries them: .clouds is absolutely positioned, so it paints in
   the positioned layer AFTER in-flow siblings and covered this control's label. The clouds are pointer-events:none,
   so the button stayed clickable while being unreadable — worse than a plain occlusion, and invisible to a hit test.
   Every in-flow element that reaches the foot of the composition needs to be in the same layer as the footer. */
.home__legal { position: relative; z-index: 1; margin-top: var(--dd-s3); width: auto; align-self: center; }
/* DF-101, second occurrence: the saved centre and the next cards are in-flow surfaces that reach the foot of the
   composition in their modes, so they join the same layer — in saved, the centre title painted UNDER the horizon. */
.saved-center, .next { position: relative; z-index: 1; }
/* DF-47 pattern, DF-66: the compact document title size is a custom property on the subtree, not a font-size rule,
   so the placeholder's reservation is derived from the same number the heading renders at rather than repeating it. */
.state--legal { align-items: stretch; text-align: left; --dd-fs-title-compact: calc(var(--dd-fs-title) * 0.82); }
.state--legal .title--compact { font-size: var(--dd-fs-title-compact); min-height: 0; margin-top: var(--dd-s2); }
.state--legal .lede--compact { min-height: 0; }
.body--meta { min-height: 0; font-size: var(--dd-fs-small); color: var(--dd-text-muted); }
/* --dd-doc-row is the ROW RESERVATION, derived (never a new number): two lines of the row's own type plus its
   padding and border. THREE lines, not two: measured across every locale at 320-844 px, the tallest row is a wrapped
   title plus its version line (99.6 px against a 25.6 px line box) - German and Russian reach it, English does not,
   and a reservation sized for English is a reservation that jumps for everyone else. Every document row is that tall
   whether it wraps or not, which is what makes the ghost and the real list the same box at every width. Derived from
   global tokens and scoped here, so it is not a second token file (TS-29). The third --dd-s2 is the row's own flex
   gap, which appears once between the wrapped title and the version line: omit it and the reservation is 4.8 px
   short per wrapped row, which is exactly the residual jump this was measured down from. */
.doclist { --dd-doc-row: calc(3lh + var(--dd-s2) * 3 + 2px); list-style: none; margin: var(--dd-s3) 0 0; padding: 0; display: grid; gap: var(--dd-s2); }
.doclist__item { margin: 0; }
.doclist__btn { width: 100%; min-height: var(--dd-doc-row); display: flex; flex-wrap: wrap; justify-content: space-between; align-items: center; gap: var(--dd-s2); text-align: left; background: var(--dd-surface); border: 1px solid var(--dd-border); border-radius: var(--dd-r3); padding: var(--dd-s2) var(--dd-s3); color: var(--dd-text); font: inherit; cursor: pointer; }
.doclist__btn:focus-visible { outline: 3px solid var(--dd-accent); outline-offset: 2px; }
.doclist__title { font-weight: 600; }
/* DF-49: the version line may wrap. It was nowrap, which made it the row's min-content floor — at 200% text on a
   320 px screen the list could not shrink and pushed the page 8.7 px wide. The rule above gives the row a zero
   minimum; this gives the text the wrapping policy that has to come with it (styles/app.css line 59 states the
   policy: zero-min PLUS a declared wrapping behaviour, or the row just overflows its own box instead of the page). */
.doclist__meta { font-size: var(--dd-fs-small); color: var(--dd-text-muted); }
.account { margin-top: var(--dd-s4); padding: var(--dd-s3); display: grid; gap: var(--dd-s2); }
.account .console__row { flex-wrap: wrap; gap: var(--dd-s2); }
.account .console__row .btn { width: auto; flex: 1 1 10rem; }
.account__email { flex: 1 1 12rem; min-height: var(--dd-control-min); border-radius: var(--dd-r1); }
.btn--danger { background: var(--dd-surface-2); border: 1px solid var(--dd-error); color: var(--dd-error); }
.warn { border-color: var(--dd-error); padding: var(--dd-s3); display: grid; gap: var(--dd-s2); }
.warn .body { min-height: 0; }
#account-state[data-identity-state="anonymous_pending"]::before, #account-state[data-identity-state="link_pending"]::before { content: "… "; }
#account-state[data-identity-state="linked"]::before { content: "✓ "; }
#account-state[data-identity-state="anonymous_failed"]::before, #account-state[data-identity-state="link_failed"]::before { content: "! "; }
#legal-doc { display: grid; gap: var(--dd-s2); }
#legal-doc .btn--ghost { width: auto; justify-self: start; }
.legal__body { display: grid; gap: var(--dd-s2); color: var(--dd-text); max-width: 65ch; }
.legal__body h3 { margin: var(--dd-s2) 0 0; font-size: var(--dd-fs-lead); }
.legal__body p { margin: 0; }
.legal__body ul { margin: 0; padding-left: 1.2rem; }

/* LANDSCAPE IS A DIFFERENT SHAPE, NOT A NARROWER ONE (DF-88, second half). At 320px of height a single column of eight
   rows cannot fit however hard the ramp squeezes — 243px still overflowed with every floor reached. The width is the
   resource that exists there, so the composition takes two columns: the reading block on the left, the emblem and the
   notice beside it, the actions under the text where the thumb already is. Only below 480px of height and above 480px
   of width, which is precisely phone landscape; portrait and tablet keep the approved single column. */
@media (max-height: 480px) and (min-width: 480px) {
  #mic-denied { grid-template-columns: minmax(0, 1.35fr) minmax(0, 1fr); column-gap: var(--dd-s5); align-content: start; }
  #mic-denied .topbar { grid-column: 1 / -1; }
  #mic-denied .title, #mic-denied .lede { grid-column: 1; }
  #mic-denied .emblem { grid-column: 2; grid-row: 2 / 4; align-self: center; justify-self: center; margin-top: 0; }
  #mic-denied .notice { grid-column: 2; grid-row: 4 / 7; margin-top: 0; }
  #mic-denied .btn { grid-column: 1; }
  /* the reference vertical rhythm belongs to portrait: in landscape every row margin collapses to the scale minimum */
  #mic-denied .title, #mic-denied .lede, #mic-denied .btn--primary, #mic-denied .btn--secondary { margin-top: var(--dd-s2); }
  #mic-denied .notice { min-height: 0; }
  /* the disc is decoration in a composition that has just lost half its width for text: it gives up more here */
  #mic-denied .emblem { width: var(--dd-fit-mic); height: var(--dd-fit-mic); }
}

/* ==== V8 CAPTURE SURFACE (r2026-08-24n) ==========================================================================
   ONE surface, four modes on [data-mode]. Geometry and palette are the approved references
   (assets/reference/v8/SCREEN_*, assets/motion/motion-implementation.html); the fit system underneath is unchanged,
   so every mode is height-adaptive and the document still cannot scroll.
   The rule that makes this readable: a mode only ever changes VISIBILITY and the mic own skin. It never moves a
   surviving element, so switching modes cannot shift layout (CLS, AC-PERF-02) — blocks that leave are removed from
   flow, blocks that stay keep their box. */
/* ===== THE STAGE — EVERY NUMBER BELOW IS READ OFF assets/reference/v8/screen-0*.svg =====================
   The owner's order is a pixel-exact reproduction of the approved artboards. A fluid column cannot do that: a
   baseline at y=284 and a mic centre at (195,536) are only reproducible in the coordinate space they were drawn in.
   So the capture surface IS that space — 390x844, absolutely positioned, scaled to fit the window by
   src/ui/v8-shell.js (--dd-stage). Consequences, all of them deliberate:
     it can never scroll, in either axis, at any window size or orientation;
     it looks the same on a phone and on a desktop, which is what the references show;
     browser zoom no longer enlarges this surface, because the surface is a fixed artboard — the accessibility
       trade-off the owner's "1 in 1" order implies. Every OTHER screen (transcript, legal, text, denied, error) keeps
       the fluid rem-based fit system, so zoom and reflow are unaffected where the references do not dictate pixels.
   Coordinates are px in artboard space. Text is positioned by BASELINE, taken from the SVG: top = baseline - 0.8em,
   which is where a line-height:1 box puts the baseline for this family. */
/* THE ONE STAGE (owner order r2026-08-26). Every screen is this 390x844 board, centred and scaled to the window:
   one canvas, one measure, one set of margins, one sky, one horizon, one closing line - for Home, the recording
   surface, the transcript review, the text route, the microphone-off screen, the legal hub and the error screen
   alike. The reference coordinates only reproduce in the space they were drawn in, and now EVERY screen is in it. */
.device { position: absolute; left: 50%; top: 50%; width: var(--dd-stage-w); height: var(--dd-stage-h);
  overflow: hidden; background: linear-gradient(180deg, var(--dd-bg) 0%, var(--dd-bg-2) 100%);
  transform: translate(-50%, -50%) scale(var(--dd-stage, 1)); transform-origin: 50% 50%; }
/* THE BOARD'S DECORATION BELONGS TO THE PRODUCT SCREENS, NOT TO THE BOOT (verifier r2026-08-26). The approved
   preloader composition is the moon, its label and its own twelve-point sky - a second star field, a second horizon
   and a second closing line under it is a screen nobody asked to change. The gate is the boot screens being gone. */
.device__sky, .device__foot { display: none; }
body:has(#loader[hidden]):has(#skeleton[hidden]) .device__sky,
body:has(#loader[hidden]):has(#skeleton[hidden]) .device__foot { display: block; }
/* the sky sits BELOW the screen's copy - decoration never paints over text (the ambient contract) - and only the
   tap pads of wishable flyers ride a thin layer above it, on the artboard alone (verifier r2026-08-27b). */
.device__sky { position: absolute; inset: 0; z-index: 0; pointer-events: none; }
.device__wish { position: absolute; inset: 0; z-index: 3; pointer-events: none; }
/* the scroll region is the stage's own viewport now: 390x844, always. Screens longer than the board (the legal hub,
   a document, a long review) scroll INSIDE the phone, which is what they do on a phone. */
/* THE BOARD IS THE COORDINATE SPACE (verifier r2026-08-26). The scroll region must not carry the screen padding
   inside the stage: absolute insets resolve against the PADDING box, so 48 px of window-derived padding moved the
   whole artboard composition to (48, 48) in a 342x796 box - the theme control landed at (74.5, 97.5) instead of
   (26.5, 49.5), the mic centre at (243, 584) instead of (195, 536), and the settings glyph was clipped by the board
   edge. The board gives its 390x844 back to the screens; the flow columns take their own vertical rhythm, and their
   24 px side inset comes from the measure (--dd-content-w = 342 centred in 390), not from a padding. */
/* THE SCREEN CREATES NO STACKING CONTEXT (verifier r2026-08-27e). It carried z-index: 1, which trapped .mic's z 4
   inside it - so a pad layer above the screen would have painted above the microphone too and stolen its tap. With
   `auto` the documented order holds globally: sky 0 < screen content < wish pads 3 < microphone 4. */
#main.screen { position: absolute; inset: 0; z-index: auto; padding: 0; }
/* the flow columns only. The boot screens author their own vertical composition (.state--loader's padding-top IS the
   distance from the top of the loader svg to the crescent centre, so the moon does not jump at the handover), and a
   selector broad enough to include them outranked it: 48 px instead of the authored 26.33 px. */
.device .state--capture, .device .state--error, .device .state--legal, .device .state--skeleton {
  padding-top: max(var(--dd-fit-pad-top), env(safe-area-inset-top));
  padding-bottom: max(var(--dd-fit-pad-bottom), env(safe-area-inset-bottom)); }
/* window-derived spacing has no meaning inside a fixed board: --dd-short is the height the WINDOW is missing and
   --dd-fit-pad-side is a vw fraction. The board is 390x844 at every window size, so both are pinned here. */
/* pinned ON THE SCROLL REGION, not on the board: .screen declares these itself (a media query on window height
   re-declares --dd-content-w), and a child's own declaration wins over an inherited one. */
#main.screen { --dd-short: 0px; --dd-fit-pad-side: var(--dd-s5); --dd-content-w: min(100%, 342px); }
/* the horizon and the closing line, at the artboard's own coordinates, over every screen and under no pointer:
   a long screen scrolls behind them, exactly as it does on Home. */
.device__foot { position: absolute; inset: 0; z-index: 5; pointer-events: none; }
/* the capture artboard is no longer its own stage - it IS the stage's content, at the same coordinates as before */
.state--content { position: absolute; inset: 0; width: 100%; height: 100%; display: block;
  padding: 0; margin: 0; overflow: hidden; background: none; transform: none; }

/* On a phone the board fills the window and is the screen. On a wider window it cannot fill anything without
   ceasing to be the approved composition, so it reads as what it is — the device the references draw. Radius and
   shadow are the reference implementation's own desktop rule (assets/motion/motion-implementation.html, min-width
   700px), not an invention of this file. */
/* THE REFLOW ESCAPE (DF-183; owner delegated the decision r2026-08-27; corrected after verification r2026-08-27b).
   Below half scale - the state a 200%+ browser zoom produces - the board releases everything that would cancel that
   zoom. TWO THINGS THIS RULE SET LEARNED THE HARD WAY:
     it applies only while a FLOW screen is the visible one. The artboard is a fixed composition at fixed
     coordinates with no reflow of its own, and a scaled element still occupies its unscaled LAYOUT box - so letting
     it into the flow put 390 px of width and 152 px of dead height into a column that then could not shrink below
     422 px, which is the 1.4.10 failure this escape exists to prevent;
     the closing band is anchored to the BOTTOM of the window, with its children anchored inside it. `inset: 0` on a
     viewport-sized container is not the same parent as the 390x844 board: the artboard-coordinate children resolved
     `top: 811px` and landed 273 px below the fold.
   All of it is WCAG's own G206 shape: a layout the user can reach without fighting the design. */
html[data-stagefit="reflow"]:has(#content[hidden]) .device { position: static; width: auto; max-width: 100%;
  height: auto; min-height: 100svh; transform: none; overflow: visible; border-radius: 0; box-shadow: none; }
html[data-stagefit="reflow"]:has(#content[hidden]) #main.screen { position: static; height: auto; min-height: 100svh;
  overflow: visible; --dd-content-w: min(100%, var(--dd-measure));
  padding: max(var(--dd-s5), env(safe-area-inset-top)) var(--dd-s4) calc(var(--dd-flowfoot) + var(--dd-s5)) var(--dd-s4); }
html[data-stagefit="reflow"]:has(#content[hidden]) .device__sky { position: fixed; inset: 0; }
html[data-stagefit="reflow"]:has(#content[hidden]) .device__foot { position: fixed; inset: auto 0 0 0;
  height: calc(var(--dd-flowfoot) + var(--dd-s5)); }
html[data-stagefit="reflow"]:has(#content[hidden]) .device__foot .clouds { position: absolute; inset: auto 0 0 0;
  top: auto; width: 100%; height: var(--dd-flowfoot); }
html[data-stagefit="reflow"]:has(#content[hidden]) .device__foot .footer { position: absolute; inset: auto 0 0 0;
  top: auto; min-height: var(--dd-flowfoot); display: flex; align-items: center; justify-content: center; }
html[data-stagefit="reflow"]:has(#content[hidden]) body { overflow: auto; }
@media (min-width: 700px) {
  /* r2026-08-27, verifier: the board carries the canvas and the clip since the one-stage refactor, so the device
     treatment belongs to .device. On .state--content it was dead CSS - that section has no background and does not
     clip, and the shadow fell on the board edge where overflow: hidden removed it. */
  .device { border-radius: 34px; box-shadow: 0 30px 80px rgba(20, 16, 40, 0.18); }
}

/* corner controls: the reference glyphs at their own coordinates, NO circle behind either (the references draw
   none). The 48 px hit area is invisible and centred on the glyph, so accessibility does not cost a pixel. */
.stagebtn { position: absolute; width: 48px; height: 48px; min-height: 48px; padding: 0; border: 0;
  background: transparent; border-radius: 50%; display: block; cursor: pointer; z-index: 6; }
#btn-theme { left: 26.5px; top: 49.5px; }
#btn-settings { left: 318px; top: 46px; }
.themebtn__moon { width: 48px; height: 48px; }
/* THE CORNER MOON'S PAINT. The mark IS the preloader's moon (#g-moon in index.html), so it carries the
   preloader's own colours — the loader's four brand fields, single-sourced from the tokens here rather than
   repeated as literals in the markup — and it carries them in BOTH appearances, unchanged, because the owner ruled
   (r2026-08-25) that the moon which lands is the moon that flew and its colour may not be adjusted on arrival.
   WHAT THAT COSTS, stated so nobody has to rediscover it: the button carries no disc and no border ([artboard]), so
   this drawing is the whole affordance, and on the DAY canvas the pale fields sit under the 3:1 non-text contrast
   bar (#8fc9de measures 1.71:1 against --dd-bg, #f3d69d 1.33:1). On the NIGHT canvas — the app's own context, and
   the appearance the approved screens draw the crescent light for — every field clears it comfortably. An owner
   decision outranks a guideline here; if the day contrast is to be fixed, it must be fixed without recolouring the
   moon (a shadow, a ring, or a darker canvas behind the corner), and that is a decision for the owner, not a
   silent recolour by a later session. */
.gm-crescent { fill: url(#gm-grad); }
.gm-s1 { stop-color: var(--dd-brand-blue); }
.gm-s2 { stop-color: var(--dd-brand-violet); }
.gm-s3 { stop-color: var(--dd-brand-blush); }
.gm-s4 { stop-color: var(--dd-brand-gold-light); }
/* DAYLIGHT (owner order r2026-08-25). At night the mark is the preloader's moon, colour for colour. On the day
   canvas the same pastels leave a control below the 3:1 non-text floor, and the owner's answer is not to darken
   them arbitrarily but to make the moon and the microphone ONE colour: the day mic's own deep rose. So in light
   appearance the moon is that single shade, flat — the same mark, one ink — and the sheen goes with the shading it
   belonged to. Measured against --dd-bg: 5.25:1. */
html:not([data-theme="dark"]) .gm-crescent { fill: var(--dd-mic-plum); }
/* the satellite pass: points, not boxes — a 2.6 px dot with a hairline glow, drawn in the star ink so it belongs to
   the sky it crosses. Position and travel are written by src/ui/v8-shell.js; nothing here animates. */
/* THE SATELLITE PASS. Points of equal brightness in single file, dim and steady: what makes a Starlink train
   recognisable is the GEOMETRY (evenly spaced dots, one line, one speed, no blinking), never brightness. A glow
   large enough to notice is what made the first version frightening, so the dot is small and its halo is one soft
   pixel. Drawn in the star ink so it belongs to the sky it crosses. Vector only: a rounded box and a shadow, no
   raster anywhere. Travel is written by src/ui/v8-shell.js; nothing here animates. */
.ambient__sat { position: absolute; width: 2.2px; height: 2.2px; margin: -1.1px 0 0 -1.1px; border-radius: 50%;
  background: var(--dd-star-5); box-shadow: 0 0 2px 0 var(--dd-star-5); opacity: 0; pointer-events: none; }
/* THE METEOR. Its own object, with a tail that points along its travel — a head and a gradient trail, both vector.
   It is spawned for one streak and removed when the streak ends: nothing in a night sky ever flies back, which is
   what the previous version did and what the owner reported. --tilt is written per event, so no two share a path. */
.ambient__meteor { position: absolute; border-radius: 50%; background: var(--dd-star-1);
  box-shadow: 0 0 4px 0.5px var(--dd-star-1); opacity: 0; pointer-events: none; }
/* the crossing: the same object, larger and with a tail long enough to read at that speed. It is the rare one. */
.ambient__meteor--long { box-shadow: 0 0 7px 1px var(--dd-star-1); }

/* THE COMET. One drawing, ten parameter sets (src/ui/v8-shell.js COMETS), all vector: the coma is a radial
   gradient, the core a lit dot, each tail rotated to its own angle. Built from what the real ones look like in
   NASA/JPL imagery - a small bright nucleus inside a round glow, a BROAD warm dust tail that lags the path and
   CURVES, and a NARROW blue-white ion tail that runs straight. Sizes come from --core, so one comet is a set of
   numbers and not a second drawing to maintain. */
.comet { position: absolute; width: 0; height: 0; opacity: 0; pointer-events: none; }
.comet__coma { position: absolute; left: 50%; top: 50%; width: calc(var(--core) * 4.2); height: calc(var(--core) * 4.2);
  margin: calc(var(--core) * -2.1) 0 0 calc(var(--core) * -2.1); border-radius: 50%;
  background: radial-gradient(circle at 50% 50%, var(--comet-ink) 0%, color-mix(in srgb, var(--comet-ink) 45%, transparent) 38%, transparent 72%);
  opacity: calc(0.55 * var(--glow)); }
.comet__core { position: absolute; left: 50%; top: 50%; width: var(--core); height: var(--core);
  margin: calc(var(--core) / -2) 0 0 calc(var(--core) / -2); border-radius: 50%; background: #ffffff;
  box-shadow: 0 0 calc(var(--core) * 1.6) calc(var(--core) * 0.35) var(--comet-ink); }
.comet__tail { position: absolute; left: 50%; top: 50%; width: var(--wide); height: var(--len);
  margin-left: calc(var(--wide) / -2); transform-origin: 50% 0; transform: rotate(var(--turn)) translateY(var(--lead, 0px));
  border-radius: 50% 50% 40% 40% / 12% 12% 88% 88%; }
/* THE CURVE IS THE PATH'S OWN (DF-149, then DF-154 — the radius). The dust band is a segment of a RING whose centre
   sits off to one side (--bow, written per pass from the travel direction) and whose stroke passes through the
   anchor. The radius is --arc: the radius of the circle the HEAD is flying (src/ui/v8-shell.js arcPath), so the
   tail cannot curl on a different curve from the trajectory — it is the same circle, and the tail is the part of it
   already travelled. How much of that curve SHOWS depends on the tail's own length (bow = len²/8R): the long-tailed
   kinds bow 3-14 px and read as curved, a 26 px stub reads straight because a stub of any admissible circle is
   straight. --sagpx sizes the box to the bow the pass actually has; the 1.5-tail-length fallback is what this rule
   assumed before the path had a radius to give it. The fade along the arc is the mask; the ion tail stays a straight
   gradient, because ion tails ARE straight. translateY(var(--lead)) pushes the anti-tail spike out past the coma
   along its own axis: a spike drawn from the centre pierced the glow it belongs to. */
/* THE TAILS ARE SHAPED BY WHAT MAKES THEM (r2026-08-27, owner report "the tails look cut off", researched against
   the astronomy: NASA/JPL imagery readings, Sky & Telescope, EBSCO Research Starters).
     THE ION TAIL is narrow, straight and collimated - the solar wind carries the ions straight out - so it stays a
     hard linear gradient of the coldest ink in the palette.
     THE DUST TAIL is broad, diffuse and gently curved: grains keep their own orbital motion, so the band WIDENS
     away from the head and fades out rather than ending. That is what the old drawing got wrong. It was a segment
     of a RING (a radial gradient at radius --arc), so once the flight path became straight the ring resolved to a
     fallback radius and the visible band read as a sliced-off crescent - the "cut off" tail. A cone does not have
     that failure mode at any path shape: it is drawn along its own axis, widened with a clip-path, and dissolved
     with a soft mask, so the tail is always the same object however the head is travelling. */
.comet__tail--dust { width: calc(var(--wide) * 3.4); margin-left: calc(var(--wide) * -1.7); border-radius: 0;
  background: linear-gradient(to bottom,
    color-mix(in srgb, var(--comet-dust) 92%, transparent) 0%,
    color-mix(in srgb, var(--comet-dust) 58%, transparent) 26%,
    color-mix(in srgb, var(--comet-dust) 26%, transparent) 62%,
    transparent 100%);
  clip-path: polygon(38% 0%, 62% 0%, 100% 78%, 88% 100%, 12% 100%, 0% 78%);
  -webkit-mask: linear-gradient(to bottom, #000 0%, rgba(0, 0, 0, 0.85) 42%, rgba(0, 0, 0, 0.4) 78%, transparent 100%);
  mask: linear-gradient(to bottom, #000 0%, rgba(0, 0, 0, 0.85) 42%, rgba(0, 0, 0, 0.4) 78%, transparent 100%);
  opacity: calc(0.5 * var(--glow)); filter: none; }
.comet__tail--ion { width: var(--wide);
  background: linear-gradient(to bottom, color-mix(in srgb, var(--comet-ion) 96%, transparent) 0%,
    color-mix(in srgb, var(--comet-ion) 62%, transparent) 46%, transparent 94%);
  opacity: calc(0.62 * var(--glow)); }
/* a fragment: the second nucleus a splitting comet shows, trailing its parent by a few pixels */
.comet__fragment { position: absolute; left: 50%; top: 50%; width: calc(var(--core) * 0.45); height: calc(var(--core) * 0.45);
  margin: calc(var(--core) * 0.9) 0 0 calc(var(--core) * -0.6); border-radius: 50%; background: #ffffff; opacity: 0.7; }
/* the four tints, all from the existing star and brand inks - no new colour enters the palette here */
.comet--warm { --comet-ink: var(--dd-brand-gold-light); --comet-dust: var(--dd-brand-gold); --comet-ion: var(--dd-star-1); }
.comet--cool { --comet-ink: var(--dd-star-1); --comet-dust: var(--dd-star-5); --comet-ion: var(--dd-brand-blue); }
.comet--mixed { --comet-ink: var(--dd-brand-blush); --comet-dust: var(--dd-brand-gold-light); --comet-ion: var(--dd-brand-blue); }
.comet--green { --comet-ink: var(--dd-success); --comet-dust: var(--dd-brand-gold-light); --comet-ion: var(--dd-success); }

/* THE STRIKE'S OWN LOOK. It is the one comet that arrives rather than passes, so it is drawn to be recognised as
   different long before it lands: the blush-and-rose pair no other kind uses, a wider coma, and a core with a real
   glow. Same drawing, its own tint — nothing new to maintain. */
.comet--strike { --comet-ink: var(--dd-brand-blush); --comet-dust: var(--dd-mic-halo); --comet-ion: var(--dd-brand-violet); }
.comet--strike .comet__coma { width: calc(var(--core) * 5.4); height: calc(var(--core) * 5.4);
  margin: calc(var(--core) * -2.7) 0 0 calc(var(--core) * -2.7); opacity: calc(0.62 * var(--glow)); }
.comet--strike .comet__core { box-shadow: 0 0 calc(var(--core) * 2.2) calc(var(--core) * 0.5) var(--comet-ink); }

/* THE SHOCK OF HEARTS. It lives INSIDE the microphone button, so it cannot drift from the control it belongs to at
   any artboard scale, and it is invisible to the pointer and to the accessibility tree: the button keeps its own
   name, its own hit area and its own behaviour — the ring is only light on top of it. --r is the radius the hearts
   settle on (the mic's own halo, so they sit ON the circle, not inside its face), --a is each heart's angle, and the
   engine writes both plus the per-heart size. Hearts are SVG paths: one geometry, any size, no font, no raster. */
.strikering { position: absolute; left: 50%; top: 50%; width: 0; height: 0; pointer-events: none; z-index: 3; overflow: visible;
  --r: 64px; }   /* fallback only: impact() measures the button and writes the real radius (DF-160) */
/* THE SALUTE'S PIECES. Confetti, not icons: three shapes in one mix, sized in artboard pixels from --sz (the engine
   draws 5-13 px per piece from the MEASURED button radius, so the burst is proportional at any scale) and tinted from
   four brand inks in rotation - the vanilla-pastel set the product already owns, no new colour. Each piece is its own
   element so the engine can animate it as a body: thrown, held on the heart curve, then falling. */
.strikering__bit { position: absolute; left: 50%; top: 50%; width: var(--sz, 12px); height: var(--sz, 12px);
  margin: calc(var(--sz, 9px) / -2) 0 0 calc(var(--sz, 9px) / -2); opacity: 0; will-change: transform, opacity; }
.strikering__bit svg { width: 100%; height: 100%; display: block; }
.strikering__bit--strip { width: calc(var(--sz, 9px) * 0.42); height: calc(var(--sz, 9px) * 1.5);
  margin: calc(var(--sz, 9px) * -0.75) 0 0 calc(var(--sz, 9px) * -0.21); border-radius: 999px; }
.strikering__bit--dot { width: calc(var(--sz, 9px) * 0.5); height: calc(var(--sz, 9px) * 0.5);
  margin: calc(var(--sz, 9px) * -0.25) 0 0 calc(var(--sz, 9px) * -0.25); border-radius: 50%; }
/* THE FOUR INKS, one per piece in rotation, selected by CLASS. They were selected as [style*="--tint:b"] for one
   round and that could never match: setProperty serialises a custom property WITH a space, so every branch missed and
   the whole salute rendered in the base ink.
   r2026-08-26, owner report: ONE SET FOR BOTH APPEARANCES. The night branch was missing entirely - only the day
   branch carried a fill - so on the dark canvas every heart fell back to the SVG default and the salute rendered
   BLACK. The day set (plum, gold, crescent, blue-deep) is the one the owner approved, so it is now theme-independent:
   the same four vanilla-pastel brand inks in both appearances, no new colour, nothing left to a UA default. */
.strikering__bit--ink0 path { fill: var(--dd-mic-plum); }
.strikering__bit--ink0.strikering__bit--strip, .strikering__bit--ink0.strikering__bit--dot { background: var(--dd-mic-plum); }
.strikering__bit--ink1 path { fill: var(--dd-brand-gold); }
.strikering__bit--ink1.strikering__bit--strip, .strikering__bit--ink1.strikering__bit--dot { background: var(--dd-brand-gold); }
.strikering__bit--ink2 path { fill: var(--dd-crescent); }
.strikering__bit--ink2.strikering__bit--strip, .strikering__bit--ink2.strikering__bit--dot { background: var(--dd-crescent); }
.strikering__bit--ink3 path { fill: var(--dd-brand-blue-deep); }
.strikering__bit--ink3.strikering__bit--strip, .strikering__bit--ink3.strikering__bit--dot { background: var(--dd-brand-blue-deep); }
/* THE DEMONSTRATION BAR. It is an instrument, not product furniture: it only exists while the demonstration switch
   is on, it sits over the surface, and it says the percentage out loud so completion is a number and not a feeling. */
.skydemo { position: absolute; left: 0; right: 0; top: 0; height: 22px; z-index: 60; pointer-events: none;
  background: color-mix(in srgb, var(--dd-backdrop-sheet) 70%, transparent); }
.skydemo__fill { position: absolute; left: 0; top: 0; bottom: 0; width: 0;
  background: linear-gradient(to right, var(--dd-brand-blue), var(--dd-brand-blush)); opacity: 0.75; }
.skydemo__label { position: absolute; left: 0; right: 0; top: 4px; text-align: center;
  font-family: var(--dd-font-mono); font-size: 11px; line-height: 14px; color: var(--dd-text); }
/* THE METEOR TAIL IS A CHILD, NOT A PSEUDO-ELEMENT (DF-145/DF-147). A real node can be animated as its own object
   — the grow-and-burn of the streak — and it PORTS: React Native has no ::after, so a pseudo-element tail is a
   rewrite at export time while a child element is an Animated.View. The static transform below is the resting
   pose; src/ui/v8-shell.js animates the full transform string over it. */
.ambient__trail { position: absolute; left: 50%; top: 50%; width: 1px; height: 26px; margin-left: -0.5px;
  transform-origin: 50% 0; transform: rotate(var(--tilt, 0deg));
  background: linear-gradient(to bottom, var(--dd-star-1), transparent); opacity: 0.55; }
.ambient__meteor--long .ambient__trail { height: 54px; opacity: 0.7; }
/* the wishing star: the one falling streak meant to be NOTICED and wished on, so it carries a brighter head and a
   longer trail than the ordinary short fall (owner order r2026-08-27b) */
.ambient__meteor--wish { background: #ffffff;
  box-shadow: 0 0 10px 2.5px color-mix(in srgb, var(--dd-star-1) 85%, transparent); }
.ambient__meteor--wish .ambient__trail { height: 46px; opacity: 0.9; }

/* THE WISHING STONE (bolide) — the asteroid people actually wish on, and the one event in this sky with a body
   rather than a point. Vector only: an irregular clip-path silhouette over a warm ablation envelope, fragments as
   dots, and a train that is its OWN element in the sky because the train marks the path and a path does not
   travel. --size is the body, --len the flown distance, --rot the travel direction; the engine writes all three. */
.bolide { position: absolute; width: 0; height: 0; opacity: 0; pointer-events: none; }
.bolide__glow { position: absolute; left: 50%; top: 50%; width: calc(var(--size) * 3.4); height: calc(var(--size) * 3.4);
  margin: calc(var(--size) * -1.7) 0 0 calc(var(--size) * -1.7); border-radius: 50%;
  background: radial-gradient(circle at 50% 50%, var(--dd-brand-gold-light) 0%, color-mix(in srgb, var(--dd-brand-gold) 60%, transparent) 38%, transparent 72%); }
.bolide__rock { position: absolute; left: 50%; top: 50%; width: var(--size); height: var(--size);
  margin: calc(var(--size) / -2) 0 0 calc(var(--size) / -2);
  background: linear-gradient(140deg, #ffffff 0%, var(--dd-brand-gold-light) 44%, var(--dd-brand-gold) 100%);
  clip-path: polygon(50% 0%, 79% 11%, 95% 41%, 85% 75%, 55% 97%, 23% 87%, 5% 57%, 15% 23%); }
.bolide__frag { position: absolute; left: 50%; top: 50%; width: calc(var(--size) * 0.3); height: calc(var(--size) * 0.3);
  margin: calc(var(--size) * -0.15) 0 0 calc(var(--size) * -0.15); border-radius: 50%;
  background: var(--dd-brand-gold-light); opacity: 0; }
.bolide__train { position: absolute; width: calc(var(--size) * 0.55); height: var(--len, 0px);
  margin-left: calc(var(--size) * -0.275); transform-origin: 50% 0; transform: rotate(var(--rot, 0deg));
  border-radius: 50%; opacity: 0; pointer-events: none;
  background: linear-gradient(to bottom, color-mix(in srgb, var(--dd-brand-gold) 60%, transparent),
    color-mix(in srgb, var(--dd-star-1) 40%, transparent) 45%, transparent 92%); }

/* THE DAY SKY (DF-152). Every travelling element here was drawn for a NIGHT canvas: a white-hot core, a pale gold
   envelope, a hairline glow — all of them light ON DARK. On the day canvas the same drawing is light on light, so
   the coma disappears, the core vanishes and the satellite dots stop existing. The rule daylight graphics follow is
   the inverse of the night one: a bright object reads by its DARK, saturated ink, not by its glow. So the inks swap
   to the palette's deep values, the glows drop, the opacities rise. Same geometry, same tokens, no new colour. */
html:not([data-theme="dark"]) .comet__core { background: var(--dd-crescent);
  box-shadow: 0 0 calc(var(--core) * 1.2) 0 color-mix(in srgb, var(--comet-ink) 70%, transparent); }
html:not([data-theme="dark"]) .comet__tail--ion { opacity: calc(0.66 * var(--glow)); }
html:not([data-theme="dark"]) .comet__fragment { background: var(--dd-crescent); }
html:not([data-theme="dark"]) .comet--warm { --comet-ink: var(--dd-mic-plum); --comet-dust: var(--dd-brand-gold); --comet-ion: var(--dd-brand-blue-deep); }
html:not([data-theme="dark"]) .comet--cool { --comet-ink: var(--dd-brand-blue-deep); --comet-dust: var(--dd-star-1); --comet-ion: var(--dd-accent); }
html:not([data-theme="dark"]) .comet--mixed { --comet-ink: var(--dd-brand-plum); --comet-dust: var(--dd-star-4); --comet-ion: var(--dd-accent); }
html:not([data-theme="dark"]) .comet--green { --comet-ink: var(--dd-success); --comet-dust: var(--dd-brand-gold); --comet-ion: var(--dd-success); }
html:not([data-theme="dark"]) .ambient__meteor { background: var(--dd-crescent);
  box-shadow: 0 0 3px 0.5px color-mix(in srgb, var(--dd-crescent) 55%, transparent); }
html:not([data-theme="dark"]) .ambient__meteor--long { box-shadow: 0 0 5px 1px color-mix(in srgb, var(--dd-crescent) 45%, transparent); }
html:not([data-theme="dark"]) .ambient__trail { background: linear-gradient(to bottom, var(--dd-crescent), transparent); opacity: 0.5; }
html:not([data-theme="dark"]) .ambient__sat { background: var(--dd-brand-blue-deep); box-shadow: none; }
/* the wishing stone's body is 5-9 px, so its LEADING mass carries the whole silhouette. The night ramp runs
   white-hot to gold; the day ramp must run the other way, or the pale gold that reads as heat on the night canvas
   is 1.33:1 on the day one and the front of the asteroid disappears. Measured against #faf7ff: gold 2.17:1 at the
   very tip, mic-plum 5.29:1 by 55%, crescent 4.82:1 at the tail — the body clears the 3:1 graphic floor from about
   15% on, and only the ablating tip is softer than it, which is what a tip should be. */
html:not([data-theme="dark"]) .bolide__rock { background: linear-gradient(140deg, var(--dd-brand-gold) 0%, var(--dd-mic-plum) 55%, var(--dd-crescent) 100%); }
html:not([data-theme="dark"]) .bolide__glow { background: radial-gradient(circle at 50% 50%, var(--dd-mic-plum) 0%, color-mix(in srgb, var(--dd-brand-plum) 55%, transparent) 40%, transparent 74%); }
html:not([data-theme="dark"]) .bolide__frag { background: var(--dd-brand-gold); }
html:not([data-theme="dark"]) .bolide__train { background: linear-gradient(to bottom, color-mix(in srgb, var(--dd-mic-plum) 55%, transparent),
  color-mix(in srgb, var(--dd-brand-plum) 35%, transparent) 45%, transparent 92%); }
html:not([data-theme="dark"]) .ambient__st--extra { background: var(--dd-brand-plum); }
/* THE SPLASH SKY ON THE DAY CANVAS. Measured against #faf7ff, four of its five inks were under the 3:1 graphic
   floor — star-1 2.75, star-2 and star-7 2.17, star-4 2.14 — and they run at 0.14-0.72 alpha, so on the light
   appearance the twelve points were effectively not there. These stars are this build's own addition (r2026-08-24t),
   not the approved asset, so they may be re-inked: same roles, the palette's deep values (plum 3.52, crescent 4.82,
   mic-plum 5.29, blue-deep 3.15, accent 5.81). The SEVEN artboard stars are left alone — those colours are the
   approved reference and only their alpha is raised. */
html:not([data-theme="dark"]) .loader__sky span { background: var(--dd-brand-plum); }
html:not([data-theme="dark"]) .loader__sky span:nth-child(2) { background: var(--dd-crescent); }
html:not([data-theme="dark"]) .loader__sky span:nth-child(4),
html:not([data-theme="dark"]) .loader__sky span:nth-child(8) { background: var(--dd-mic-plum); }
html:not([data-theme="dark"]) .loader__sky span:nth-child(6) { background: var(--dd-brand-blue-deep); }
html:not([data-theme="dark"]) .loader__sky .spark { background: var(--dd-accent); }
/* THE DAY SKY, TONED (owner order r2026-08-24w8: on the light canvas the sky was too loud and pulled the eye off
   the value). The deep inks stay — they are what makes an object visible on a light canvas — but they are carried at
   a fraction of their strength, so the events read as weather rather than as UI: comas and tails drop about a third
   of their alpha, heads keep a tight glow instead of a halo. The SATELLITE is the exception and goes the other way:
   at --dd-brand-blue-deep it was 3.15:1 on a 2.2 px dot and the owner could not find it, so it takes the accent
   (5.81:1) and a hairline ring of its own. The night canvas is untouched by every rule in this block. */
html:not([data-theme="dark"]) .comet__coma { opacity: calc(0.46 * var(--glow)); }
html:not([data-theme="dark"]) .comet__tail--dust { opacity: calc(0.38 * var(--glow)); }
html:not([data-theme="dark"]) .comet__tail--ion { opacity: calc(0.42 * var(--glow)); }
html:not([data-theme="dark"]) .comet__core { background: color-mix(in srgb, var(--dd-crescent) 88%, transparent);
  box-shadow: 0 0 calc(var(--core) * 0.9) 0 color-mix(in srgb, var(--comet-ink) 45%, transparent); }
html:not([data-theme="dark"]) .ambient__meteor { box-shadow: 0 0 2px 0 color-mix(in srgb, var(--dd-crescent) 40%, transparent); }
html:not([data-theme="dark"]) .ambient__trail { opacity: 0.34; }
html:not([data-theme="dark"]) .ambient__sat { width: 2.6px; height: 2.6px; margin: -1.3px 0 0 -1.3px;
  background: var(--dd-accent); box-shadow: 0 0 0 0.5px color-mix(in srgb, var(--dd-accent) 45%, transparent); }
html:not([data-theme="dark"]) .bolide__glow { opacity: 0.72; }
html:not([data-theme="dark"]) .bolide__train { opacity: 0.8; }
/* the scattered sky: the seven reference stars are the artboard’s, these are the rest of the night. Same ink,
   smaller, spread across the WHOLE surface (the owner asked for a sky, not a band at the top). */
.ambient__st--extra { width: 2px; height: 2px; margin: -1px 0 0 -1px; border-radius: 50%;
  background: var(--dd-star-2); opacity: 0; }
.ambient__st { transform-origin: 50% 50%; }
.sliders { width: 48px; height: 48px; }
.sliders__line { fill: none; stroke: var(--dd-text); stroke-width: 2.2; stroke-linecap: round; }
.sliders__dot { fill: var(--dd-bg); stroke: var(--dd-text); stroke-width: 2.2; }

/* --- ambient field: the seven reference stars, as percentages of the artboard (identical to its px) --- */
.ambient { position: absolute; inset: 0; width: 100%; height: 100%; pointer-events: none; z-index: 0; }
.ambient__st { position: absolute; width: 20px; height: 20px; margin: -10px 0 0 -10px; opacity: 0.34; }
.ambient__st svg { width: 100%; height: 100%; overflow: visible; }
.a1 { left: 13.3%; top: 19.4%; } .a2 { left: 78.5%; top: 17.9%; } .a3 { left: 88.2%; top: 29.9%; } .a4 { left: 18.7%; top: 43.4%; }
.a5 { left: 79.7%; top: 48.6%; } .a6 { left: 10%; top: 61.5%; } .a7 { left: 85.4%; top: 72.3%; }
.a1 .st__fill { fill: var(--dd-star-1); } .a2 .st__fill { fill: var(--dd-star-2); } .a3 .st__fill { fill: var(--dd-star-3); }
.a4 .st__fill { fill: var(--dd-star-4); } .a5 .st__fill { fill: var(--dd-star-5); } .a6 .st__fill { fill: var(--dd-star-6); }
.a7 .st__fill { fill: var(--dd-star-7); }
.ambient__trail { stroke: var(--dd-star-3); opacity: 0; }

/* --- lockup: gold star at 142,69.2; violet dot at 155,69; DREAM baseline y77, x164 --- */
#content .brand { position: absolute; left: 0; top: 0; width: 100%; height: 0; margin: 0; display: block; }
#content .brand__mark { position: absolute; left: 140px; top: 60px; width: 20px; height: 20px; margin: 0; }
#content .brand__word { position: absolute; left: 164px; top: calc(77px - 11px * (1 + var(--dd-asc) - var(--dd-desc)) / 2); margin: 0;
  font-family: var(--dd-font-artboard);
  font-size: 11px; font-weight: 700; letter-spacing: 2.5px; line-height: 1; color: var(--dd-lockup-ink); }

/* BASELINE PLACEMENT (DF-108). The references position text by BASELINE; CSS positions boxes. With line-height:1 the
   distance from the box top to the baseline is fontSize x (1 + asc - desc) / 2, and with an explicit line-height L it
   is (L - fontSize x (asc + desc)) / 2 + fontSize x asc. asc and desc are the resolved font's own bounding-box ratios,
   measured pre-paint by the appearance boot. Every rule below therefore states the REFERENCE BASELINE as the number a
   reader can check against the SVG, and derives the box position from it — rather than the other way round, which is
   how an assumed 0.8 constant put every baseline 1-3 px low with a green suite. */
/* --- ready: title baseline 284, hint baseline 320 --- */
#content .title { position: absolute; left: 0; right: 0; top: calc(284px - 26px * (1 + var(--dd-asc) - var(--dd-desc)) / 2); margin: 0; min-height: 0;
  font-family: var(--dd-font-artboard); font-size: 26px; font-weight: 600; line-height: 1;
  letter-spacing: normal; text-align: center; text-wrap: nowrap; }
#content .lede { position: absolute; left: 0; right: 0; top: calc(320px - 15px * (1 + var(--dd-asc) - var(--dd-desc)) / 2); margin: 0; min-height: 0;
  font-family: var(--dd-font-artboard); font-size: 15px; line-height: 1; text-align: center; color: var(--dd-text-muted); }

/* --- the wave: 29 bars of 4 px on a 10 px pitch (gap 6), centred on y=340, tallest 116 --- */
.wave--live { position: absolute; left: 55px; top: 282px; width: 284px; height: 116px;
  display: flex; gap: 6px; align-items: center; justify-content: space-between; overflow: visible; }

/* The interruption line. NOTHING renders in the ordinary state: the references draw no label there. It appears only
   when sound has stopped arriving or durable storage is refusing writes — the two facts [TS-113] exists for. */
.record-card { position: absolute; left: 24px; right: 24px; top: 418px; margin: 0; text-align: center; }
.record-card .card__head { display: flex; align-items: center; justify-content: center; gap: 6px; margin: 0; }
.record-card .card__note { margin: 4px 0 0; color: var(--dd-text-muted); font-size: 13px; }
/* DF-126. The markup ships data-rec="live" on #record-card, so this rule hid the card whose entire job is to say
   "listening" exactly when listening starts — and with it every interruption surface built in r2026-08-24j.
   "idle" is the value that means nothing is being captured; "live" is the value that means it is. */
#content[data-rec="idle"] .record-card, .record-card[data-rec="idle"] { display: none; }
/* The elapsed time had never been visible (DF-125), so it had no geometry either: a static <p> inside an absolutely
   positioned artboard would have landed on the corner controls the moment it was revealed. It sits under the
   waveform and above the microphone, centred, in the artboard's own coordinates. */
#record-timer { position: absolute; left: 0; right: 0; top: 440px; margin: 0; text-align: center; z-index: 3; }

/* --- THE ONE CONTROL: halo r70 and disc r58 at (195,536); glyph 32x46 at (179,512) --- */
.mic { position: absolute; left: 125px; top: 466px; width: 140px; height: 140px; min-height: 0;
  padding: 0; border: 0; background: transparent; border-radius: 50%; cursor: pointer; z-index: 4; }
.mic__halo { position: absolute; inset: 0; border-radius: 50%;
  background: var(--dd-mic-halo); opacity: 0.12; filter: blur(12px); }
.mic__core { position: absolute; left: 12px; top: 12px; width: 116px; height: 116px; border-radius: 50%;
  background: radial-gradient(72% 72% at 45% 30%, var(--dd-mic-core-1) 0%, var(--dd-mic-core-2) 100%);
  box-shadow: 0 10px 28px rgba(0, 0, 0, 0.16); }
.mic__glyph { position: absolute; left: 42px; top: 34px; width: 32px; height: 46px;
  fill: none; stroke: #ffffff; stroke-width: 3; stroke-linecap: round; stroke-linejoin: round; }
.mic__stop { display: none; position: absolute; left: 44px; top: 44px; width: 28px; height: 28px;
  border-radius: 6px; background: var(--dd-stop-square); }
[data-mode="recording"] .mic__glyph { display: none; }
[data-mode="recording"] .mic__stop { display: block; }
[data-mode="recording"] .mic .mic__core { background: var(--dd-stop-face);
  box-shadow: inset 0 0 0 2px var(--dd-stop-ring), 0 10px 28px rgba(0, 0, 0, 0.16); }
[data-mode="recording"] .mic .mic__halo { opacity: 0; }

/* --- clouds: the reference's own two paths in the artboard's own space, seen through a fade (owner order
   r2026-08-25). What was wrong was never the silhouette, it was that the silhouette had an EDGE: a scalloped line
   ran across the foot of the screen and the eye found it immediately. The paths are unchanged; each band is drawn
   through a vertical gradient mask (index.html), so its crest is at zero and only the body near the footer carries
   weight. Three planes now — a faint mist behind the two reference bands — which is what makes a two-colour horizon
   read as depth rather than as two shapes.
   THE DRIFT. Each plane sways on its own long period, in the opposite direction to the plane in front of it, which
   is parallax: 5-9 px over 46-96 s is under the speed at which motion is read AS motion, so it is read as air. The
   bands are scaled a few percent wider than the artboard so the sway can never expose an edge, and they only ever
   move DOWN, never up, because the artboard's own foot is the bottom of the path.
   It is decorative motion that starts on its own and runs longer than five seconds, so it belongs to the
   Ambient-motion switch (AC-MOT-13/14) as well as to both reduced-motion routes. --- */
/* r2026-08-26: the window-pinned shell layers are gone - the stage owns the sky and the horizon for every screen.
   What remains is the RESERVED BAND at the foot of a scrolling screen, so no control can end up under the horizon.
   A generated last child is in the flow (a clamped screen's own padding is not), so it is counted by the scroll. */
.state--capture::after, .state--error::after, .state--legal::after {
  content: ""; display: block; flex: none; height: calc(var(--dd-flowfoot) + var(--dd-s5)); }
.state--capture, .state--error { position: relative; }
.clouds { position: absolute; left: 0; top: 0; width: 390px; height: 844px; z-index: 0; pointer-events: none; }
.clouds__far { fill: var(--dd-cloud-1); }
.clouds__near { fill: var(--dd-cloud-2); }
/* Three planes have to be three TONES as well as three shapes, and the WEIGHT ORDER of a real sky: the farthest
   plane is the palest and faintest, the near band carries the ink. r2026-08-24w found this inverted - the mist ran
   at 0.46 of a mix leaning the DARKER ink while the plane in front of it was erased by its own feather - so the
   mist now leans hard to the light ink at lower opacity. Same palette, no new colour. */
.clouds__mist { fill: color-mix(in srgb, var(--dd-cloud-1) 86%, var(--dd-cloud-2)); opacity: 0.38; }
/* the grain plate: enough to dither the ramps, far too little to be seen as texture */
/* The grain plate, and why it is composited NORMALLY. Dither has to move the result by about one 8-bit level to
   break a one-level step. On a near-black canvas an overlay blend multiplies toward the backdrop and moves it by a
   fraction of a level - measured, the bands survived it. Plain compositing of a full-swing noise at 2.2% moves the
   result by about two and a half levels, which is what actually dissolves the steps, and it lifts the average by
   roughly one level: invisible. Static: one raster, nothing per frame. */
.clouds__grain { opacity: 0.022; pointer-events: none; }
.clouds__drift { transform-box: view-box; transform-origin: 195px 844px; }
.clouds__drift--mist { animation: dd-cloud-mist 96s ease-in-out infinite; }
.clouds__drift--far { animation: dd-cloud-far 64s ease-in-out infinite; }
.clouds__drift--near { animation: dd-cloud-near 46s ease-in-out infinite; }
@keyframes dd-cloud-mist { 0%, 100% { transform: scaleX(1.08) translate3d(-9px, 0, 0); } 50% { transform: scaleX(1.08) translate3d(9px, 3px, 0); } }
@keyframes dd-cloud-far { 0%, 100% { transform: scaleX(1.06) translate3d(7px, 0, 0); } 50% { transform: scaleX(1.06) translate3d(-7px, 2px, 0); } }
@keyframes dd-cloud-near { 0%, 100% { transform: scaleX(1.05) translate3d(-5px, 0, 0); } 50% { transform: scaleX(1.05) translate3d(5px, 1px, 0); } }
/* The Ambient switch is the pause mechanism for decorative motion that starts on its own and runs longer than five
   seconds (AC-MOT-13/14/15). The splash sky is exactly that — twelve points on 4.1-9.4 s cycles, running before the
   user has touched anything — so it obeys the same switch as the horizon drift. Both reduced-motion routes already
   stopped it; this is the third route, and it was the one a returning user would have noticed. */
html[data-ambient="off"] .clouds__drift, html[data-ambient="off"] .loader__sky span { animation: none !important; }
.footer { position: absolute; left: 0; right: 0; top: calc(811px - 13px * (1 + var(--dd-asc) - var(--dd-desc)) / 2); z-index: 1; margin: 0; padding: 0;
  font-family: var(--dd-font-artboard); color: var(--dd-footer-ink); font-size: 13px; font-weight: 500; line-height: 1; text-align: center; }

/* --- saved: toast 282x76 at (54,108); centre halo r57 and disc r45 at (195,423) --- */
.toast { position: absolute; left: 54px; top: 108px; width: 282px; height: 76px; z-index: 12;
  display: block; padding: 0; text-align: left; border: 1px solid var(--dd-toast-border); border-radius: 16px;
  background: var(--dd-toast-bg); box-shadow: 0 10px 28px var(--dd-shadow-card); }
.toast__check { position: absolute; left: 16px; top: 16px; width: 28px; height: 32px;
  fill: none; stroke: var(--dd-glyph-accent); stroke-width: 3; stroke-linecap: round; stroke-linejoin: round; }
.toast strong { position: absolute; left: 52px; top: calc(28px - 14px * (1 + var(--dd-asc) - var(--dd-desc)) / 2); font-size: 14px; font-weight: 700; line-height: 1; }
.toast span { position: absolute; left: 52px; top: calc(50px - 11.5px * (1 + var(--dd-asc) - var(--dd-desc)) / 2); color: var(--dd-text-muted); font-size: 11.5px; line-height: 1; }
.saved-center { position: absolute; inset: 0; margin: 0; z-index: 1; }
.saved-check { position: absolute; left: 138px; top: 366px; width: 114px; height: 114px; margin: 0;
  border-radius: 50%; background: color-mix(in srgb, var(--dd-glyph-accent) 12%, transparent); }
.saved-check::before { content: ""; position: absolute; left: 12px; top: 12px; width: 90px; height: 90px;
  border-radius: 50%; background: var(--dd-panel);
  border: 1px solid color-mix(in srgb, var(--dd-glyph-accent) 45%, transparent); }
.saved-check svg { position: absolute; left: 32px; top: 22px; width: 50px; height: 52px;
  fill: none; stroke: var(--dd-glyph-accent); stroke-width: 5; stroke-linecap: round; stroke-linejoin: round; }
.saved-center strong { position: absolute; left: 0; right: 0; top: calc(516px - 26px * (1 + var(--dd-asc) - var(--dd-desc)) / 2); display: block;
  font-size: 26px; font-weight: 600; line-height: 1; text-align: center; }
.saved-center span { position: absolute; left: 95px; right: 95px; top: calc(550px - (23px - 15px * (var(--dd-asc) + var(--dd-desc))) / 2 - 15px * var(--dd-asc)); display: block; margin: 0;
  color: var(--dd-text-muted); font-size: 15px; line-height: 23px; text-align: center; }

/* --- next: title baseline 220; cards 310x132 at (40,268) and (40,424), rx20 --- */
.next { position: absolute; inset: 0; display: block; width: auto; margin: 0; z-index: 1; }
.next, .toast, .saved-center { font-family: var(--dd-font-artboard); }
.next__title { position: absolute; left: 0; right: 0; top: calc(220px - 26px * (1 + var(--dd-asc) - var(--dd-desc)) / 2); margin: 0;
  font-size: 26px; font-weight: 600; line-height: 1; text-align: center; }
/* THE CHOICE CARDS READ AT A GLANCE (owner order r2026-08-27: "bad text spacing and sizes on the screen where you
   choose to sleep or to look - make it so nobody has to squint"). Researched against Material 3's layout rules:
   an 8dp spacing scale, line heights on a 4dp step, a 48dp minimum target. The three lines were absolutely
   positioned at artboard y-coordinates at 16/13/12 px, which is below the density the guidance asks of a primary
   choice and could not grow for a longer language. The card is a grid now: icon column, text column, one gap.
   Geometry that matters is kept - the same left edge, the same width, the same 132 px height as its FLOOR. */
.choice { position: absolute; left: 40px; width: 310px; min-height: 132px; height: auto;
  display: grid; grid-template-columns: 40px 1fr; align-items: center; column-gap: 16px;
  padding: 20px 20px 20px 24px;
  border: 1px solid var(--dd-choice-border); border-radius: 20px; background: var(--dd-choice-bg);
  color: var(--dd-text); text-align: left; }
.choice--sleep { top: 268px; }
.choice--review { top: 424px; }
.choice__icon { position: static; grid-column: 1; fill: none; stroke: var(--dd-glyph-accent); stroke-width: 2.4;
  stroke-linecap: round; stroke-linejoin: round; }
.choice--sleep .choice__icon { width: 36px; height: 30px; }
.choice--review .choice__icon { width: 36px; height: 40px; }
.choice__text { grid-column: 2; display: grid; gap: 4px; }
.choice b { position: static; display: block; font-size: 18px; font-weight: 700; line-height: 24px; }
.choice em { position: static; display: block; margin: 0; font-style: normal; font-size: 14px; line-height: 20px;
  color: var(--dd-text); }
.choice small { position: static; display: block; margin: 0; font-size: 14px; line-height: 20px;
  color: var(--dd-text-muted); }

/* --- what each mode shows. A mode only changes VISIBILITY; nothing that survives a mode change ever moves. --- */
[data-mode="ready"] .wave--live, [data-mode="ready"] .record-card,
[data-mode="ready"] .toast, [data-mode="ready"] .saved-center, [data-mode="ready"] .next { display: none; }
[data-mode="recording"] .title, [data-mode="recording"] .lede,
[data-mode="recording"] .toast, [data-mode="recording"] .saved-center, [data-mode="recording"] .next { display: none; }
[data-mode="saved"] .title, [data-mode="saved"] .lede, [data-mode="saved"] .mic,
[data-mode="saved"] .wave--live, [data-mode="saved"] .record-card, [data-mode="saved"] .next { display: none; }
[data-mode="next"] .title, [data-mode="next"] .lede, [data-mode="next"] .mic,
[data-mode="next"] .wave--live, [data-mode="next"] .record-card,
[data-mode="next"] .toast, [data-mode="next"] .saved-center { display: none; }

/* --- the settings sheet drops over the composition, never into the column --- */
.settings { position: absolute; top: 100px; right: 24px; z-index: 30; width: 296px;
  background: var(--dd-panel); border: 1px solid var(--dd-panel-border);
  border-radius: 16px; padding: 16px; box-shadow: 0 10px 24px var(--dd-shadow-card); text-align: left; }
/* BESIDE THE PHONE, NOT OVER IT (owner order r2026-08-27). On the web the board is a phone-sized card in the middle
   of a much wider window, so a sheet dropped over it hides the very screen it is meant to demonstrate - the sky, the
   animations, the microphone. When the window has room to the right of the board (half the window minus half the
   scaled board, plus the sheet and two gutters) the sheet moves OUT of the board and sits next to it. When it does
   not, nothing changes: the sheet drops over the composition exactly as before. The condition is the geometry
   itself, so there is no breakpoint to keep in step with the stage scale. */
html[data-sheet="side"] .settings { right: auto; left: calc(50% + (var(--dd-stage-w) * var(--dd-stage, 1)) / 2 + var(--dd-s5));
  top: calc(50% - (var(--dd-stage-h) * var(--dd-stage, 1)) / 2); max-height: calc(var(--dd-stage-h) * var(--dd-stage, 1));
  overflow: auto; }
/* the legal hub is a store requirement with no entry drawn on the approved capture screens, so it lives in the
   sheet the reference's own sliders control opens rather than as a button the references do not have */
.settings__legal { width: 100%; min-height: 48px; margin-top: 12px; padding: 0 12px;
  border: 1px solid var(--dd-border); border-radius: 12px; background: transparent;
  color: var(--dd-text); font-size: 14px; font-weight: 600; cursor: pointer; }
.settings__title { margin: 0 0 var(--dd-s2); font-size: var(--dd-fs-lead); font-weight: 600; color: var(--dd-text); }
.settings__row { display: flex; align-items: center; justify-content: space-between; gap: var(--dd-s3); min-height: var(--dd-control-min); }
.settings__label { color: var(--dd-text); font-size: 14px; font-weight: 650; }
.settings__hint { margin: var(--dd-s1) 0 0; color: var(--dd-text-muted); font-size: 11.5px; }
/* SETTINGS_*: the track is 48x32 rx16, ON fill is the graphic accent, the knob is a WHITE r12 circle. The button
   box stays 56x48 so the hit target keeps the 48px minimum the track alone cannot give. */
.switch { width: 56px; height: 48px; min-height: var(--dd-control-min); border: 0; background: transparent;
  position: relative; padding: 8px 4px; cursor: pointer; border-radius: 24px; }
.switch::before { content: ""; position: absolute; inset: 8px 4px; border-radius: 16px;
  background: var(--dd-surface-2); border: 1px solid var(--dd-border); transition: background 160ms ease; }
.switch__knob { position: absolute; top: 12px; left: 8px; width: 24px; height: 24px; border-radius: 50%;
  background: var(--dd-text-muted); transition: transform 160ms ease, background 160ms ease; }
.switch[aria-checked="true"]::before { background: var(--dd-glyph-accent); border-color: var(--dd-glyph-accent); }
.switch[aria-checked="true"] .switch__knob { transform: translateX(16px); background: #ffffff; }
.switch:focus-visible { outline: 3px solid var(--dd-focus); outline-offset: 2px; }
/* THE DEMONSTRATION TEMPO (DF-146). A review instrument needs a rate: at 1× a full pass of sixteen events is
   minutes long, which is how the owner met it. 0× freezes the sky mid-event (the one way to LOOK at a comet),
   1× is the design tempo, 2×-3× are review speeds. Four exact named rates, not a slider: the pressed one IS the
   state, and the bar over the sky repeats it. */
.speedset { display: flex; gap: 4px; }
.speedset__btn { min-width: 38px; min-height: var(--dd-control-min); padding: 0 5px; border: 1px solid var(--dd-border);
  border-radius: var(--dd-r1); background: transparent; color: var(--dd-text-muted);
  font-family: var(--dd-font-mono); font-size: 12px; font-weight: 650; cursor: pointer; }
/* the pressed ink is the TOKEN for ink on an accent-filled mark, not a hand-picked white: --dd-glyph-accent is
   documented in tokens.css as a 3:1 GRAPHIC colour, so 12px/650 copy on it must take the paired ink or it misses
   the 4.5:1 floor [TS-40] holds every other text pair to (measured: dark 4.67:1, light 4.51:1). */
.speedset__btn[aria-pressed="true"] { background: var(--dd-glyph-accent); border-color: var(--dd-glyph-accent); color: var(--dd-on-accent); }
.speedset__btn:focus-visible { outline: 3px solid var(--dd-focus); outline-offset: 2px; }
/* ONE BUTTON PER EFFECT (owner order r2026-08-27). A dense wrap of numbered 48-px targets - the row is built from the
   programme, so it grows with it. The number is the label; the effect's own name is the accessible name and the
   tooltip. data-playing marks the one that was just tapped, so a reviewer can see which number produced what. */
.settings__row--wrap { flex-wrap: wrap; align-items: flex-start; margin-top: var(--dd-s3); }
.stepset { display: flex; flex-wrap: wrap; gap: 4px; max-width: 100%; }
.stepset__btn { min-height: var(--dd-control-min); padding: 4px 8px;
  display: inline-flex; align-items: center; gap: 5px;
  border: 1px solid var(--dd-border); border-radius: var(--dd-r1); background: transparent; color: var(--dd-text-muted);
  font-family: var(--dd-font-mono); font-size: 11px; font-weight: 650; cursor: pointer; }
.stepset__n { min-width: 2ch; text-align: right; opacity: 0.65; }
.stepset__name { font-family: var(--dd-font-body); font-size: 11px; font-weight: 600; letter-spacing: 0.01em; }
.stepset__btn[data-playing] { background: var(--dd-glyph-accent); border-color: var(--dd-glyph-accent); color: var(--dd-on-accent); }
.stepset__btn:focus-visible { outline: 3px solid var(--dd-focus); outline-offset: 2px; }
/* A DREAM'S STAR (owner order r2026-08-27e). The scatter is 1-2 px white dots; a dream is a four-point sparkle with
   a soft halo, in the brand's blush, so "mine" reads at a glance without a legend. Halo is a gradient, not a filter -
   nothing here enters the blur budget. */
.ambient__st--dream .st__fill { fill: var(--dd-brand-blush); }
.ambient__st--dream { opacity: 0.82; }
.ambient__st--dream .st__core { fill: #ffffff; opacity: 0.9; }
/* ONE ANIMATION, A HUNDRED CLOCKS (owner order r2026-08-27j). Every dream star runs this; the duration and a negative
   delay are written per star, so they breathe independently and none of it touches the main thread. The band comes
   from --o1/--o2, which the recency pass sets, so age still reads. */
@keyframes dd-dreamtwinkle {
  0%, 100% { opacity: var(--o1, 0.45); transform: scale(1); }
  50%      { opacity: var(--o2, 0.95); transform: scale(1.22); }
}
html:not([data-theme="dark"]) .ambient__st--dream .st__fill { fill: var(--dd-mic-plum); }
.ambient__st--dream[data-fresh] .st__fill { fill: #ffffff; }
html:not([data-theme="dark"]) .ambient__st--dream[data-fresh] .st__fill { fill: var(--dd-mic-plum); }
html:not([data-theme="dark"]) .ambient__st--dream .st__core { fill: var(--dd-mic-plum); }
/* THE SOUND CONTROL (owner order r2026-08-27e): bottom-left of the board, on every screen, level with the corner
   moon's inset so the two corners answer each other. 48 px target, line glyph, brand ink in both appearances. */
.soundbtn { position: absolute; left: 26.5px; top: 726px; z-index: 5; width: 48px; height: 48px; padding: 0;
  display: grid; place-items: center; border: 0; background: transparent; color: var(--dd-star-1); cursor: pointer; }
html:not([data-theme="dark"]) .soundbtn { color: var(--dd-mic-plum); }
.soundbtn__glyph { width: 28px; height: 28px; }
/* THE CHIP (owner report r2026-08-27g: "there is no music button"). A line glyph over the cloud drawing had no ground
   of its own, so it read as part of the clouds. A translucent disc gives it an edge on both appearances without
   introducing a new colour: the panel's own surface tokens, at the size the target already is. */
/* THE CONTROL IS DRAWN IN THE PRODUCT'S OWN VOCABULARY (owner order r2026-08-27j: "that glyph is not this design").
   A speaker cone on a dark disc belonged to a settings screen, not to this sky: the app speaks in crescents, sparkles
   and soft dots, so the control is the SAME crescent as the corner moon, singing two thin arcs - and muted, the arcs
   become one quiet dot. No chip, no plate: a line in the appearance's own ink, like everything else on the board. */
.soundbtn--chip { top: 690px; background: transparent; border: 0; box-shadow: none; }
.soundbtn { color: var(--dd-crescent); }
html:not([data-theme="dark"]) .soundbtn { color: var(--dd-mic-plum); }
/* the star steppers: a count with two 48-px targets, in the panel's own idiom */
.starstep { display: inline-flex; align-items: center; gap: 4px; }
.starstep__btn { min-width: var(--dd-control-min); min-height: var(--dd-control-min); padding: 0;
  border: 1px solid var(--dd-border); border-radius: var(--dd-r1); background: transparent;
  color: var(--dd-text); font-size: 18px; font-weight: 650; line-height: 1; cursor: pointer; }
.starstep__btn:focus-visible { outline: 3px solid var(--dd-focus); outline-offset: 2px; }
.starstep__count { min-width: 2.5ch; text-align: center; font-family: var(--dd-font-mono); font-size: 13px;
  color: var(--dd-text-muted); }
/* THE SILHOUETTE SURVIVES THE MUTE (verifier r2026-09-04). Off used to hide the five bars entirely and show only the
   strike, so a first-time user - who always opens on OFF - saw a 13 px dash and had no way to know the product has
   music at all. Standard mute treatment instead: the SAME waveform, dimmed, with a diagonal strike over it, so both
   states read as the same control and only the strike tells them apart. */
.soundbtn__wave, .soundbtn__slash { transition: opacity 160ms ease; }
html[data-sound="off"] .soundbtn__wave { opacity: 0.38; }
html[data-sound="off"] .soundbtn__slash { opacity: 1; }
.soundbtn__slash { opacity: 0; }
.soundbtn:focus-visible { outline: 3px solid var(--dd-focus); outline-offset: 2px; border-radius: 12px; }
.wish-hit { position: absolute; left: 50%; top: 50%; width: 76px; height: 76px; margin: -38px 0 0 -38px;
  border-radius: 50%; background: transparent; pointer-events: auto; cursor: pointer; touch-action: manipulation; }
.wishspark { position: absolute; border-radius: 50%; background: #ffffff; opacity: 0;
  box-shadow: 0 0 6px 1px color-mix(in srgb, var(--dd-star-1) 70%, transparent); pointer-events: none; z-index: 2; }
/* the flying bodies that can be tapped take the pointer themselves; the field stays transparent to it, and the
   microphone is on a higher layer, so a wish can never steal a product tap */
.ambient__meteor, .comet { touch-action: manipulation; }
.ritual { position: absolute; width: 0; height: 0; pointer-events: none; z-index: 2; }
.ritual__core { position: absolute; left: 50%; top: 50%; width: 7px; height: 7px; margin: -3.5px 0 0 -3.5px;
  border-radius: 50%; background: #ffffff;
  box-shadow: 0 0 10px 2px color-mix(in srgb, var(--dd-brand-blush) 70%, transparent); }
.ritual__halo { position: absolute; left: 50%; top: 50%; width: 26px; height: 26px; margin: -13px 0 0 -13px;
  border-radius: 50%; opacity: 0.55;
  background: radial-gradient(circle, color-mix(in srgb, var(--dd-brand-blush) 60%, transparent) 0%, transparent 70%); }

/* THE SHELL BAR (DF-149). The settings control is the only way to the ambient switch, the demonstration and the
   legal hub, and it existed on ONE screen. It is now two launchers over one sheet: the artboard keeps the approved
   sliders glyph at its reference coordinates, and this shell-level copy carries the same glyph, at the same corner
   position, on every screen the artboard does not cover. The bar covers the viewport so the sheet can be anchored
   to it, and passes every pointer through except on its own controls. */
/* THE SHELL BAR follows the SCREEN, not the window (DF-166). Fixed to the viewport, its glyph sat up to 118 px
   outside the flow column on a wide viewport — a header with an empty third track and a control floating beside the
   card. It is now the same centred column the screens are, so the glyph lands in that third track at any width. */
/* THE SHELL SHEET. The settings panel is a shell element so it is reachable from every screen; its LAUNCHERS live in
   the screens themselves (the artboard's own #btn-settings, and .topbar .headbtn on each flow screen), because a
   control that completes a header must scroll with it. The bar covers the viewport so the sheet can be anchored to
   it, and passes every pointer through except on the sheet. */
.shellbar { position: fixed; inset: 0; z-index: 30; pointer-events: none; }
.shellbar > * { pointer-events: auto; }
/* the artboard draws its own control at its own reference coordinate, so no shell copy exists there; the flow
   screens carry theirs inside their header (.topbar .headbtn), which is why nothing here positions a button. */
/* the same night-sky vocabulary on the flow screens: the lockup at the top and the horizon at the foot, the
   artboard's own two cloud paths in the artboard's own tokens — the same drawing, without the masks and the grain
   plate that only the 390x844 surface can afford ([TS-42] counts filters). */
#mic-denied { position: relative; }
/* the base .clouds rule pins top:0 390x844 board; on a flow screen the band must sit at the FOOT,
   so top is released or the bottom anchor is ignored (with top+height+bottom all set, CSS drops bottom). And it is
   DECORATION: an absolutely positioned last child paints ABOVE its in-flow siblings whatever its z-index, unless
   that index is negative — which is why the band covered both buttons in the owner's screenshot. Shorter and
   fainter too: a horizon, not a wash over the controls. */
/* every flow screen closes with the line screen 1 closes with — but the global .footer rule is written for the
   390x844 artboard, which --dd-stage SCALES: `top: calc(811px - ...)` is a surface coordinate there and a WINDOW
   coordinate here, so on an unscaled screen it landed at y=812 in a 540-tall viewport, off screen and unreachable
   (DF-159). It is anchored to its own section instead, and the sections are positioned so `bottom` resolves against
   the screen and not against some ancestor. Out of flow, so it costs no grid row and the --dd-fit ramp is unchanged. */
/* THE CLOSING BAND IS A ROW, NOT A COORDINATE (DF-161, third pass — read the two occurrences above before touching
   this). Reserving space inside a FIXED-HEIGHT box whose content already overflows does nothing, and an absolutely
   positioned footer then anchors to that box: measured on the transcript screen, the closing line landed inside the
   primary button and both buttons painted 13-61 px BELOW the section. So neither piece is positioned any more.
   The footer is an ordinary last child with a band-height row of its own — it cannot overlap what precedes it,
   because flow does not overlap. The horizon shares that row — in flow, pulled back out of it by a negative margin,
   so it costs no height and paints behind the line. It was STICKY for one round and that was the same bug again in a
   new mechanism: a sticky band inside a scrolling section pins to the BOX, so on the two screens whose content
   overflows it left the footer row and crossed the primary button (measured 27 px). In flow it cannot.
   The sections do NOT clip: a fixed-height box with overflow hidden turns a visible spill into an invisible one, and
   on the two screens whose entire purpose is one action, that action was cut at the fold. The row grows instead
   (main's own scroll region takes it), which is what a screen taller than the viewport is for. */
/* r2026-08-26: the per-screen horizon strip and per-screen closing line are gone from the markup - one shell-wide
   declared with the padding above, so nothing in flow can enter the band the horizon occupies. */
.state--capture, .state--error { position: relative; }
/* THE CLOSING BAND (owner screenshot r2026-08-24z: the footer line sat ON the last button and the horizon reached up
   across both of them). One reserved band at the foot of every flow screen: nothing in flow may enter it, the closing
   line lives in it, and the horizon strip is that band and no taller — so decoration and copy can never meet a
   control again. Declared once, because five screens with five paddings is how the first collision happened. */
.state--capture > .brand { justify-content: flex-start; }

/* --- the handoff flyers: two elements cloned from the loader, animated on transform/opacity only (AC-MOT-08).
       The crescent flies to the top-left corner, the halo into the mic own box. No filter is animated: the halo
       carries a STATIC blur and only its transform and opacity change. --- */
.flyer { position: absolute; pointer-events: none; z-index: 40; will-change: transform, opacity; }
.flyer--halo { border-radius: 50%; background: var(--dd-brand-halo); filter: blur(13px); }
/* The moon disc that leaves the splash and lands on the microphone's circle. Its FILL is read off the asset at
   flight time (src/app.js), so the moon that flies is the moon that was on the splash — dark on the night canvas,
   white on the day one — and it keeps that colour the whole way: the change belongs to the OVERLAP, where the
   mic's own face rises under it. NO RIM (owner ruling r2026-08-24w6): a hairline outline read as the microphone's
   own border travelling around the sky. What makes a dark sphere visible against a dark sky is the light it already
   had — the splash's own halo, which flies beside it as .flyer--halo — so the disc carries only a soft diffusion of
   its own, no edge, and no filter (the halo's blur is static). */
/* NO EDGE AT ALL (owner order r2026-08-24z, and this is the second attempt at it). The moon that lands must not grow
   a rim as it arrives: the first pass removed the inset ring but left a 24 px soft shadow, which is exactly the edge
   the owner kept seeing appear and vanish over the 2100 ms flight. The disc now carries NOTHING of its own — the glow
   beside it is .flyer--halo, which is the splash's own halo flying with it, and that blur is static. */
.flyer--disc { border-radius: 50%; }
/* THE MOON'S OWN DIFFUSION. On the day canvas the splash halo (#d8c8f2 on #faf7ff, 1.15:1) is invisible, so the
   white moon flew with no glow at all and the dark moon had nothing to read against on either canvas but this.
   Same ink family, enough presence to be the moon's light rather than a rectangle of nothing. */
html:not([data-theme="dark"]) .flyer--halo { background: color-mix(in srgb, var(--dd-brand-plum) 58%, transparent); }
@media (prefers-reduced-motion: reduce) { .flyer { display: none !important; } }

/* ==================================================================================================================
   FLUID SHELL — THE BOARD IS THE VIEWPORT (owner order r2026-09-12: remove the phone-emulation width limit, full
   adaptivity on every screen; "on a real device the emulation only glitches").

   WHAT CHANGED, AND WHY IT IS THIS AND NOT A REWRITE.
   The composition was a 390x844 board centred in the window and fitted with transform: scale(). Three faults the
   owner reported and two the guidelines add:
     a scaled board is a CARD with visible edges in the middle of a wide window — the thing the screenshot marks;
     transform: scale cancels the user's own browser zoom, which is WCAG SC 1.4.4 failure F69, and it makes every
     measured rect a lie for the animation code (app.js had to divide by the scale to survive it);
     on a phone the scale is ~1 anyway, so the emulation bought nothing there and cost a frame of layout on resize.
   The fix keeps every approved coordinate and drops the frame: the board now FILLS the window (no transform, no
   radius, no shadow), and the artboard's own coordinates are re-expressed against it —
     HORIZONTAL: as an offset from the centre line (x - 195), which IS the reference number at 390 wide and keeps the
       composition centred at any width. Off-centre furniture (the corner controls, the sound control) is floored by
       a gutter plus the safe-area inset, so nothing can leave the screen on a 320 px viewport or under a notch.
     VERTICAL: as a percentage of the 844 design height, which is the reference number at 844 tall and keeps the
       rhythm proportional on anything taller or shorter. The artboard carries a 620 px floor: below that the screen
       SCROLLS instead of compressing, so a landscape phone or a 400% zoom never overlaps two elements.
   Sizes are NOT scaled: a 140 px microphone, 26 px title and 48 px targets stay exactly what the design and the
   touch-target floor say they are at every viewport. Only position is fluid. Type therefore answers a text-only
   zoom the way it did (WCAG 1.4.4) and no target ever shrinks (2.5.5).
   Nothing in this block introduces a colour, a size or a breakpoint of its own. ================================== */

/* the board: a grid item that fills the shell row, not a scaled card in the middle of it */
.device { position: relative; left: auto; top: auto; width: 100%; max-width: none; height: 100%;
  transform: none; border-radius: 0; box-shadow: none; }
@media (min-width: 700px) { .device { border-radius: 0; box-shadow: none; } }
/* the horizon stretches with the window (the svg is preserveAspectRatio="none", so the bands follow the foot) */
.clouds { width: 100%; height: 100%; }
/* NO DECORATION OVER THE ONE CONTROL (verifier r2026-09-12). The closing band is z 5 so a long FLOW screen scrolls
   behind the horizon — a deliberate effect, and on the 844-tall design it costs nothing, because there the disc ends
   at 71.8% of the height and the bands start at 73.5%. Those two facts stop agreeing the moment the window is
   shorter than the artboard's 620px floor: the composition keeps its floored box while the horizon follows the
   window, so the band climbs into the microphone and, being higher, paints across it. On the ARTBOARD the horizon
   therefore drops below the control (sky 0 < screen content < pads 3 = foot 3 < mic 4 < corner controls 6); the flow
   screens, which have no disc under the band and do want the scroll-behind, keep z 5. */
html:has(#content:not([hidden])) .device__foot { z-index: 3; }
/* the window-derived ramp means something again: the scroll region IS the window now, so --dd-short stops being
   pinned to 0 and the flow screens compress on a short viewport instead of overrunning it. `inherit` picks up the
   :root value INCLUDING its narrow-width variant (tokens.css, max-width: 374px). */
#main.screen { --dd-short: inherit; --dd-fit-pad-side: min(var(--dd-s5), 6.2vw); }
/* ONE COORDINATE SYSTEM ON THE ARTBOARD (verifier r2026-09-13). The scroll region's client box excludes a classic
   scrollbar while .device — the parent of the horizon, the closing line and the sound control — includes it, so on
   Windows/Linux every `50% ± offset` inside the scroller resolved against 909px and every one outside it against
   924px: the composition and the pinned layer were centred 7.5px apart, and at 320px their left floors sat 15px
   apart. The artboard is a drawing with exact coordinates, so on THAT screen the scrollbar stops taking layout
   width and the two boxes become identical; it still scrolls by wheel, key, trackpad and touch, and it only ever
   scrolls at all on a window shorter than the 620px floor.
   Flow screens keep their scrollbar: they are long text, where a visible one is information the reader wants, and
   nothing on them is positioned from the pinned layer. `scrollbar-gutter: stable` was the other candidate and is
   worse here: it reserves the gutter at EVERY width, which moves the centre line on every desktop window instead
   of only the scrolling ones, and doubling it on both boxes shifts the floors twice (measured). */
html:has(#content:not([hidden])) #main.screen { scrollbar-width: none; }
html:has(#content:not([hidden])) #main.screen::-webkit-scrollbar { width: 0; height: 0; }
/* flow screens get the window's own side inset back (the artboard needs none: its children are positioned against
   the centre line, and a padding on the scroll region would move all of them) */
html:has(#content[hidden]) #main.screen {
  padding-left: max(var(--dd-fit-pad-side), env(safe-area-inset-left));
  padding-right: max(var(--dd-fit-pad-side), env(safe-area-inset-right)); }
/* and they get the reading measure instead of the 342 px phone column: on a wide window a legal document, a
   transcript or an error is a column of 45-75 characters, centred, not a phone-width strip */
.state--capture, .state--error, .state--legal { width: min(100%, var(--dd-measure)); }
/* the boot skeleton is NOT a flow screen: it predicts Home, so it keeps the reference phone column at every window
   size. Without this the short-viewport rule (max-height: 800px) let it grow to the measure while Home stayed a
   390 band, and the swap the no-shift contract protects (F-01) would have moved every role sideways. */
.state--skeleton { width: min(calc(100% - 24px), 342px); }

/* the artboard: its own positioning context, with a floor under it instead of a clip */
.state--content { position: relative; inset: auto; width: 100%; height: auto;
  min-height: max(100%, 620px); overflow: visible; }
/* the lockup's box must be the artboard's full height, or a percentage inside it resolves against zero */
#content .brand { height: 100%; pointer-events: none; }
#content .brand__mark { left: calc(50% - 55px); top: 7.109%; }
#content .brand__word { left: calc(50% - 31px); top: calc(9.1232% - 11px * (1 + var(--dd-asc) - var(--dd-desc)) / 2); }
/* the two corner controls answer the corners of the SCREEN, floored by a gutter and the safe area */
#btn-theme { left: max(calc(env(safe-area-inset-left) + 8px), calc(50% - 168.5px));
  top: max(calc(env(safe-area-inset-top) + 8px), 5.865%); }
#btn-settings { left: auto; right: max(calc(env(safe-area-inset-right) + 8px), calc(50% - 171px));
  top: max(calc(env(safe-area-inset-top) + 8px), 5.45%); }
/* THE TITLE MAY WRAP NOW, AND IT GROWS INTO THE SKY (measured r2026-09-12). `text-wrap: nowrap` was safe inside a
   fixed 390 box and is not safe in a fluid one: a longer locale at 320 px ran off both edges. Wrapping alone is not
   enough either — the title and the hint are pinned at two artboard y-coordinates 36 px apart, so a second line ran
   straight over the hint (measured 41 px of overlap with a three-line string at 320). The box is therefore anchored
   by its BOTTOM on the reference line: one line lands exactly where the reference draws it, and every extra line
   grows UPWARD into the empty band between the title and the lockup — about five lines of room at any viewport,
   which is what a 200% text zoom or an unusually long locale actually needs. Nothing below the title moves. */
#content .title { left: max(12px, calc(50% - 195px)); right: max(12px, calc(50% - 195px));
  top: auto; bottom: calc(66.3507% + 26px * (1 + var(--dd-asc) - var(--dd-desc)) / 2 - 26px);
  text-wrap: balance; }
#content .lede { left: max(12px, calc(50% - 195px)); right: max(12px, calc(50% - 195px));
  top: calc(37.9147% - 15px * (1 + var(--dd-asc) - var(--dd-desc)) / 2); }
.wave--live { left: max(12px, calc(50% - 142px)); right: max(12px, calc(50% - 142px)); width: auto; top: 33.4123%; }
.record-card { left: max(16px, calc(50% - 171px)); right: max(16px, calc(50% - 171px)); top: 49.5261%; }
#record-timer { left: max(12px, calc(50% - 195px)); right: max(12px, calc(50% - 195px)); top: 52.1327%; }
.mic { left: calc(50% - 70px); top: 55.2133%; }
/* the toast is an inset pair rather than a width, so a narrow phone shortens it instead of pushing it off-centre */
.toast { left: max(12px, calc(50% - 141px)); right: max(12px, calc(50% - 141px)); width: auto;
  height: auto; min-height: 76px; }
.toast strong, .toast span { right: 16px; }
.saved-check { left: calc(50% - 57px); top: 43.3649%; }
.saved-center strong { left: max(12px, calc(50% - 195px)); right: max(12px, calc(50% - 195px));
  top: calc(61.1374% - 26px * (1 + var(--dd-asc) - var(--dd-desc)) / 2); }
.saved-center span { left: max(16px, calc(50% - 100px)); right: max(16px, calc(50% - 100px));
  top: calc(65.1659% - (23px - 15px * (var(--dd-asc) + var(--dd-desc))) / 2 - 15px * var(--dd-asc)); }
.next__title { left: max(12px, calc(50% - 195px)); right: max(12px, calc(50% - 195px));
  top: calc(26.0664% - 26px * (1 + var(--dd-asc) - var(--dd-desc)) / 2); }
.choice { left: max(16px, calc(50% - 155px)); right: max(16px, calc(50% - 155px)); width: auto; }
.choice--sleep { top: 31.7536%; }
.choice--review { top: 50.237%; }
/* the closing line and the sound control are anchored to the FOOT of the window: on a short viewport the artboard
   above them scrolls, and neither the horizon nor a control the user needs can end up below the fold */
.footer { left: max(12px, calc(50% - 195px)); right: max(12px, calc(50% - 195px));
  top: auto; bottom: max(env(safe-area-inset-bottom), 3.91%); }
.soundbtn { left: max(calc(env(safe-area-inset-left) + 8px), calc(50% - 168.5px));
  top: auto; bottom: max(calc(env(safe-area-inset-bottom) + 8px), 8.2938%);
  /* z 6, with the other corner controls. At 5 it tied with the closing band and lost on DOM order, so the horizon
     painted across the sound control on every flow screen (harness r2026-09-12). A control outranks decoration. */
  z-index: 6; }
.soundbtn--chip { top: auto; bottom: max(calc(env(safe-area-inset-bottom) + 8px), 12.5592%); }
/* the sheet: anchored to the composition, never wider than the window, and scrollable when the window is short */
/* the cap is derived from the sheet's OWN top inset, not from a guessed 96px: at 390x844 the fixed number left the
   sheet 4px past the foot of the window (measured in the viewport harness, r2026-09-12). Both terms resolve against
   the same box — .shellbar is fixed at inset 0, so its 100% IS the window. */
.settings { --dd-sheet-top: max(calc(env(safe-area-inset-top) + 8px), 11.8483%);
  top: var(--dd-sheet-top);
  right: max(12px, calc(50% - 171px)); width: min(296px, calc(100% - 24px));
  max-height: calc(100% - var(--dd-sheet-top) - 24px); overflow: auto; overscroll-behavior: contain; }
html[data-sheet="side"] .settings { left: calc(50% + 195px + var(--dd-s5)); right: auto;
  top: max(calc(env(safe-area-inset-top) + 8px), 8svh); max-height: 84svh; }
/* cross-device input hygiene: no 300 ms double-tap wait on a control, no grey flash on iOS, and a text size the
   engine may not inflate on rotation (which is how a mobile Safari landscape turn used to break a fitted column) */
.btn, .mic, .stagebtn, .soundbtn, .switch, .choice, .doclist__btn { touch-action: manipulation; }
/* the four tempo buttons were the one control under the target floor (38x48 measured at every profile). 44px is the
   floor WCAG 2.5.5 and both platform guidelines name; the sheet's own 48 would wrap the row at 296px of sheet. */
.speedset__btn { min-width: 44px; }
/* A ROW WRAPS INSTEAD OF RUNNING OFF THE SHEET (measured r2026-09-12: with the tempo buttons at their new 44px the
   label plus four targets came to 308px inside a 247px row, and the last one landed 17px past the window). Wrapping
   is the fluid answer for every row in the panel, not just this one: a label above its controls is readable, a
   control the user cannot reach is not. */
.settings__row { flex-wrap: wrap; row-gap: var(--dd-s2); }
.speedset { flex-wrap: wrap; }
html { -webkit-text-size-adjust: 100%; text-size-adjust: 100%; }
