/* ==========================================================================
   Universal Conversion
   Committed system: DESIGN.md section 6.
   One shell morphs between every screen. Nothing opens in a separate box.
   ========================================================================== */

@property --refract-angle {
  syntax: '<angle>';
  inherits: false;
  initial-value: 0deg;
}

:root {
  /* --- type ------------------------------------------------------------- */
  --font-display: "Outfit", "Trebuchet MS", sans-serif;
  --font-ui: "Hanken Grotesk", "Avenir Next", "Segoe UI", sans-serif;
  --font-mono: "JetBrains Mono", "SF Mono", ui-monospace, monospace;

  --t-xs: 0.75rem;
  --t-sm: 0.875rem;
  --t-base: 1rem;
  --t-md: 1.125rem;
  --t-lg: 1.5rem;
  --t-xl: 2rem;
  --t-2xl: 2.6rem;
  --t-display: clamp(2.45rem, 9.2vw, 7.6rem);

  --lh-display: 0.92;
  --lh-tight: 1.15;
  --lh-prose: 1.55;
  --track-display: -0.035em;
  --track-eyebrow: 0.34em;

  /* --- space (4px base) -------------------------------------------------- */
  --s1: 4px;  --s2: 8px;  --s3: 12px; --s4: 16px; --s5: 24px;
  --s6: 32px; --s7: 48px; --s8: 64px; --s9: 96px;

  /* --- shape ------------------------------------------------------------- */
  --radius-control: 999px;
  --hairline: 1px;

  /* --- colour: page chrome, light ---------------------------------------- */
  --ground: #F4F2EC;
  --ground-alt: #EAE7DE;
  --surface: #FFFDF8;
  --ink: #14161A;
  --ink-2: #474C52;
  --ink-3: #6C7278;
  --rule: #D8D4C8;

  /* --- colour: the film shell -------------------------------------------
     The look itself is the room's -- palette, material, motion and reaction
     are all in `themes.css`, which is the one place a room may change
     anything. What is left here is what every room shares and no room may
     touch: the type, the spacing, the component shapes, and the values
     derived from the room's own shade. */
  --film-shell-solid: #12161A;

  --scrim-core: rgb(var(--shade-rgb) / 0.52);
  --scrim-mid: rgb(var(--shade-rgb) / 0.30);
  --scrim-edge: rgb(var(--shade-rgb) / 0.12);
  --scrim-top: rgb(var(--shade-rgb) / 0.44);
  --scrim-belt: rgb(var(--shade-rgb) / 0.14);
  --scrim-foot: rgb(var(--shade-rgb) / 0.50);
  --veil-drag: rgb(var(--shade-rgb) / 0.72);
  --halo-title: rgb(var(--shade-rgb) / 0.45);
  --halo-eyebrow: rgb(var(--shade-rgb) / 0.55);

  --knob-face: rgba(255, 255, 255, 0.86);
  --knob-face-hi: #FFFFFF;
  --knob-ink: #14161A;

  /* Hover elevation. Shadows appear only while a control is under the pointer,
     never at rest, so surfaces stay flat until you touch them. */
  --lift-1: 0 8px 22px -10px rgb(var(--shade-rgb) / 0.62), 0 2px 6px -3px rgb(var(--shade-rgb) / 0.42);
  --lift-2: 0 14px 30px -8px rgb(var(--shade-rgb) / 0.70), 0 4px 10px -3px rgb(var(--shade-rgb) / 0.48);
  --sheen-dark: rgba(0, 0, 0, 0.12);
  --sheen-dark-mid: rgba(0, 0, 0, 0.04);

  /* --- states, which mean the same thing in every room -------------------- */
  --idle: #8A9390;
  --film-ok: #6FD3A0;
  --film-bad: #F09A8E;
  --film-working: #EBBA6A;
}

:root[data-theme="dark"] {
  --ground: #0E1013; --ground-alt: #15181C; --surface: #1A1E23;
  --ink: #EEF1F0; --ink-2: #B3B9BD; --ink-3: #868D93; --rule: #2A2F35;
}
@media (prefers-color-scheme: dark) {
  :root:not([data-theme="light"]) {
    --ground: #0E1013; --ground-alt: #15181C; --surface: #1A1E23;
    --ink: #EEF1F0; --ink-2: #B3B9BD; --ink-3: #868D93; --rule: #2A2F35;
  }
}

/* ==========================================================================
   Base
   ========================================================================== */

*, *::before, *::after { box-sizing: border-box; }
[hidden] { display: none !important; }

html { -webkit-text-size-adjust: 100%; }

/* No scrollbar anywhere. The page is one panel on a field of video, and it
   scrolls only when that panel opens something -- which it does by animating the
   page itself. A bar sliding in alongside that announces "this is a document
   being scrolled" rather than "this is a thing opening", which is the opposite
   of the intention. Scrolling by wheel, trackpad, keyboard and touch all still
   work; only the indicator is gone. */
html {
  scrollbar-width: none;          /* Firefox */
  -ms-overflow-style: none;       /* old Edge */
}

html::-webkit-scrollbar,
body::-webkit-scrollbar {
  width: 0;
  height: 0;
  display: none;                  /* WebKit and Blink */
}

body {
  margin: 0;
  min-height: 100svh;
  display: flex;
  flex-direction: column;
  font-family: var(--font-ui);
  font-size: var(--t-base);
  line-height: var(--lh-prose);
  color: var(--film-ink);
  background: var(--film-shell-solid);
  -webkit-font-smoothing: antialiased;
}

h1, h2, p, dl, dd, form { margin: 0; }
img, video { max-width: 100%; display: block; }

.mono { font-family: var(--font-mono); font-variant-numeric: tabular-nums; }

.visually-hidden {
  position: absolute; width: 1px; height: 1px;
  margin: -1px; padding: 0; overflow: hidden;
  clip-path: inset(50%); white-space: nowrap;
}

:focus-visible { outline: 2px solid var(--signal); outline-offset: 3px; }
/* The rounded ring is for controls. Given to everything that can take focus,
   it reshaped the thing itself: the docs editor's page area, focused when a
   document opens (and so in Safari on every open), became an ellipse that
   cut off the pages; a clip on the video timeline, the crop rectangle, a PDF
   page and the notes editor turned into pills under the keyboard. `:where`
   keeps the weight of the rule it replaces, so a control's own radius still
   wins as it did. Not a textarea: it scrolls, so a radius clips its text,
   and a 999px one hid the first letters of every line under an invisible
   oval (the feedback box, the contract page's reasons). Its frame draws the
   shape. */
:where(a, button, summary, input, select):focus-visible { border-radius: var(--radius-control); }

.skip {
  position: absolute; left: var(--s4); top: -100px; z-index: 10;
  padding: var(--s3) var(--s4);
  background: var(--film-shell-solid); color: var(--film-ink);
  border-radius: var(--radius-control); text-decoration: none;
  transition: top var(--dur-state) var(--ease);
}
.skip:focus { top: var(--s4); }

/* ==========================================================================
   Background film
   ========================================================================== */

.backdrop {
  position: fixed; inset: 0; z-index: -1; overflow: hidden;
  /* The room's still, put here by wearFilm while a new film has no frame yet. */
  background: center / cover no-repeat;
}
.backdrop__video {
  width: 100%;
  height: 100%;
  object-fit: cover;
  /* Its own compositing layer: the blurred panel above re-samples this
     constantly, and without the promotion Safari folds video decoding into the
     same repaint as the rest of the page. */
  transform: translateZ(0);
  backface-visibility: hidden;
}
/* A film just swapped in has nothing to show until it decodes a frame (see
   wearFilm): the still behind it shows instead of black. */
.backdrop__video[data-waiting] { opacity: 0; }
.backdrop__scrim {
  position: absolute; inset: 0;
  background:
    radial-gradient(115% 80% at 50% 34%, var(--scrim-core) 0%, var(--scrim-mid) 48%, var(--scrim-edge) 100%),
    linear-gradient(180deg, var(--scrim-top) 0%, var(--scrim-belt) 40%, var(--scrim-foot) 100%);
  transition: background-color var(--dur-face) var(--ease);
}
body[data-drag="over"] .backdrop__scrim { background-color: var(--veil-drag); }

/* ==========================================================================
   Top bar
   ========================================================================== */

.topbar {
  display: flex; align-items: center; justify-content: space-between;
  gap: var(--s4);
  padding: var(--s5) clamp(var(--s4), 4vw, var(--s7));
  /* Its own layer above the room, so the dial's column of rooms hangs in front
     of the islands instead of behind them (the shell's frosted glass makes a
     layer of its own, drawn later). Below the full-screen views (photo's big
     view, the video editor's full screen, 60 and up), the sheets and the
     journey, which are meant to cover it. */
  position: relative; z-index: 50;
}
.topbar__mark {
  font-family: var(--font-mono); font-size: var(--t-sm); letter-spacing: 0.22em;
  color: var(--film-ink-2); background: none; border: 0; padding: var(--s2) 0;
  cursor: pointer;
  transition: color var(--dur-state) var(--ease);
}
.topbar__mark:hover { color: var(--film-ink); }
.topbar__nav { display: flex; align-items: center; gap: var(--s4); }

.quota { display: inline-flex; align-items: center; gap: var(--s2); font-size: var(--t-sm); color: var(--film-ink-2); }
.quota__dot { width: 7px; height: 7px; border-radius: var(--radius-control); background: var(--idle); transition: background-color var(--dur-state) var(--ease); }
/* The dot is the room, except when it is a warning.

   It was one green in every room -- `--film-ok` -- so the top bar said the
   same thing in the same colour wherever you stood, while everything beside
   it had become the room you were in. It takes `--accent` now: the colour
   that room is from outside, which is the colour its own button in the dial
   wears two inches to the right of it.

   `spent` keeps the amber, and that is the point of the exception. Green,
   black and leaf-green are all *a room*; amber is *a state*, and the one
   thing this dot has to be able to do is stop looking like the room and start
   looking like a warning. */
.quota[data-state="ok"] .quota__dot,
.quota[data-state="paid"] .quota__dot { background-color: var(--accent, var(--signal)); }
.quota[data-state="spent"] .quota__dot { background-color: var(--film-working); }

/* The canyon's dot has the light moving in it.

   That room is light being split by stone, and its dot was the one warm
   colour the split ends up at -- true, and the least interesting half of it.
   So the two ends move through it instead: the gold and the blue the film
   actually has in it, sampled from `photo-poster.jpg` rather than picked. Its
   cool runs #316E7F to #84CDD7 and its warm #B99269 to #F7E1B9; these are
   those, saturated enough to survive being seven pixels across.

   Only where the dot is *not* a warning. A glistening amber is a decoration
   with a job it cannot do. */
[data-room="photo"] .quota[data-state="ok"] .quota__dot,
[data-room="photo"] .quota[data-state="paid"] .quota__dot {
  /* Two colours with a crossing between them, not a wash.

     The first version spread five stops evenly, so at seven pixels across the
     dot was almost always showing a blend of gold and blue -- a muddy
     in-between for most of the cycle, which is not what light through crystal
     does. It is gold, and then it is blue.

     So each colour holds a flat plateau wide enough to fill the dot on its
     own, and the crossing between them is narrow. `background-size: 340%`
     makes the visible window about a third of the gradient, which is what
     decides how wide "wide enough" is. */
  background-image: linear-gradient(115deg,
    #D8A23C 0%, #D8A23C 36%,
    #8FD6E2 50%,
    #6FB6CE 64%, #6FB6CE 100%);
  background-size: 340% 340%;
  animation: canyon-glisten 8s var(--ease) infinite;
}

/* A glint travelling across whichever colour it is currently being.

   The plateaus stop the dot being a muddy average, and on their own they also
   stop it doing anything for two thirds of the time. This is the other half:
   a narrow highlight crossing the dot on its own faster clock, so the colour
   stays whole and the light on it does not. It is what the room is -- the
   stone does not change colour, the light on it moves. */
.quota__dot { position: relative; overflow: hidden; }
[data-room="photo"] .quota[data-state="ok"] .quota__dot::after,
[data-room="photo"] .quota[data-state="paid"] .quota__dot::after {
  content: "";
  position: absolute;
  inset: 0;
  border-radius: inherit;
  background: linear-gradient(115deg,
    transparent 38%, rgba(255, 255, 255, 0.75) 50%, transparent 62%);
  background-size: 300% 300%;
  animation: canyon-glint 2.9s var(--ease-soft) infinite;
}

@keyframes canyon-glint {
  0%   { background-position: 0% 50%; }
  100% { background-position: 100% 50%; }
}

/* And it rests at each end rather than sweeping continuously. Three seconds
   of gold, a second of crossing, three of blue, a second back -- so the dot
   is one whole colour for three quarters of its cycle and the movement is
   the thing that happens between. */
@keyframes canyon-glisten {
  0%,  34% { background-position: 0% 50%; }
  50%, 84% { background-position: 100% 50%; }
  100%     { background-position: 0% 50%; }
}

@media (prefers-reduced-motion: reduce) {
  /* The colour it settles on is the room's own, so switching the movement off
     leaves the dot saying exactly what it said before. */
  [data-room="photo"] .quota .quota__dot { background-image: none; animation: none; }
  [data-room="photo"] .quota .quota__dot::after { animation: none; opacity: 0; }
}
.quota__text { white-space: nowrap; }

@media (max-width: 620px) {
  /* Three things on the first line -- the mark, the account, the dial -- and
     the allowance across the second. The dial needs a place of its own here:
     `display: contents` dissolves the nav, so anything the grid is not told
     about drops to a row of its own under everything else. */
  .topbar {
    display: grid; grid-template-columns: auto 1fr auto;
    align-items: center; row-gap: var(--s3); column-gap: var(--s3);
  }
  .topbar__nav { display: contents; }
  .topbar__mark { grid-column: 1; grid-row: 1; }
  #account-btn { grid-column: 2; grid-row: 1; justify-self: end; }
  .dial { grid-column: 3; grid-row: 1; }
  .quota { grid-column: 1 / -1; grid-row: 2; }
  .quota__text { white-space: normal; }
}

/* ==========================================================================
   Stage: the head collapses, the shell morphs
   ========================================================================== */

main {
  flex: 1;
  display: flex;
  align-items: center;
  justify-content: center;
  padding: var(--s5) clamp(var(--s4), 4vw, var(--s7)) var(--s7);
  width: 100%;
}

.stage { width: 100%; display: flex; flex-direction: column; align-items: center; }

/* Applied for one frame while the finished layout is measured. */
.stage.is-measuring,
.stage.is-measuring * { transition: none !important; animation: none !important; }

/* 1fr to 0fr is the one height animation that stays smooth without measuring. */
.stage__head {
  display: grid;
  grid-template-rows: 1fr;
  width: 100%;
  opacity: 1;
  transition: grid-template-rows var(--dur-head) var(--ease-soft),
              opacity var(--dur-face) var(--ease),
              transform var(--dur-head) var(--ease-soft);
}
.stage__head-inner { overflow: hidden; text-align: center; }

.stage[data-rest="no"] .stage__head {
  grid-template-rows: 0fr;
  opacity: 0;
  transform: translateY(-14px);
}

.eyebrow {
  font-size: clamp(0.65rem, 1.15vw, var(--t-sm));
  font-weight: 600; text-transform: uppercase;
  letter-spacing: var(--track-eyebrow); text-indent: var(--track-eyebrow);
  color: var(--film-ink-2);
  text-shadow: 0 1px 12px var(--halo-eyebrow);
  margin-bottom: var(--s4);
}
/* The face, the weight, the case and the tracking are the room's -- themes.css,
   under identity. The size, the scale it comes from and the line height are
   not: a room speaks in its own voice, at the building's volume. */
.title {
  font-family: var(--font-title); font-weight: var(--title-weight);
  font-size: var(--t-display); line-height: var(--lh-display);
  letter-spacing: var(--title-track); text-transform: var(--title-case);
  color: var(--film-ink); text-wrap: balance;
  max-width: 13ch; margin: 0 auto clamp(var(--s6), 5vw, var(--s8));
  text-shadow: 0 2px 28px var(--halo-title);
}

/* ==========================================================================
   The shell -- one element, every screen
   ========================================================================== */

/* The islands on screen, as a row.

   Almost always a row of one: the shell on its own, centred, which is what it
   was before this existed and is why adding this changed nothing anywhere
   else. A row of one with `justify-content: center` puts the single item
   exactly where `align-items: center` on the stage put it.

   It is here so that a room can set a second island down beside the first.
   The photo room does, when there is more than one photograph open. Two
   reasons it is a flex row and not a panel positioned against the shell's
   edge: the pair stays centred as a pair, so the picture does not sit off to
   one side of the window; and `align-items: stretch` gives the second island
   exactly the first one's height, including while the shell is animating
   between two heights, without anyone measuring anything. */
.deck {
  width: 100%;
  display: flex;
  justify-content: center;
  align-items: stretch;
  gap: var(--s3);
}

/* The shell is a flex item now. Without this its `width` is a suggestion that
   flex may shrink below -- and `min-width: auto` would let a wide neighbour
   push it past the point where its own contents fit. */
.deck > .shell { flex: 0 1 auto; min-width: 0; }

.shell {
  position: relative;
  width: min(100%, 620px);
  border-radius: var(--radius-shell-idle);
  background: var(--film-raise);
  /* The island's own edge, which is the room's -- `themes.css`, under identity.
     Most rooms draw a hairline here and it is the same hairline that divides a
     list. The fog draws none: a station in fog has no outline, it has a place
     where there is more of it. */
  border: var(--hairline) solid var(--shell-edge, var(--film-rule));
  overflow: hidden;
  /* Clipped rather than hidden where the browser knows the word. `hidden`
     still makes a scroll container: a focus or a scrollIntoView on anything
     low in a short window scrolled the shell itself, and the whole face slid
     up under its own top edge -- title and toolbar gone, a blank band below.
     `clip` cannot be scrolled by anything. */
  overflow: clip;
  /* What the edge does at rest is the room's -- `themes.css`, under reaction.
     Nothing at all in the grove, where a border is just a border; in the canyon
     a faint chromatic split that opens while you work. */
  box-shadow: var(--react-edge);
  transition: width var(--dur-morph) var(--ease-soft),
              border-radius var(--dur-morph) var(--ease-soft),
              background-color var(--dur-morph) var(--ease-soft),
              height var(--dur-morph) var(--ease-soft),
              transform 420ms var(--ease-soft),
              box-shadow var(--dur-face) var(--react-settle);
}
.shell[data-moving] { box-shadow: var(--react-edge-moving); }

@supports (backdrop-filter: blur(1px)) {
  .shell { backdrop-filter: blur(var(--blur-shell)) saturate(1.12); }
}

/* Only while the geometry is actually moving. A blurred surface being resized
   is the most expensive thing on this page, and it lasts under a second. */
.shell.is-morphing { will-change: width, height; }


.stage[data-state="convert"] .shell { width: min(100%, 920px); }
.stage[data-state="auth"] .shell,
.stage[data-state="plans"] .shell,
.stage[data-state="account"] .shell { width: min(100%, 560px); }

.stage[data-rest="no"] .shell {
  border-radius: var(--radius-shell-open);
  background: var(--film-shell);
}

/* --- the refraction, only while it morphs -------------------------------- */

.shell__refract {
  position: absolute; inset: 0;
  border-radius: inherit;
  pointer-events: none;
  padding: var(--refract-width);
  opacity: 0;
  background: conic-gradient(from var(--refract-angle),
    hsl(6 78% 72% / 0) 0deg,
    hsl(42 92% 72% / 0.62) 46deg,
    hsl(96 70% 70% / 0) 96deg,
    hsl(178 82% 72% / 0.58) 152deg,
    hsl(214 88% 76% / 0) 206deg,
    hsl(286 72% 76% / 0.55) 262deg,
    hsl(332 80% 74% / 0) 318deg,
    hsl(6 78% 72% / 0) 360deg);
  -webkit-mask: linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0);
  -webkit-mask-composite: xor;
  mask: linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0);
  mask-composite: exclude;
}

/* A second, much fainter sheen drifting across the surface: the "wave". */
.shell__refract::after {
  content: "";
  position: absolute; inset: -40%;
  background: linear-gradient(104deg,
    hsl(190 90% 74% / 0) 38%,
    hsl(190 90% 74% / 0.10) 47%,
    hsl(46 95% 74% / 0.12) 52%,
    hsl(320 85% 76% / 0.10) 57%,
    hsl(320 85% 76% / 0) 66%);
  transform: translateX(-38%);
}

.shell.is-morphing .shell__refract {
  animation: refract-ring 1250ms var(--ease-soft) both,
             refract-turn 1250ms linear both;
}
.shell.is-morphing .shell__refract::after {
  animation: refract-wave 1250ms var(--ease-soft) both;
}

@keyframes refract-ring {
  0%   { opacity: 0; }
  22%  { opacity: 1; }
  62%  { opacity: 0.85; }
  100% { opacity: 0; }
}
@keyframes refract-turn { to { --refract-angle: 320deg; } }
@keyframes refract-wave {
  0%   { transform: translateX(-38%); }
  100% { transform: translateX(38%); }
}

/* ==========================================================================
   Faces
   ========================================================================== */

.shell__faces { position: relative; }

.face {
  display: none;
  opacity: 0;
  padding: var(--s5) clamp(var(--s4), 3vw, var(--s6));
  transition: opacity var(--dur-face) var(--ease);
}
.face.is-active { display: block; opacity: 1; }
.face.is-leaving {
  display: block;
  position: absolute; inset: 0;
  opacity: 0;
  pointer-events: none;
}
/* The face a room rests on, whatever it is called there.

   These were keyed on the name `idle`, which was right while there was one
   room. Face names are namespaced now -- the photo room rests on `photo-idle`
   -- so the rules matched nothing there and its bar had no padding, no hover
   and no sheen: the same component, quietly not looking like itself. `data-rest`
   is what a resting face already carries, and it says what is meant. */
.face[data-rest] { padding: var(--s3); }

/* Faces that arrive after the shell has already opened rise into place. */
.face.is-rising { animation: rise var(--dur-face) var(--ease-soft) both; }
@keyframes rise {
  from { opacity: 0; transform: translateY(14px); }
  to   { opacity: 1; transform: none; }
}

/* ==========================================================================
   Lit surfaces

   A control under the pointer gets a highlight that follows the cursor and a
   refraction that only shows near its border. Both are driven by custom
   properties written by js/lighting.js. At rest every value is 0, so nothing
   renders and nothing animates.

   Nesting matters: the round button inside the bar is its own lit surface, so
   moving onto it takes the light off the bar. That is what sells them as two
   separate layers.
   ========================================================================== */

/* Weight shift. Under a degree and a half at the corners -- enough to feel the
   panel respond, not enough to notice it as an effect.

   The lean is read by the panel alone, so it is not handed down: an ordinary
   custom property is inherited by everything inside, and the pointer moving
   it every frame had the browser restyle the whole panel -- every face, a
   whole open editor -- sixty times a second, for a transform on one box. */
@property --px {
  syntax: '<number>';
  inherits: false;
  initial-value: 0;
}
@property --py {
  syntax: '<number>';
  inherits: false;
  initial-value: 0;
}
[data-tilt] {
  --px: 0;
  --py: 0;
  transform: perspective(1400px)
             rotateX(calc(var(--py) * -0.85deg))
             rotateY(calc(var(--px) * 1.15deg));
  transform-style: preserve-3d;
}

[data-lit] {
  position: relative;
  isolation: isolate;
  --lit: 0;
  --edge: 0;
  --ang: 0deg;
  --mx: 50%;
  --my: 50%;
}

/* The moving highlight. z-index -1 keeps it above the element's own background
   but below its text. */
[data-lit]::before {
  content: "";
  position: absolute;
  inset: 0;
  z-index: -1;
  border-radius: inherit;
  pointer-events: none;
  background: radial-gradient(var(--sheen-reach, 240px) circle at var(--mx) var(--my),
    var(--sheen-light) 0%,
    var(--sheen-light-mid) 42%,
    rgba(255, 255, 255, 0) 72%);
  opacity: var(--lit);
  transition: opacity 280ms var(--ease);
}

/* Pale controls need the highlight to darken rather than lighten. */
[data-lit="invert"]::before,
[data-lit][aria-checked="true"]::before {
  background: radial-gradient(var(--sheen-reach, 240px) circle at var(--mx) var(--my),
    var(--sheen-dark) 0%,
    var(--sheen-dark-mid) 45%,
    rgba(0, 0, 0, 0) 74%);
}

/* The refraction: a thin rainbow ring that fades in only as the pointer nears
   the border, and turns with the angle of approach. */
[data-lit]::after {
  content: "";
  position: absolute;
  inset: 0;
  z-index: 2;
  border-radius: inherit;
  pointer-events: none;
  padding: var(--refract-edge);
  background: conic-gradient(from var(--ang),
    hsl(6 82% 74% / 0) 0deg,
    hsl(44 94% 74% / 0.75) 52deg,
    hsl(150 72% 72% / 0) 104deg,
    hsl(186 88% 74% / 0.7) 158deg,
    hsl(226 90% 78% / 0) 212deg,
    hsl(292 76% 78% / 0.65) 268deg,
    hsl(336 84% 76% / 0) 322deg,
    hsl(6 82% 74% / 0) 360deg);
  -webkit-mask: linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0);
  -webkit-mask-composite: xor;
  mask: linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0);
  mask-composite: exclude;
  opacity: calc(var(--lit) * var(--edge) * 0.9);
  transition: opacity 200ms linear;
}

/* Elevation. Transform and shadow only; no colour is animated here. */
.knob, .action, .ghost, .chip, .back, .rail button {
  /* No blanket will-change here. Leaving it on permanently promotes every
     button to its own layer for the whole life of the page, which on Safari --
     where these sit above a blurred surface over a playing video -- costs more
     than the transforms it was meant to help. Promote on hover intent instead. */
  transition: transform 280ms var(--ease-soft),
              box-shadow 280ms var(--ease-soft),
              background-color var(--dur-state) var(--ease),
              color var(--dur-state) var(--ease),
              border-color var(--dur-state) var(--ease);
}

.knob:hover, .action:hover, .ghost:hover, .chip:hover, .back:hover, .rail button:hover {
  will-change: transform;
}

.action:hover:not(:disabled),
.ghost:hover,
.chip:hover,
.back:hover,
.rail button:hover {
  /* How far, and on what, is the room's -- `themes.css`, under reaction. The
     grove's values are the ones that were written here: a small rise and a
     small sink, because in the grove surfaces stay flat until you touch them.
     A room made of paper barely moves; one made of water moves more. */
  transform: translateY(var(--react-rise-y)) scale(var(--react-rise));
  box-shadow: var(--react-lift);
}

.rail button:active,
.action:active:not(:disabled),
.ghost:active,
.chip:active,
.back:active { transform: translateY(0) scale(var(--react-press)); box-shadow: none; }

/* ==========================================================================
   The bar -- the idle state, and every text field on the site
   ========================================================================== */

/* The island you arrive at. Its height, its padding and the knob on the end of
   it are the room's -- themes.css, under identity -- because this is the whole
   of the first impression and eight of them differing only in hue is eight
   tints. What it *is* does not change: a bar, a line typing itself in it, one
   knob, and the same morph when you drop something on it. */
.bar {
  display: flex; align-items: center; gap: var(--s3);
  min-height: var(--rest-height);
  padding-left: var(--rest-pad);
  /* The room's shape, not the pill's. This was `--radius-control` -- a 999px
     capsule -- which is right in the grove, where the island is a capsule, and
     wrong in the canyon, where a rounded highlight sat inside a cut-cornered
     island and did not belong to it. */
  border-radius: var(--rest-bar-radius);
  cursor: pointer;
}
/* Deliberately almost nothing: the shade under the pointer comes from the
   moving highlight, not from lighting the whole bar at once. */
.face[data-rest] .bar:hover { background: var(--react-bar-hover); }
.face[data-rest] .bar { --sheen-reach: 300px; }

.bar__type {
  flex: 1;
  font-family: var(--rest-type-font);
  font-size: var(--rest-type-size);
  text-transform: var(--rest-type-case);
  letter-spacing: var(--rest-type-track);
  color: var(--film-ink-2);
  white-space: nowrap; overflow: hidden; text-overflow: clip;
}
.bar__type[data-overflow] {
  -webkit-mask-image: linear-gradient(90deg, transparent, #000 var(--s6));
  mask-image: linear-gradient(90deg, transparent, #000 var(--s6));
}
.bar__type::after {
  content: "";
  display: inline-block;
  width: 1px; height: 1.05em;
  margin-left: 2px;
  vertical-align: -0.18em;
  background: var(--film-ink-2);
  animation: caret 1.05s steps(1) infinite;
}
@keyframes caret { 0%, 49% { opacity: 1; } 50%, 100% { opacity: 0; } }

.bar--field { background: var(--film-raise); cursor: text; padding-right: var(--s4); }
.bar__input {
  flex: 1; min-width: 0;
  font: inherit; font-size: var(--t-base);
  color: var(--film-ink);
  background: none; border: 0; outline: none;
  padding: var(--s3) 0;
}
.bar__input::placeholder { color: var(--film-ink-3); }

.knob {
  flex: none;
  width: var(--knob-size); height: var(--knob-size);
  display: grid; place-items: center;
  border: 0; border-radius: var(--knob-radius);
  background: var(--knob-face); color: var(--knob-ink);
  cursor: pointer;
  transition: background-color var(--dur-state) var(--ease);
}
.knob { --sheen-reach: 90px; }
/* Lifts further than anything else, scales more, and tilts: the point is that it
   detaches from the bar rather than moving with it. */
.knob:hover {
  background: var(--knob-face-hi);
  transform: translateY(-3px) scale(1.075) rotate(-6deg);
  box-shadow: var(--lift-2);
}
.knob:active { transform: translateY(-1px) scale(1.02) rotate(-3deg); box-shadow: var(--lift-1); }
.knob svg { transition: transform 280ms var(--ease-soft); }
.knob:hover svg { transform: translateY(-1px); }
.knob svg { width: 20px; height: 20px; fill: none; stroke: currentColor; stroke-width: 2; stroke-linecap: round; stroke-linejoin: round; }

/* ==========================================================================
   Panel contents
   ========================================================================== */

.panel__title {
  font-family: var(--font-display); font-weight: 800;
  font-size: var(--t-xl); line-height: var(--lh-tight); letter-spacing: -0.028em;
  margin-bottom: var(--s4);
}
/* The back control sits with the title it belongs to, not floating over it. */
.panel__head {
  display: flex;
  align-items: flex-start;
  gap: var(--s4);
  margin-bottom: var(--s5);
}
.panel__head .panel__title,
.panel__head .panel__top { margin-bottom: 0; }
.panel__head .panel__top { min-width: 0; }

.back {
  flex: none;
  /* 44px is the touch-target minimum in DESIGN.md 2.5. */
  width: 44px; height: 44px;
  display: grid; place-items: center;
  border: var(--hairline) solid var(--film-rule);
  border-radius: var(--radius-control);
  background: none;
  color: var(--film-ink-2);
  cursor: pointer;
  transition: background-color var(--dur-state) var(--ease),
              color var(--dur-state) var(--ease),
              border-color var(--dur-state) var(--ease);
}
.back:hover { background: var(--film-raise); color: var(--film-ink); border-color: var(--film-ink-3); }
.back { --sheen-reach: 90px; }
.back svg {
  width: 18px; height: 18px;
  fill: none; stroke: currentColor; stroke-width: 2;
  stroke-linecap: round; stroke-linejoin: round;
}

.panel__top { margin-bottom: var(--s5); }
.panel__name { font-size: var(--t-md); color: var(--film-ink); overflow-wrap: anywhere; }
.panel__sub { font-size: var(--t-sm); color: var(--film-ink-3); margin-top: var(--s1); }
/* Sentences under a heading or beside a form, on every account face: no
   single word left on a line of its own ("...the size limit with / it."). */
.panel__sub, .panel__note, .panel__fine, .panel__alert, .setting__why { text-wrap: pretty; }
.panel__empty { font-size: var(--t-sm); color: var(--film-ink-3); }

/* --- the batch list ---------------------------------------------------------
   One row per file. Structure from hairline rules and tone, as everywhere else:
   no boxes, no cards, no icons. What a row is doing is said by the weight of
   its right-hand column, and by one mark in the margin. */
.stack {
  list-style: none;
  /* As far below as the formats sit under a single file's name: with no
     margin here the last row's name sat 8px over the format pills. */
  margin: var(--s4) 0 var(--s4);
  padding: 0;
  max-height: 13.5em;
  overflow-y: auto;
  /* The page hides its scrollbar; this list wants one. How far down twelve
     files you are is worth knowing. */
  scrollbar-width: thin;
  scrollbar-color: var(--film-rule) transparent;
}

.stack__row {
  display: flex;
  align-items: baseline;
  gap: var(--s3);
  padding: var(--s2) 0 var(--s2) var(--s3);
  border-bottom: var(--hairline) solid var(--film-rule);
  position: relative;
}

.stack__row:last-child { border-bottom: none; }

/* The mark in the margin: a small bar that says which of the four states this
   row is in, since colour alone must never carry meaning. Waiting is a hairline
   tick, working is a full-height bar, done and failed keep the bar and change
   the hue. */
.stack__row::before {
  content: "";
  position: absolute;
  left: 0;
  top: 50%;
  width: 2px;
  height: 6px;
  transform: translateY(-50%);
  border-radius: 1px;
  background: var(--film-rule);
  transition: height var(--dur-state) var(--ease), background-color var(--dur-state) var(--ease), opacity var(--dur-state) var(--ease);
}
/* Before anything has been converted there is no queue to be waiting in, and
   the tick read as a stray mark beside each name: it shows once the first file
   has started. */
.stack:not(:has(.stack__row:not([data-state="waiting"]))) .stack__row::before { opacity: 0; }

.stack__row[data-state="working"]::before { height: 18px; background: var(--film-working); }
.stack__row[data-state="done"]::before    { height: 18px; background: var(--film-ok); }
.stack__row[data-state="failed"]::before  { height: 18px; background: var(--film-bad); }

.stack__name {
  flex: 1 1 auto;
  min-width: 0;
  font-size: var(--t-sm);
  color: var(--film-ink-2);
  /* A long filename gives up its middle rather than pushing the size off the
     row -- the extension is the half worth keeping. */
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}

.stack__row[data-state="waiting"] .stack__name { color: var(--film-ink-3); }

.stack__note {
  flex: none;
  font-size: var(--t-sm);
  color: var(--film-ink-3);
  max-width: 45%;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}

/* A size fits in the right-hand column; a sentence saying why a file could not
   be converted does not, and truncating it to "This browser cannot open that
   ima..." is the same as not saying it. A failed row gives it a line. */
.stack__row[data-state="failed"] { flex-wrap: wrap; }

.stack__row[data-state="failed"] .stack__note {
  flex-basis: 100%;
  max-width: 100%;
  white-space: normal;
  color: var(--film-bad);
}

.stack__get {
  color: var(--film-ink);
  text-decoration: none;
  border-bottom: var(--hairline) solid var(--film-rule);
  padding-bottom: 1px;
  transition: border-color var(--dur-state) var(--ease);
}

.stack__get:hover,
.stack__get:focus-visible { border-bottom-color: var(--film-ink); }

@media (prefers-reduced-motion: reduce) {
  .stack__row::before { transition: none; }
}
/* --- the trim span ---------------------------------------------------------
   Two range inputs laid over one track. Each keeps its own thumb and loses its
   own track, so what is seen is a single bar with two handles on it, while the
   browser still handles dragging, arrow keys and focus for each separately. */
.slider--span .span {
  position: relative;
  height: 28px;
  display: grid;
  align-items: center;
}

.span::before {
  content: "";
  position: absolute;
  inset: 50% 0 auto;
  height: 3px;
  transform: translateY(-50%);
  border-radius: var(--radius-control);
  background: var(--film-rule);
}

/* Drawn between the handles' centres, not across the whole track.

   The thumb is 20px wide, so its centre only ever reaches 10px in from each end
   -- the same travel the stop labels are positioned against further down. Laid
   across the full width the fill ran 10px past each handle, which is what made
   the end handle look as though it were not at the end. */
.span__fill {
  position: absolute;
  inset: 50% auto auto 0;
  height: 3px;
  transform: translateY(-50%);
  border-radius: var(--radius-control);
  background: var(--film-ink-3);
  left: calc(var(--thumb, 20px) / 2 + var(--from, 0) * (100% - var(--thumb, 20px)));
  right: calc(100% - var(--thumb, 20px) / 2 - var(--to, 1) * (100% - var(--thumb, 20px)));
  width: auto;
}

.span__handle {
  position: absolute;
  inset: 0;
  width: 100%;
  margin: 0;
  background: none;
  pointer-events: none;   /* the track is shared; only the thumbs take the pointer */
}

.span__handle::-webkit-slider-thumb { pointer-events: auto; }
.span__handle::-moz-range-thumb { pointer-events: auto; }
.span__handle::-webkit-slider-runnable-track { background: none; }
.span__handle::-moz-range-track { background: none; }

/* --- the preview island ----------------------------------------------------
   Sized to the picture rather than to the panel: the frame is the window, so
   there is no surround of a different shade for the eye to read as a second
   box. */
.island {
  position: relative;
  width: fit-content;
  max-width: 100%;
  margin: var(--s4) auto 0;
  overflow: hidden;
  border-radius: calc(var(--radius-shell-open) - 12px);
  background: #000;
  transform-origin: 50% 50%;
}

/* No picture -- an audio file -- leaves only the bar, which should not be
   stretched across the panel. */
.island[data-sound] { width: min(22em, 100%); }
.island[data-sound] .island__video { display: none; }

.island__video,
.island__still {
  display: block;
  background: #000;
}

/* One surface at a time: a still for a photograph, the video element for
   anything with a timeline. */
.island__still { display: none; }
.island[data-still] .island__video { display: none; }
.island[data-still] .island__still { display: block; }
/* A photograph has no timeline, so it has no transport -- but it may still have
   a crop, and the reading for that belongs on the picture. The bar stays for
   that reason alone, and goes when there is nothing to put in it. */
.island[data-still] .island__play { display: none; }
/* The bar earns its place if it has either reading or the cut-out switch on it. */
.island[data-still]:not([data-croppable]):not([data-cuttable]) .island__bar { display: none; }

.island__bar {
  display: flex;
  align-items: center;
  /* Wraps rather than widening the island: the picture decides how wide this
     is, and a narrow portrait gets a two-line bar instead of a black margin. */
  flex-wrap: wrap;
  gap: var(--s2);
  padding: var(--s2) var(--s3);
  background: rgb(var(--shade-rgb) / 0.55);
}

.island__play {
  display: grid;
  place-items: center;
  width: 30px;
  height: 30px;
  flex: none;
  border: var(--hairline) solid var(--film-rule);
  border-radius: var(--radius-control);
  background: var(--film-raise);
  color: var(--film-ink);
  cursor: pointer;
}

.island__icon { width: 15px; height: 15px; fill: currentColor; }
.island__icon--pause { display: none; }
.island[data-playing] .island__icon--play { display: none; }
.island[data-playing] .island__icon--pause { display: block; }

.island__time {
  font-size: var(--t-sm);
  color: var(--film-ink-2);
}

/* The cut-out switch. Sits where the transport would be on a clip -- a
   photograph has no transport, so the space is free -- and reads as pressed or
   not rather than as a value. */
.island__cut {
  display: inline-flex;
  align-items: center;
  gap: var(--s2);
  flex: none;
  min-height: 30px;
  padding: 0 var(--s3);
  font: inherit;
  font-size: var(--t-sm);
  border: var(--hairline) solid var(--film-rule);
  border-radius: var(--radius-control);
  background: var(--film-raise);
  color: var(--film-ink-2);
  cursor: pointer;
  white-space: nowrap;
  transition: background-color var(--dur-state) var(--ease),
              color var(--dur-state) var(--ease),
              border-color var(--dur-state) var(--ease);
}

.island__cut svg { width: 15px; height: 15px; flex: none; }
/* On a narrow picture the label gives way before the island does. */
.island__cut { min-width: 0; }
.island__cut span { overflow: hidden; text-overflow: ellipsis; }

.island__cut:hover { background: var(--film-raise-hi); color: var(--film-ink); }

.island__cut[aria-pressed="true"] {
  background: var(--film-ink);
  border-color: var(--film-ink);
  color: #12161A;
}

/* While the model is working. The dashes on the icon turn over, which is the
   only moving thing on the bar and enough to say "wait". */
.island__cut[data-busy] { pointer-events: none; }
.island__cut[data-busy] svg { animation: cut-working 1.1s linear infinite; }

@keyframes cut-working {
  to { stroke-dashoffset: -20; }
}

/* Pushed to the far end of the bar, away from the transport: it is a reading of
   the picture, not a control of the playback. */
.island__crop {
  margin-left: auto;
  font-size: var(--t-sm);
  color: var(--film-ink-3);
  white-space: nowrap;
}
.island__crop[data-set] { color: var(--film-ink-2); }

/* The cut-out preview, laid over the still at exactly its size. The picture
   underneath keeps the island the right shape; this covers it. */
.island__matte {
  position: absolute;
  inset: 0 0 auto 0;
  display: block;
  pointer-events: none;
}

/* --- the crop rectangle -----------------------------------------------------
   Laid over the picture at exactly the picture's size, and described in
   fractions of it, so the same four numbers mean the same thing here, in the
   canvas that redraws the photograph, and in ffmpeg. */
.crop {
  position: absolute;
  inset: 0 0 auto 0;
  pointer-events: none;
}

/* The dimming is this box's own shadow, thrown outwards and cut off by the
   island's overflow -- one element rather than four panels to keep in step, and
   it follows the rectangle for free however it is dragged. */
.crop__box {
  position: absolute;
  left: calc(var(--cx, 0) * 100%);
  top: calc(var(--cy, 0) * 100%);
  width: calc(var(--cw, 1) * 100%);
  height: calc(var(--ch, 1) * 100%);
  /* Three shadows, one element: the dimming thrown outwards, the hairline that
     marks the edge, and a dark breath just inside it.

     The last is not decoration. White marks on a pale picture are no marks at
     all, and this rectangle has to read over a snowfield as well as over a night
     sky. Drawn as shadows rather than as a `filter`, which would have taken the
     100vmax dimming with it and asked the compositor to blur a screenful. */
  box-shadow:
    0 0 0 100vmax rgb(var(--shade-rgb) / 0.58),
    inset 0 0 0 1px rgba(238, 241, 240, 0.55),
    inset 0 0 5px rgb(var(--shade-rgb) / 0.45);
  cursor: move;
  pointer-events: auto;
  touch-action: none;
}

.crop__box:focus-visible {
  outline: 2px solid var(--film-ink);
  outline-offset: -2px;
}

/* Thirds, for composing by. Only while the rectangle is being worked on: left
   up permanently they are furniture, and the picture is what one is looking
   at. */
.crop__thirds {
  position: absolute;
  inset: 0;
  opacity: 0;
  transition: opacity 240ms ease;
  pointer-events: none;
  background:
    linear-gradient(to right,
      transparent calc(33.333% - 0.5px), rgba(238, 241, 240, 0.42) calc(33.333% - 0.5px),
      rgba(238, 241, 240, 0.42) calc(33.333% + 0.5px), transparent calc(33.333% + 0.5px),
      transparent calc(66.667% - 0.5px), rgba(238, 241, 240, 0.42) calc(66.667% - 0.5px),
      rgba(238, 241, 240, 0.42) calc(66.667% + 0.5px), transparent calc(66.667% + 0.5px)),
    linear-gradient(to bottom,
      transparent calc(33.333% - 0.5px), rgba(238, 241, 240, 0.42) calc(33.333% - 0.5px),
      rgba(238, 241, 240, 0.42) calc(33.333% + 0.5px), transparent calc(33.333% + 0.5px),
      transparent calc(66.667% - 0.5px), rgba(238, 241, 240, 0.42) calc(66.667% - 0.5px),
      rgba(238, 241, 240, 0.42) calc(66.667% + 0.5px), transparent calc(66.667% + 0.5px));
}

.crop[data-working] .crop__thirds,
.crop__box:focus-visible .crop__thirds { opacity: 1; }

/* Corner brackets rather than dots: a viewfinder's way of saying where the
   frame is without drawing a border round the whole picture. The element is a
   generous target; the mark inside it is small. */
.crop__grip {
  position: absolute;
  width: 26px;
  height: 26px;
  pointer-events: auto;
  touch-action: none;
}

.crop__grip::before {
  content: "";
  position: absolute;
  width: 15px;
  height: 15px;
  border: 2px solid var(--film-ink);
  border-radius: 1px;
  filter: drop-shadow(0 1px 2px rgb(var(--shade-rgb) / 0.8));
  transition: transform 160ms ease;
}

.crop__grip--nw { left: -2px;  top: -2px;  cursor: nwse-resize; }
.crop__grip--ne { right: -2px; top: -2px;  cursor: nesw-resize; }
.crop__grip--sw { left: -2px;  bottom: -2px; cursor: nesw-resize; }
.crop__grip--se { right: -2px; bottom: -2px; cursor: nwse-resize; }

.crop__grip--nw::before { left: 2px;  top: 2px;  border-right: none; border-bottom: none; }
.crop__grip--ne::before { right: 2px; top: 2px;  border-left: none;  border-bottom: none; }
.crop__grip--sw::before { left: 2px;  bottom: 2px; border-right: none; border-top: none; }
.crop__grip--se::before { right: 2px; bottom: 2px; border-left: none;  border-top: none; }

.crop__grip:hover::before { transform: scale(1.14); }

/* The edges take a whole side each, so one can pull a side in without hunting
   for a handle -- the visible bar is only the hint of where to grab. */
.crop__edge {
  position: absolute;
  pointer-events: auto;
  touch-action: none;
}

.crop__edge::before {
  content: "";
  position: absolute;
  inset: 50% auto auto 50%;
  transform: translate(-50%, -50%);
  border-radius: var(--radius-control);
  background: var(--film-ink);
  opacity: 0.85;
  filter: drop-shadow(0 1px 2px rgb(var(--shade-rgb) / 0.8));
  transition: transform 160ms ease;
}

.crop__edge--n, .crop__edge--s { left: 26px; right: 26px; height: 20px; cursor: ns-resize; }
.crop__edge--w, .crop__edge--e { top: 26px; bottom: 26px; width: 20px; cursor: ew-resize; }
.crop__edge--n { top: -10px; }
.crop__edge--s { bottom: -10px; }
.crop__edge--w { left: -10px; }
.crop__edge--e { right: -10px; }

.crop__edge--n::before, .crop__edge--s::before { width: 26px; height: 3px; }
.crop__edge--w::before, .crop__edge--e::before { width: 3px; height: 26px; }

.crop__edge--n:hover::before, .crop__edge--s:hover::before { transform: translate(-50%, -50%) scaleX(1.3); }
.crop__edge--w:hover::before, .crop__edge--e:hover::before { transform: translate(-50%, -50%) scaleY(1.3); }

/* Arriving. The picture resolves first and the frame settles onto it a beat
   later, which is the order one would draw them in. */
.island[data-opening] .crop__box {
  animation: crop-settle 620ms cubic-bezier(.24, .9, .3, 1) 340ms both;
}

@keyframes crop-settle {
  from { opacity: 0; transform: scale(1.035); }
  to   { opacity: 1; transform: none; }
}

@media (prefers-reduced-motion: reduce) {
  .island[data-opening] .crop__box { animation: none; }
  .crop__thirds, .crop__grip::before, .crop__edge::before { transition: none; }
}

/* Drawn back from the middle rather than faded in. The panel has already eased
   to its new height by the time this runs, so nothing here moves the layout and
   the opening can take its time.

   clip-path rather than height: it opens from a hairline at the centre to the
   whole frame without needing to know how tall the picture is. */
.island[data-opening] {
  animation: island-open 860ms cubic-bezier(.26, .02, .18, 1) both,
             extras-grow 780ms cubic-bezier(.3, 0, .2, 1) both;
}

.island[data-opening] .island__video,
.island[data-opening] .island__still {
  animation: island-resolve 900ms cubic-bezier(.3, 0, .2, 1) both;
}

/* The same hues the shell uses when it changes shape, passing over the picture
   once as it opens. */
.island[data-opening]::after {
  content: "";
  position: absolute;
  inset: 0;
  border-radius: inherit;
  pointer-events: none;
  background: linear-gradient(100deg,
    transparent 38%,
    hsl(44 94% 74% / 0.26) 45%,
    hsl(150 72% 72% / 0.26) 50%,
    hsl(186 88% 74% / 0.26) 55%,
    hsl(292 76% 78% / 0.26) 60%,
    transparent 67%);
  background-size: 260% 100%;
  animation: island-sheen 860ms cubic-bezier(.3, 0, .25, 1) both;
}

.island[data-closing] {
  animation: island-shut 520ms cubic-bezier(.4, 0, .3, 1) 60ms both,
             extras-fold 780ms cubic-bezier(.4, 0, .25, 1) both;
}

@keyframes island-open {
  from { clip-path: inset(50% 0 50% 0); }
  /* The slit holds for a moment before it opens, which is what makes it read as
     being drawn back rather than simply growing. */
  20%  { clip-path: inset(49.2% 0 49.2% 0); }
  to   { clip-path: inset(0 0 0 0); }
}

/* The picture arrives out of focus and settles, a beat behind the frame. */
@keyframes island-resolve {
  from { filter: blur(14px); transform: scale(1.06); opacity: 0.35; }
  35%  { filter: blur(9px); opacity: 0.8; }
  to   { filter: blur(0); transform: none; opacity: 1; }
}

@keyframes island-sheen {
  from { background-position: 150% 0; opacity: 0; }
  30%  { opacity: 1; }
  to   { background-position: -150% 0; opacity: 0; }
}

@keyframes island-shut {
  from { clip-path: inset(0 0 0 0); opacity: 1; }
  to   { clip-path: inset(50% 0 50% 0); opacity: 0; }
}

/* --- the plus ---------------------------------------------------------------
   A cross that comes apart and puts itself back together as a frame: the four
   arms each turn a quarter and move out to become the four sides of a window,
   which is what pressing it opens. Nothing else on the panel changes shape, so
   it is the one control that announces there is more underneath. */
/* Written as `.rail .extras` so it outranks `.rail button`, which would
   otherwise give it a chip's padding and min-height and leave it 34 wide by 44
   tall. It matches the chips' height and is square. */
.rail .extras {
  position: relative;
  display: grid;
  place-items: center;
  width: 44px;
  height: 44px;
  min-height: 44px;
  flex: none;
  padding: 0;
  margin-left: var(--s1);
  border: var(--hairline) solid var(--film-rule);
  border-radius: var(--radius-control);
  background: var(--film-raise);
  color: var(--film-ink);
  cursor: pointer;
  transition: transform var(--dur-state) var(--ease), box-shadow var(--dur-state) var(--ease);
}

.rail .extras:hover { background: var(--film-raise-hi); color: var(--film-ink); }

.extras:hover { transform: scale(1.06); }

/* A tenth of the way to the frame on hover -- enough to read as alive, not as a
   state. The slow duration below is the one that applies on the way *out*, so
   the shape darts towards the frame and then takes most of a second drifting
   back. */
.extras:hover .extras__arm { transition-duration: 150ms; }
.extras:hover .extras__arm--n { transform: translate(0, -0.15px) rotate(9deg) scaleY(1.1); }
.extras:hover .extras__arm--s { transform: translate(0, 0.15px) rotate(9deg) scaleY(1.1); }
.extras:hover .extras__arm--w { transform: translate(-0.15px, 0) rotate(9deg) scaleX(1.1); }
.extras:hover .extras__arm--e { transform: translate(0.15px, 0) rotate(9deg) scaleX(1.1); }

/* Once open the frame holds, whatever the pointer is doing. */
.extras[aria-expanded="true"]:hover .extras__arm { transition-duration: 620ms; }
.extras[aria-expanded="true"]:hover .extras__arm--n { transform: translate(0, -1.5px) rotate(90deg) scaleY(2); }
.extras[aria-expanded="true"]:hover .extras__arm--s { transform: translate(0, 1.5px) rotate(90deg) scaleY(2); }
.extras[aria-expanded="true"]:hover .extras__arm--w { transform: translate(-1.5px, 0) rotate(90deg) scaleX(2); }
.extras[aria-expanded="true"]:hover .extras__arm--e { transform: translate(1.5px, 0) rotate(90deg) scaleX(2); }

.rail .extras svg { width: 19px; height: 19px; overflow: visible; }

.extras__arm {
  fill: currentColor;
  transform-box: fill-box;
  transform-origin: center;
  transition: transform 1100ms cubic-bezier(.22, .64, .3, 1);
}

/* Each arm turns a quarter, doubles in length and steps outward, and the cross
   is a frame. The numbers are the geometry of that frame, not taste: a 7-long
   arm becomes a 14-long side, and a centre 1.5 from the middle becomes one 7
   from it. */
.extras[aria-expanded="true"] .extras__arm--n { transform: translate(0, -1.5px) rotate(90deg) scaleY(2); }
.extras[aria-expanded="true"] .extras__arm--s { transform: translate(0, 1.5px) rotate(90deg) scaleY(2); }
.extras[aria-expanded="true"] .extras__arm--w { transform: translate(-1.5px, 0) rotate(90deg) scaleX(2); }
.extras[aria-expanded="true"] .extras__arm--e { transform: translate(1.5px, 0) rotate(90deg) scaleX(2); }

/* A slow colour drifting across the glyph, at the edge of noticing. The same
   hues the shell uses when it changes shape, so it belongs to the same world. */
.extras::after {
  content: "";
  position: absolute;
  inset: -1px;
  border-radius: inherit;
  padding: 1.25px;
  background: linear-gradient(115deg,
    transparent 30%,
    hsl(44 94% 74% / 0.5) 42%,
    hsl(150 72% 72% / 0.5) 48%,
    hsl(186 88% 74% / 0.5) 54%,
    hsl(292 76% 78% / 0.5) 60%,
    transparent 72%);
  background-size: 260% 100%;
  -webkit-mask: linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0);
  mask: linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0);
  -webkit-mask-composite: xor;
  mask-composite: exclude;
  animation: extras-glisten 7s linear infinite;
  pointer-events: none;
}

@keyframes extras-glisten {
  from { background-position: 130% 0; }
  to   { background-position: -130% 0; }
}


/* Its own line, so the plus sits out to the right of the settings above it
   rather than squeezing in among them. */
.extras-row {
  display: flex;
  align-items: center;
  margin-top: var(--s2);
}

/* --- building in ------------------------------------------------------------
   The trim bar does not appear, it is drawn: the track opens from its middle
   outwards, and the handles arrive once there is a track to sit on. */
/* The row's height is animated too, not just its contents: that is what carries
   the whole layout below it -- the convert button included -- rather than
   letting it jump into place when the reveal finishes. `--h` is its measured
   height, set by `main.js` just before this runs. */
.slider--span[data-building],
.slider--span[data-closing] {
  overflow: hidden;
}

.slider--span[data-building] {
  animation: extras-grow 780ms cubic-bezier(.3, 0, .2, 1) both;
}

.slider--span[data-closing] {
  animation: extras-fold 780ms cubic-bezier(.4, 0, .25, 1) both;
}

@keyframes extras-grow {
  from { height: 0; margin-top: 0; margin-bottom: 0; opacity: 0; }
  to   { height: var(--h, auto); opacity: 1; }
}

/* The end state pulls back by one gap as well as by its own height.

   The row sits in a flex column with a gap between items, and a gap is not part
   of any item -- so folding the row to nothing still left its gap standing until
   the row was actually taken out of the flow, at which point the panel lost that
   height in a single frame. `--fold-gap` is the container's gap, set by
   `main.js` only when the row has a neighbour to have a gap with. */
@keyframes extras-fold {
  from { height: var(--h, auto); opacity: 1; }
  to   {
    height: 0;
    margin-top: 0;
    margin-bottom: calc(-1 * var(--fold-gap, 0px));
    opacity: 0;
  }
}

.slider--span[data-building] .span::before,
.slider--span[data-building] .span__fill {
  animation: span-draw 700ms cubic-bezier(.3, 0, .2, 1) both;
}

.slider--span[data-building] .span__handle,
.slider--span[data-building] .slider__head {
  animation: span-arrive 520ms cubic-bezier(.3, 0, .25, 1) 380ms both;
}

@keyframes span-draw {
  from { clip-path: inset(0 50% 0 50%); }
  to   { clip-path: inset(0 0 0 0); }
}

@keyframes span-arrive {
  from { opacity: 0; transform: translateY(3px); }
  to   { opacity: 1; transform: none; }
}

/* Going away is the same in reverse, and in reverse order: the handles and the
   reading leave first, then the track draws itself shut towards the middle. It
   used to simply vanish, which was the one part of the sequence that had no
   movement in it at all. */
.slider--span[data-closing] .span__handle,
.slider--span[data-closing] .slider__head {
  animation: span-leave 240ms cubic-bezier(.45, 0, .7, 1) both;
}

.slider--span[data-closing] .span::before,
.slider--span[data-closing] .span__fill {
  animation: span-undraw 420ms cubic-bezier(.5, 0, .25, 1) 150ms both;
}

@keyframes span-leave {
  from { opacity: 1; transform: none; }
  to   { opacity: 0; transform: translateY(4px); }
}

@keyframes span-undraw {
  from { clip-path: inset(0 0 0 0); }
  to   { clip-path: inset(0 50% 0 50%); }
}

@media (prefers-reduced-motion: reduce) {
  .extras__arm { transition: none; }
  .extras::after { animation: none; }
  .slider--span[data-building] .span::before,
  .slider--span[data-building] .span__fill,
  .slider--span[data-building] .span__handle,
  .slider--span[data-building] .slider__head,
  .slider--span[data-closing] .span::before,
  .slider--span[data-closing] .span__fill,
  .slider--span[data-closing] .span__handle,
  .slider--span[data-closing] .slider__head,
  .slider--span[data-building],
  .slider--span[data-closing] { animation: none; }
}

@media (prefers-reduced-motion: reduce) {
  .island[data-opening], .island[data-closing],
  .island[data-opening] .island__video,
  .island[data-opening] .island__still,
  .island[data-opening]::after { animation: none; }
}

/* The last chip in the row keeps the same format and only changes its size, so
   it is set slightly apart from the formats rather than sitting among them: a
   little more space before it and a dashed outline instead of a solid one. No
   pseudo-element divider -- ::before and ::after on these buttons already carry
   the pointer-tracked sheen and refraction. */
.rail button[data-resize] {
  margin-left: var(--s3);
  border-style: dashed;
}

.panel__est {
  font-size: var(--t-sm);
  color: var(--film-ink-3);
  white-space: nowrap;
  overflow: hidden;
}

/* No width animation at all. It was there so the row would settle into its new
   length rather than snap to it, but the box clips its overflow, so a line that
   grew longer was drawn with its last word cut off until the width caught up --
   which is exactly the tail that appeared a beat late. Safari made it worse by
   transitioning the used width of a flex item even when the width is `auto`,
   but the clipping was there in every browser. The blur and the letter spacing
   carry the swap on their own; the row taking its new width immediately, while
   the words are blurred out, is not something the eye catches. */
.panel__est:empty { display: none; }

/* The phrase does not cut to the figure, it reorganises into it: the letters
   pull together and blur out, then the number resolves out of the same blur.
   Two halves of one movement, so it reads as the same material rearranging. */
.panel__est[data-phase="out"] {
  animation: est-out 150ms var(--ease) forwards;
}
.panel__est[data-phase="in"] {
  animation: est-in 260ms var(--ease-soft) forwards;
}

@keyframes est-out {
  from { opacity: 1; filter: blur(0); letter-spacing: 0; }
  to   { opacity: 0; filter: blur(3px); letter-spacing: -0.07em; }
}
@keyframes est-in {
  from { opacity: 0; filter: blur(3px); letter-spacing: 0.10em; }
  to   { opacity: 1; filter: blur(0); letter-spacing: 0; }
}

/* While a size is being worked out, the same refraction hues used on the shell
   sweep through the letters and accelerate, the way a loading bar does.
   DESIGN.md 1.1 bans gradient text; this is a transient loading state rather
   than a heading, and it is the one place the wait needs a voice. */
.panel__est[data-state="working"] {
  background-image: linear-gradient(
    100deg,
    var(--film-ink-3) 0%,
    var(--film-ink-3) 34%,
    hsl(44 94% 74%) 43%,
    hsl(150 72% 72%) 48%,
    hsl(186 88% 74%) 53%,
    hsl(292 76% 78%) 58%,
    var(--film-ink-3) 66%,
    var(--film-ink-3) 100%
  );
  background-size: 300% 100%;
  /* repeat-x, not no-repeat: a single tile slid across leaves part of the text
     with no background behind it, and with color:transparent those letters
     disappear entirely. Both ends of the gradient are the same grey, so the
     tiling seam is invisible. */
  background-repeat: repeat-x;
  -webkit-background-clip: text;
  background-clip: text;
  color: transparent;
  /* WebKit does not honour `color: transparent` with background-clip: text --
     it needs its own fill property, without which Safari shows plain grey text
     and no sweep at all. */
  -webkit-text-fill-color: transparent;
  animation: est-sweep 1500ms cubic-bezier(.65, 0, .85, .35) infinite;
}

/* One dot, then two, then three. `content` animates in discrete steps, and a
   fixed-width box keeps the row from twitching as they appear. The box is set
   in ch rather than a guessed em value: this readout uses a monospace face
   where a dot advances 0.6em, so the old 1.4em held only two dots and a third
   of the next -- and since the parent clips overflow, the last dot was simply
   cut off. The spare 0.4ch is trailing room in case a fallback face advances
   a dot wider than its digits. */
.panel__est[data-state="working"]::after {
  content: ".";
  display: inline-block;
  width: 3.4ch;
  text-align: left;
  animation: est-dots 1200ms steps(1) infinite;
}

@keyframes est-dots {
  0%        { content: "."; }
  30%       { content: ".."; }
  60%, 100% { content: "..."; }
}

@supports not ((-webkit-background-clip: text) or (background-clip: text)) {
  .panel__est[data-state="working"] {
    color: var(--film-ink-3);
    -webkit-text-fill-color: var(--film-ink-3);
    animation: none;
  }
}

/* Starts with the colour already entering the text rather than travelling in
   from off-screen first, so the line reads as alive from the first frame. */
@keyframes est-sweep {
  from { background-position: 78% 0; }
  to   { background-position: -46% 0; }
}

.panel__go { display: flex; align-items: center; gap: var(--s3); flex-wrap: wrap; margin-top: var(--s5); }
.panel__done { margin-top: var(--s5); }

.action {
  font: inherit; font-size: var(--t-sm); font-weight: 600; line-height: 1;
  min-height: 48px; padding: 0 var(--s6);
  display: inline-flex; align-items: center; justify-content: center;
  border: 0; border-radius: var(--radius-control);
  background: var(--knob-face); color: var(--knob-ink);
  cursor: pointer; text-decoration: none; white-space: nowrap;
  transition: background-color var(--dur-state) var(--ease), color var(--dur-state) var(--ease), opacity var(--dur-state) var(--ease);
}
.action:hover:not(:disabled) { background: var(--knob-face-hi); }
.action:disabled { background: var(--film-raise); color: var(--film-ink-3); cursor: not-allowed; }

/* One primary when done. With a result on the face, Download is the thing to
   press, and two white buttons stacked one above the other said there were
   two. The convert button steps back to the quiet rank -- the ghost's look,
   in the same place and the same size, so nothing moves -- until a setting
   or the format changes (main.js sets `data-changed`, or clears the result),
   and then it is the primary again. */
#face-convert:has(#result:not([hidden])) #run-btn:not([data-changed]):not(:disabled) {
  background: none;
  color: var(--film-ink-2);
  box-shadow: inset 0 0 0 var(--hairline) var(--film-rule);
}
#face-convert:has(#result:not([hidden])) #run-btn:not([data-changed]):not(:disabled):hover {
  background: var(--film-raise);
  color: var(--film-ink);
  box-shadow: inset 0 0 0 var(--hairline) var(--film-rule), var(--react-lift);
}
#face-convert:has(#result:not([hidden])) #run-btn:not([data-changed]):not(:disabled):active {
  box-shadow: inset 0 0 0 var(--hairline) var(--film-rule);
}
#face-convert:has(#result:not([hidden])) #run-btn:not([data-changed])::before { opacity: 0; }

.ghost {
  font: inherit; font-size: var(--t-sm); font-weight: 600; line-height: 1;
  min-height: 48px; padding: 0 var(--s5);
  display: inline-flex; align-items: center;
  border: var(--hairline) solid var(--film-rule); border-radius: var(--radius-control);
  background: none; color: var(--film-ink-2);
  cursor: pointer; white-space: nowrap;
  transition: background-color var(--dur-state) var(--ease), color var(--dur-state) var(--ease);
}
.ghost:hover { background: var(--film-raise); color: var(--film-ink); }
a.ghost { text-decoration: none; }

.chip {
  font: inherit; font-size: var(--t-sm); font-weight: 600; line-height: 1;
  min-height: 44px; padding: 0 var(--s4);
  display: inline-flex; align-items: center;
  border: var(--hairline) solid var(--film-rule); border-radius: var(--radius-control);
  background: var(--film-raise); color: var(--film-ink);
  cursor: pointer; white-space: nowrap;
  transition: background-color var(--dur-state) var(--ease);
}
.chip:hover { background: var(--film-raise-hi); }
/* On the face it opens -- signing in, or the account -- the chip is a second
   way to the page you are already on. One home per control: it steps out,
   keeping its place so the allowance and the dial beside it do not move. */
body:has(.stage:is([data-state="auth"], [data-state="account"])) #account-btn {
  opacity: 0; visibility: hidden;
  transition: opacity var(--dur-state) var(--ease), visibility 0s linear var(--dur-state);
}

/* --- the format rail: pills, never a dropdown ---------------------------- */

.rail { display: flex; flex-wrap: wrap; gap: var(--s2); }

.rail button {
  font: inherit;
  font-family: var(--font-mono); font-size: var(--t-sm);
  min-height: 44px; padding: 0 var(--s4);
  display: inline-flex; align-items: center;
  border: var(--hairline) solid var(--film-rule); border-radius: var(--radius-control);
  background: none; color: var(--film-ink-2);
  cursor: pointer;
  transition: background-color var(--dur-state) var(--ease),
              color var(--dur-state) var(--ease),
              border-color var(--dur-state) var(--ease);
}
.rail button { --sheen-reach: 150px; }
.rail button:hover { background: var(--film-raise); color: var(--film-ink); }
.rail button[aria-checked="true"] {
  background: var(--knob-face); color: var(--knob-ink); border-color: transparent;
}

/* A target this browser cannot produce. Still present and still focusable --
   hiding it would leave people wondering why a format they expected is missing
   -- but visibly struck out. */
/* While a conversion runs the settings are fixed: changing them mid-encode
   would describe a file that is not the one being written. Going back is still
   allowed, and cancels. */
.face[data-busy] .rail button,
.face[data-busy] .slider input[type="range"] {
  opacity: 0.45;
  pointer-events: none;
}
.face[data-busy] .slider__head { opacity: 0.45; }

.rail button[data-unsupported] {
  color: var(--film-ink-3);
  border-color: var(--film-rule);
  cursor: not-allowed;
}
.rail button[data-unsupported]:hover {
  background: none;
  color: var(--film-ink-3);
  transform: none;
  box-shadow: none;
}
/* The bar. ::after carries the refraction ring on a normal control; here it
   earns its keep as the strike instead, and ::before is switched off. */
.rail button[data-unsupported]::before { display: none; }
.rail button[data-unsupported]::after {
  content: "";
  position: absolute;
  left: 10%;
  right: 10%;
  top: 50%;
  height: 1px;
  padding: 0;
  border-radius: 0;
  background: var(--film-ink-3);
  opacity: 0.85;
  -webkit-mask: none;
  mask: none;
  transform: rotate(-9deg);
}

/* Pressing it should feel like the button declining, not like nothing happened. */
.rail button.is-refusing { animation: refuse 320ms var(--ease) 1; }
@keyframes refuse {
  0%, 100% { transform: translateX(0); }
  16%      { transform: translateX(-4px); }
  34%      { transform: translateX(4px); }
  52%      { transform: translateX(-3px); }
  70%      { transform: translateX(2px); }
  86%      { transform: translateX(-1px); }
}

.rail--plans { flex-direction: column; gap: var(--s3); margin-top: var(--s4); }
.rail--plans .pick {
  display: grid;
  grid-template-columns: auto 1fr;
  gap: 0 var(--s4);
  align-items: baseline;
  padding: var(--s4) var(--s5);
  min-height: 0;
  text-align: left;
  font-family: var(--font-ui);
}
/* The price column is as wide as the widest price on the rail ("Free", "€1",
   "CHF 1"), set by account.js, so the words beside them start on one line;
   each price is centred in it.

   Centred top to bottom as well. The figure shares a baseline with "per
   month", which is what gives the bubble its height, and on that baseline it
   rides high: 20.5px above it and 35.4px below at 1440, the font's 10px
   descent sitting unused under the digits. Taking it off the baseline would
   shrink every bubble; nudging it down by half the difference keeps the
   bubble exactly the size it was and puts both the box and the ink of the
   figure in the middle. In em, so it holds at every size the figure takes. */
.pick__price {
  font-size: var(--t-xl);
  grid-row: span 2;
  min-width: var(--price-ch, 0);
  text-align: center;
  position: relative;
  top: 0.233em;
}
.pick__unit { font-size: var(--t-sm); font-weight: 600; }
.pick__note { font-size: var(--t-sm); opacity: 0.7; grid-column: 2; }
/* The two lines beside the price, as one block centred on the price's
   figures and on the card (the owner, 6 October 2026). The grid's shared
   baseline is what sets the card's height, so it stays; the block is moved
   off it by however far its ink sits from the middle, which depends on the
   words (a descender or none) and so is measured -- js/ui/plan-ink.js.
   Relative, so nothing else moves: not the card, not the price. */
.rail--plans :is(.pick__unit, .pick__note) { position: relative; top: var(--ink-dy, 0px); }

/* --- sliders: every option, no dropdowns anywhere ------------------------ */

.sliders { display: flex; flex-direction: column; gap: var(--s5); margin-top: var(--s6); }
.sliders:empty { display: none; }

.slider__head {
  display: flex; justify-content: space-between; align-items: baseline; gap: var(--s3);
  font-size: var(--t-xs); text-transform: uppercase; letter-spacing: 0.14em;
  color: var(--film-ink-3);
  margin-bottom: var(--s2);
}
.slider__value { font-family: var(--font-mono); letter-spacing: 0; text-transform: none; color: var(--film-ink); }

.slider input[type="range"] {
  -webkit-appearance: none; appearance: none;
  width: 100%; height: 28px;
  background: none; cursor: pointer;
}
.slider input[type="range"]::-webkit-slider-runnable-track {
  height: 4px; border-radius: var(--radius-control); background: var(--film-raise-hi);
}
.slider input[type="range"]::-moz-range-track {
  height: 4px; border-radius: var(--radius-control); background: var(--film-raise-hi);
}
.slider input[type="range"]::-webkit-slider-thumb {
  -webkit-appearance: none; appearance: none;
  width: 20px; height: 20px; margin-top: -8px;
  border: 0; border-radius: var(--radius-control);
  background: var(--knob-face);
  transition: background-color var(--dur-state) var(--ease);
}
.slider input[type="range"]::-moz-range-thumb {
  width: 20px; height: 20px; border: 0; border-radius: var(--radius-control);
  background: var(--knob-face);
}
.slider input[type="range"]:hover::-webkit-slider-thumb { background: var(--knob-face-hi); }

/* A slider being moved. What happens is the room's -- `themes.css`, under
   reaction: nothing at all in the grove, where the number changing is the
   feedback; in the canyon the track's own edge picks up the warm and cool ends
   of the split, which is the room saying "this is light, and you are bending
   it". The flag is set by the shell, so it works the same for every room and
   for the keyboard as well as the pointer. */
.slider input[type="range"][data-moving]::-webkit-slider-runnable-track,
.adj__slider[data-moving]::-webkit-slider-runnable-track {
  filter: var(--react-drag-track);
}
.slider input[type="range"][data-moving]::-moz-range-track,
.adj__slider[data-moving]::-moz-range-track {
  filter: var(--react-drag-track);
}
.slider input[type="range"][data-moving]::-webkit-slider-thumb,
.adj__slider[data-moving]::-webkit-slider-thumb {
  box-shadow: var(--react-drag-thumb);
}
.slider input[type="range"][data-moving]::-moz-range-thumb,
.adj__slider[data-moving]::-moz-range-thumb {
  box-shadow: var(--react-drag-thumb);
}

/* The thumb is 20px wide, so its centre only ever reaches 10px in from each end.
   Labels are positioned against that travel, not against the container, or they
   sit progressively further out of line towards the ends. */
.slider__stops {
  position: relative;
  height: 1.2em;
  margin-top: var(--s1);
  font-family: var(--font-mono); font-size: var(--t-xs);
  color: var(--film-ink-3);
  --thumb-half: 10px;
}
/* --at is a fraction from 0 to 1. A percentage in `left` resolves against the
   padding box, so the inset has to be inside the calculation rather than being
   padding on the container. */
.slider__stops span {
  position: absolute;
  top: 0;
  left: calc(var(--thumb-half) + var(--at) * (100% - 2 * var(--thumb-half)));
  white-space: nowrap;
  transform: translateX(-50%);
}
/* The outermost two are anchored by their inner edge so they cannot hang off
   the side of the panel. */
.slider__stops span:first-child { transform: translateX(0); }
.slider__stops span:last-child { transform: translateX(-100%); }

/* --- progress: the only feedback the panel gives ------------------------- */

.bar-progress {
  position: relative;
  margin-top: var(--s5);
  height: 44px;
  border-radius: var(--radius-control);
  background: var(--film-raise);
  overflow: hidden;
}
.bar-progress__fill {
  position: absolute; inset: 0 auto 0 0;
  width: 0%;
  background: var(--knob-face);
  transition: width 320ms var(--ease-soft);
}
.bar-progress__pct {
  position: absolute; inset: 0;
  display: grid; place-items: center;
  font-size: var(--t-sm);
  color: var(--film-ink);
  mix-blend-mode: difference;
}

/* Comes out of the progress bar sideways: the row opens a crack under the bar
   and the line is wiped into view from its left edge, while a wave runs through
   the letters and the box gives way under it.

   Three animations, because they answer to different clocks. The wipe uncovers
   the line over 1.6s. The wave is per-letter and finishes later, once its far
   end has settled. The lean is the body's reply to the wave and has to last as
   long as the wave does, so it is separate from the wipe.

   The arrival and the colour sweep are separate states rather than one run of
   animation, and they have to be: the sweep paints the letters by clipping a
   background to them, and a transformed inline descendant is painted outside
   that clip, so the waving letters of the arrival would simply vanish. The line
   arrives in plain grey, then hands over to the sweep. */
.panel__remain {
  margin-top: calc(var(--s2) - 2px);
  padding-bottom: 2px;
  /* Shrink-wrapped, and it matters: clip-path percentages are measured against
     this box, not against the words in it. As a full-width block the box was
     856px against 163px of text, so "13% revealed" already showed two thirds of
     the line and the wipe was over in the first fifth of its own duration --
     which is why the text used to outrun the wave. fit-content rather than
     max-content so a long line can still wrap on a narrow screen. */
  width: fit-content;
  max-width: 100%;
  font-size: var(--t-sm);
  color: var(--film-ink-3);
  transform-origin: 0% 50%;
  /* Not `overflow: hidden`. The dip has to be able to leave this box: the panel
     has about 25px of its own padding below this line, and that gap is the
     distance the wave has to close before it looks like it is reaching the
     panel's edge at all. Clipping to the box capped the dip at a third of that
     and left it hanging in open space. The wipe below uses clip-path with a
     negative bottom inset instead, which hides the line horizontally while
     leaving the space underneath it open. */
  overflow: visible;
}

.panel__remain[data-phase="arriving"] {
  animation: remain-squeeze 2400ms linear both,
             remain-lean var(--lean-dur, 2700ms) both,
             remain-open var(--open-dur, 343ms) cubic-bezier(.33, 0, .28, 1) var(--open-delay, 275ms) both;
}

/* The reveal. Opacity and the horizontal wipe, and nothing else -- in
   particular not the row's height.

   The height used to be animated here, and it was the whole problem. Opening a
   row from zero moves the panel's bottom edge by a full line, about 27px, and
   it did that inside the first 160ms, while the wipe had still revealed exactly
   nothing. The edge had already done all its travelling before a letter was on
   screen; the wave's own contribution was 6px arriving half a second later. The
   box opened itself and the wave added a rounding error. Now the row takes its
   full height immediately and is pulled back up by a negative margin, which the
   wave then releases -- so the panel's growth is the wave's doing.

   The sweep is near steady rather than surging. Letters are handed their dip at
   the moment the wipe reaches them, so a burst of reveal handed ten letters
   their dip inside half a second, and with each dip lasting the better part of a
   second all ten fell at once: not a wave travelling along a line, a line
   sagging. At about 102ms apart, a sixth of a dip, a handful are in motion at a
   time and the travel is legible.

   Linear, because the shape is in the keyframes now: easing each pair as well
   would put a small hesitation at every one of them. */
@keyframes remain-squeeze {
  0%     { opacity: 0; clip-path: inset(0 100% -34px 0); }
  6%     { opacity: 1; clip-path: inset(0 100% -34px 0); }
  14%    { clip-path: inset(0 100% -34px 0); }
  22.6%  { clip-path: inset(0 95% -34px 0); }
  35.5%  { clip-path: inset(0 82% -34px 0); }
  52.7%  { clip-path: inset(0 60% -34px 0); }
  65.6%  { clip-path: inset(0 44% -34px 0); }
  78.5%  { clip-path: inset(0 28% -34px 0); }
  89.7%  { clip-path: inset(0 13% -34px 0); }
  100%   { opacity: 1; clip-path: inset(0 0 -34px 0); }
}

/* The boundary. The row is born with its full height taken but hauled back up
   by a negative margin measured in `main.js` to be exactly what the row adds to
   the panel -- margin-top and border box together -- so the panel's edge does
   not move by even a pixel when the line arrives. The margin is then released,
   and that release is the edge being driven down by the first letter pressing
   on it.

   Two keyframes and one curve, and the curve is the letter's own: this runs for
   exactly the descent of the first letter, with the same easing, starting on
   the same millisecond. Both timings come from `main.js`, which knows what it
   gave that letter. Nothing here can drift out of step with the wave because
   nothing here is timed independently of it. */
@keyframes remain-open {
  from { margin-bottom: var(--tuck, -30px); }
  to   { margin-bottom: 0; }
}

/* The body's reply to the wave. The box is pinned at its left edge, so a dip
   near that edge has almost no leverage and a dip at the far end has all of it:
   the line is held tipped up while the wave is on the left, swings through level
   as it crosses the middle, and is pulled right-end-down as it reaches the end,
   then settles back.

   Three keyframes, not thirteen. The earlier version placed a stop at every
   moment of interest and let the default ease-in-out apply to each pair, which
   meant the motion braked almost to a stop and surged again at every one of
   them -- measured at eleven such hitches across the run, with the rotation
   speed swinging between 0.1 and 2.9 degrees a second. An eased pair is only
   smooth in isolation; strung together they judder. One long swing and one
   settle, each with its own curve, is the whole shape. */
@keyframes remain-lean {
  0%   {
    transform: perspective(700px) translateY(-6px) rotate(-1.35deg);
    animation-timing-function: cubic-bezier(.45, .02, .5, 1);
  }
  /* The turn comes at 78%, and the swing stops at 0.62deg rather than 0.8deg.
     Both changes are about the settle that follows it: at 0.8deg over 324ms it
     peaked at 5.6 degrees a second, a snap at the end of an otherwise unhurried
     movement. Less to undo, 594ms to undo it in, and a curve that leaves the
     turn slowly, and it reads as coming to rest instead. */
  78%  {
    transform: perspective(700px) translateY(0.95px) rotate(0.62deg);
    animation-timing-function: cubic-bezier(.4, 0, .3, 1);
  }
  100% { transform: perspective(700px) translateY(0) rotate(0); }
}

/* The wave itself. Each letter dips towards the bottom of the box and comes
   back, and the dip passes along the line from left to right, so the text looks
   like it is settling into the space the panel has just opened for it rather
   than being placed there.

   Depth and duration come from `main.js` per letter, and both fall away along
   the line: the wave is deepest and slowest where it starts, at the left, and
   has shallowed to almost nothing by the time it runs off the end. A wave of
   even height all the way across reads as a mechanism; one that dies out reads
   as an impulse. The first letter falls far enough to come within about 9px of
   the panel's bottom edge, which is what makes it look like it is pressing on
   the boundary rather than bobbing in the middle of the gap. Each
   letter's delay comes from the same curve the wipe uses, so it starts to fall
   as the wipe uncovers it -- the wave is that reveal made visible, not a second
   effect running beside it. */
.panel__remain-letter {
  display: inline-block;
  white-space: pre;
  animation-name: remain-wave;
  animation-timing-function: cubic-bezier(.33, 0, .28, 1);
  animation-fill-mode: both;
}

@keyframes remain-wave {
  0%   { transform: translateY(0); }
  44%  { transform: translateY(var(--amp, 5px)); }
  100% { transform: translateY(0); }
}

/* Once the line has arrived, the same sweep the size line uses, at the same
   1.5s and the same easing, so the two readouts keep time with one another.
   What differs is the band and where it starts: a tenth of a 300% tile, about a
   third of the line rather than all of it, and it begins with the colour barely
   onto the first letters instead of already spread across the words. */
.panel__remain[data-state="working"] {
  animation: remain-sweep 1500ms cubic-bezier(.65, 0, .85, .35) infinite;
  background-image: linear-gradient(
    100deg,
    var(--film-ink-3) 0%,
    var(--film-ink-3) 46%,
    hsl(44 94% 74%) 48%,
    hsl(150 72% 72%) 50%,
    hsl(186 88% 74%) 52%,
    hsl(292 76% 78%) 54%,
    var(--film-ink-3) 56%,
    var(--film-ink-3) 100%
  );
  background-size: 300% 100%;
  background-repeat: repeat-x;
  -webkit-background-clip: text;
  background-clip: text;
  color: transparent;
  -webkit-text-fill-color: transparent;
}

/* 82% leaves the band's leading edge a whisker onto the first letters, which is
   where each pass begins and, because it loops, where each pass returns to. 12%
   carries it clear of the last letter. */
@keyframes remain-sweep {
  from { background-position: 82% 0; }
  to   { background-position: 12% 0; }
}

.panel__remain[data-state="working"]::after {
  content: ".";
  display: inline-block;
  width: 3.4ch;
  text-align: left;
  /* A dot every 1.2s: three even steps across 3.6s. The size line runs the same
     dots three times faster, which suits a wait measured in tenths of a second
     but hurries a line that is reporting minutes. */
  animation: remain-dots 3600ms steps(1) infinite;
}

@keyframes remain-dots {
  0%           { content: "."; }
  33.34%       { content: ".."; }
  66.67%, 100% { content: "..."; }
}

@supports not ((-webkit-background-clip: text) or (background-clip: text)) {
  .panel__remain[data-state="working"] {
    color: var(--film-ink-3);
    -webkit-text-fill-color: var(--film-ink-3);
  }
}

.panel__alert {
  margin-top: var(--s4);
  font-size: var(--t-sm);
  color: var(--film-bad);
  transition: opacity 320ms var(--ease);
}
.panel__alert[data-fading] { opacity: 0; }

/* A way forward, inside the line that says what went wrong: the text of the
   line, underlined, rather than a button that would outweigh the reason. */
.panel__alert-action {
  font: inherit;
  color: var(--film-ink);
  background: none;
  border: 0;
  padding: 0;
  text-decoration: underline;
  text-underline-offset: 0.2em;
  cursor: pointer;
}
.panel__alert-action:hover { color: var(--film-ink-2); }
.panel__alert-action:focus-visible { outline: 2px solid currentColor; outline-offset: 2px; border-radius: 2px; }

/* The same line, when what it has to report went right.

   Every room's `say()` takes a kind and has always passed `'good'` for the
   confirmations -- "Saved.", "Transcript copied.", "Saved with the subtitles
   as a track you can turn on and off." -- and nothing anywhere read it. So
   every one of those arrived in the colour this stylesheet reserves for
   failure, and the room's way of saying it had worked was indistinguishable
   from its way of saying it had not. `--film-ok` was already here, on
   `.panel__note`, doing this job for the account screens. */
.panel__alert[data-kind="good"] { color: var(--film-ok); }

/* Confirmations and instructions, as opposed to failures. */
.panel__note {
  margin-top: var(--s4);
  font-size: var(--t-sm);
  color: var(--film-ok);
}

.stackform { display: flex; flex-direction: column; gap: var(--s3); }

/* --- the legal steps: the boxes before paying, the withdrawal form ----------

   The box is the site's own: a button that says it is a checkbox. Square with
   the control radius scaled down, drawn in the room's film tokens, so it wears
   whichever room it is in. The tick is two borders of a rotated box, centred
   by construction, so it sits in the middle of its square at any size. */
.consents { display: flex; flex-direction: column; gap: var(--s4); margin-top: var(--s5); }
.consent {
  display: grid;
  grid-template-columns: var(--tick) 1fr;
  gap: var(--s3);
  align-items: start;
  --tick: 22px;
}
.tick {
  position: relative;
  width: var(--tick); height: var(--tick);
  /* The first line of the words beside it is 21px tall at --t-sm; the box
     sits on that line's middle, not its top. */
  margin-top: calc((var(--t-sm) * 1.5 - var(--tick)) / 2);
  padding: 0;
  border: var(--hairline) solid var(--film-ink-3);
  border-radius: 6px;
  background: var(--film-raise);
  cursor: pointer;
  transition: background-color var(--dur-state) var(--ease), border-color var(--dur-state) var(--ease);
}
.tick:hover { border-color: var(--film-ink); }
.tick:focus-visible { outline: 2px solid var(--film-ink); outline-offset: 2px; }
.tick[aria-checked="true"] { background: var(--knob-face); border-color: transparent; }
.tick[aria-checked="true"]::after {
  content: "";
  position: absolute;
  left: 50%; top: 50%;
  width: 6px; height: 11px;
  border: solid var(--knob-ink);
  border-width: 0 2px 2px 0;
  /* Rotated about its own middle, then nudged up by a pixel: a tick's weight
     is in its foot, and centring the box puts the stroke visibly low. */
  transform: translate(-50%, -60%) rotate(45deg);
}
.consent__text {
  font-size: var(--t-sm);
  line-height: 1.5;
  color: var(--film-ink-2);
  cursor: pointer;
}
.consent__text a { color: var(--film-ink); text-decoration: underline; text-underline-offset: 0.2em; }
.consent__text a:hover { color: var(--film-ink-2); }

/* A form under a paragraph that explains it (deleting the account) gets the
   same air the plans rail gets under its sentence. */
.panel__sub + .stackform { margin-top: var(--s5); }

.field__label { font-size: var(--t-xs); color: var(--film-ink-3); margin-top: var(--s2); }
.field__value { font-size: var(--t-sm); color: var(--film-ink); margin-top: calc(var(--s3) * -1 + var(--s1)); }

/* "Withdraw from contract here", in its own row under the plan, with the
   date it stops applying beneath it. */
.withdraw-entry { margin-top: var(--s5); }
.panel__fine { margin-top: var(--s2); font-size: var(--t-xs); color: var(--film-ink-3); }
/* A link inside the small print or an explaining sentence: the sentence's
   own colour, a step lighter, underlined -- never the browser's blue. */
.panel__fine a, .panel__sub a { color: var(--film-ink-2); text-decoration: underline; text-underline-offset: 0.2em; }
.panel__fine a:hover, .panel__sub a:hover { color: var(--film-ink); }
#auth-fine { margin-top: var(--s4); }
/* "Compare plans" beside Not now: small print in the row of buttons, so it
   reads as a way to look further rather than a third choice. */
.panel__more {
  font-size: var(--t-xs); color: var(--film-ink-3);
  text-decoration: underline; text-underline-offset: 0.2em;
  /* Height for a finger, none sideways: on a phone it wraps under the
     buttons and starts on their left edge. */
  padding: var(--s2) 0;
  transition: color var(--dur-state) var(--ease);
}
.panel__more:hover { color: var(--film-ink); }

/* Deleting the account: findable, and quiet. */
.panel__quiet { margin-top: var(--s5); }
.quietlink {
  font: inherit; font-size: var(--t-xs);
  color: var(--film-ink-3);
  background: none; border: 0; padding: 0;
  text-decoration: underline; text-underline-offset: 0.2em;
  cursor: pointer;
}
.quietlink:hover { color: var(--film-ink); }
.quietlink:focus-visible { outline: 2px solid currentColor; outline-offset: 2px; border-radius: 2px; }

/* --- the account's faces wear the room --------------------------------------

   The owner's decision of 6 October 2026: signing in, the plans, the step
   before paying and the account take the room's heading voice and its
   shapes, not only its colours. The values are the room's `--face-*` tokens
   in themes.css, under identity; the grove's are what these components
   already were, so the converter does not change.

   Scoped by the faces' own ids rather than by a class, for two reasons. The
   markup is shared (account.js), and dressing it should not need a change
   to it. And the same components elsewhere in a room -- the photo room's
   Export, the docs room's pills -- keep the shapes the owner approved there.
   The plan sheet comes too: it is dressed as the plans face is.

   Only shape, edge and type move. Every size stays the building's, so a plan
   card is the same box in every room and the price sits where it was
   measured to sit. */
:is(#face-auth, #face-reset, #face-verify, #face-plans, #face-delete, #face-account, #face-settings, #face-feedback, .plan-sheet) .panel__title {
  font-family: var(--face-title-font);
  font-weight: var(--face-title-weight);
  font-size: var(--face-title-size);
  text-transform: var(--face-title-case);
  letter-spacing: var(--face-title-track);
}
:is(#face-auth, #face-reset, #face-verify, #face-plans, #face-delete, #face-account, #face-settings, #face-feedback, .plan-sheet) .panel__head .panel__title { margin-top: var(--face-title-drop); text-wrap: var(--face-title-wrap); }
:is(#face-auth, #face-reset, #face-verify, #face-plans, #face-delete, #face-account, #face-settings, #face-feedback, .plan-sheet) :is(.action, .ghost, .back, .cogbtn, .pill) {
  border-radius: var(--face-radius);
  clip-path: var(--face-clip);
  --cut: var(--face-cut);
}
/* The text fields: the room's bar already (`--rest-bar-radius`), cut where
   the room cuts. A box of several lines stays capped at 24px. */
:is(#face-auth, #face-reset, #face-verify, #face-plans, #face-delete, #face-account, #face-settings, #face-feedback, .plan-sheet) .bar--field { border-radius: var(--face-field-radius); clip-path: var(--face-clip); }
:is(#face-auth, #face-reset, #face-verify, #face-plans, #face-delete, #face-account, #face-settings, #face-feedback, .plan-sheet) .bar--area { border-radius: min(var(--face-field-radius), 24px); }
/* The box of collected details under a bug report: a card, so the card's
   corner -- but capped at the 12px it was drawn with, because a tall box at
   a capsule's radius is a stadium, not a box. */
:is(#face-auth, #face-reset, #face-verify, #face-plans, #face-delete, #face-account, #face-settings, #face-feedback, .plan-sheet) .feedback-details__body {
  border-radius: min(var(--face-card-radius), 12px);
  clip-path: var(--face-card-clip);
  border-color: var(--face-line);
  box-shadow: var(--face-glow);
  --cut: var(--face-card-cut);
  --cut-ink: var(--face-line);
}
:is(#face-auth, #face-reset, #face-verify, #face-plans, #face-delete, #face-account, #face-settings, #face-feedback, .plan-sheet) .pick {
  border-radius: var(--face-card-radius);
  clip-path: var(--face-card-clip);
  --cut: var(--face-card-cut);
}

/* The quiet controls' edge and face, at rest and under the pointer. The
   chosen plan keeps the knob's face, which is the building's. */
:is(#face-auth, #face-reset, #face-verify, #face-plans, #face-delete, #face-account, #face-settings, #face-feedback, .plan-sheet) :is(.ghost, .back, .cogbtn, .pick:not([aria-checked="true"])) {
  border-color: var(--face-line);
  background-color: var(--face-fill);
  box-shadow: var(--face-glow);
}
:is(#face-auth, #face-reset, #face-verify, #face-plans, #face-delete, #face-account, #face-settings, #face-feedback, .plan-sheet) :is(.ghost, .back, .pick:not([aria-checked="true"])):hover {
  background-color: var(--face-fill-hover);
  box-shadow: var(--face-glow-hover);
}
:is(#face-auth, #face-reset, #face-verify, #face-plans, #face-delete, #face-account, #face-settings, #face-feedback, .plan-sheet) :is(.back, .cogbtn):hover {
  border-color: var(--face-line-hover);
}
:is(#face-auth, #face-reset, #face-verify, #face-plans, #face-delete, #face-account, #face-settings, #face-feedback, .plan-sheet) :is(.ghost, .back, .pick:not([aria-checked="true"])):active {
  box-shadow: var(--face-glow);
}

/* A cut corner, and its edge. A clip-path takes the border off the diagonal
   with the corner, and takes the focus ring with it, so both are drawn back
   here: a band parallel to each cut, inside it, in two background tiles at
   the corners the chamfer knocks off. `--cut-a` and `--cut-b` are how far
   inside the cut the band starts and stops, in the same terms as the border
   and an inset shadow along the straight sides (which starts inside the
   border, hence the pixel added for a bordered control), so the two meet.
   Where a room cuts nothing (`--cut` is 0) the tiles are 0 wide and draw
   nothing. */
:is(#face-auth, #face-reset, #face-verify, #face-plans, #face-delete, #face-account, #face-settings, #face-feedback, .plan-sheet) :is(.action, .ghost, .back, .cogbtn, .pill, .pick, .feedback-details__body),
#account-btn,
:root[data-room] .page :is(.page-action, .page-ghost, .page-pill, .page-input) {
  --cut-ink: var(--face-line);
  --cut-a: 0px;
  --cut-b: 1px;
  --cut-tile: calc(var(--cut) * 3);
  background-image:
    linear-gradient(135deg,
      transparent calc(var(--cut) * 0.7071 + var(--cut-a)),
      var(--cut-ink) calc(var(--cut) * 0.7071 + var(--cut-a)) calc(var(--cut) * 0.7071 + var(--cut-b)),
      transparent calc(var(--cut) * 0.7071 + var(--cut-b))),
    linear-gradient(135deg,
      transparent calc((var(--cut-tile) * 2 - var(--cut)) * 0.7071 - var(--cut-b)),
      var(--cut-ink) calc((var(--cut-tile) * 2 - var(--cut)) * 0.7071 - var(--cut-b)) calc((var(--cut-tile) * 2 - var(--cut)) * 0.7071 - var(--cut-a)),
      transparent calc((var(--cut-tile) * 2 - var(--cut)) * 0.7071 - var(--cut-a)));
  background-position: 0 0, 100% 100%;
  background-size: var(--cut-tile) var(--cut-tile);
  background-repeat: no-repeat;
}
:is(#face-auth, #face-reset, #face-verify, #face-plans, #face-delete, #face-account, #face-settings, #face-feedback, .plan-sheet) :is(.action, .pick[aria-checked="true"], .pill[aria-checked="true"]) { --cut-ink: transparent; }
/* Focus, inside the cut: a ring in the room's signal along the sides and
   the band along the diagonals. On the pale primary it is the room's shade,
   a step in from the edge, because brass on white is not a ring. `--face-ring`
   is 0 where nothing is cut, and the building's own outline does the job. */
:is(#face-auth, #face-reset, #face-verify, #face-plans, #face-delete, #face-account, #face-settings, #face-feedback, .plan-sheet) :is(.ghost, .back, .cogbtn, .pill, .pick):focus-visible {
  --cut-ink: var(--signal);
  --cut-a: 1px;
  --cut-b: calc(var(--face-ring) + 1px);
  box-shadow: inset 0 0 0 var(--face-ring) var(--signal), var(--face-glow);
}
:is(#face-auth, #face-reset, #face-verify, #face-plans, #face-delete, #face-account, #face-settings, #face-feedback, .plan-sheet) :is(.action, .pick[aria-checked="true"], .pill[aria-checked="true"]):focus-visible {
  --cut-ink: rgb(var(--shade-rgb));
  --cut-a: var(--face-ring);
  --cut-b: calc(var(--face-ring) * 2);
  box-shadow: inset 0 0 0 var(--face-ring) var(--knob-face), inset 0 0 0 calc(var(--face-ring) * 2) rgb(var(--shade-rgb));
}

/* ==========================================================================
   Footer and plain pages
   ========================================================================== */

.footer {
  padding: var(--s5) clamp(var(--s4), 4vw, var(--s7));
  font-size: var(--t-xs); color: var(--film-ink-3); text-align: center;
}
.footer a { color: var(--film-ink-2); text-underline-offset: 3px; }
/* The contract link stands a little apart from the rest, as the law asks of a
   cancellation button: the room's own ink, at full strength. */
.footer .footer__contract { color: var(--film-ink); font-weight: 600; }

.page { background: var(--ground); color: var(--ink); min-height: 100svh; padding: var(--s8) clamp(var(--s4), 6vw, var(--s9)); }
.page__inner { max-width: 62ch; }
.page h1 { font-family: var(--font-display); font-weight: 800; font-size: var(--t-2xl); line-height: var(--lh-tight); letter-spacing: -0.03em; margin-bottom: var(--s5); }
.page h2 { font-size: var(--t-md); font-weight: 600; margin: var(--s7) 0 var(--s3); }
.page p, .page li { color: var(--ink-2); margin-bottom: var(--s3); }
.page a { color: var(--ink); text-underline-offset: 3px; }
.page code { font-family: var(--font-mono); font-size: 0.9em; }
.page .mono { font-family: var(--font-mono); font-size: 0.9em; overflow-wrap: anywhere; } /* a long file path breaks rather than pushing a phone page sideways */
.page .muted { color: var(--ink-3); }

/* --- the legal pages ---------------------------------------------------------
   Terms, legal notice, withdrawal and privacy, in the same plain style as the
   licences page. Until a lawyer has read them, every point still open is
   shown as it is in the draft -- a bracketed placeholder or a [CONFIRM] --
   marked so it cannot be mistaken for settled text. */
.page .draft-note {
  margin-bottom: var(--s6);
  padding: var(--s3) var(--s4);
  border-left: 2px solid var(--ink-3);
  background: var(--ground-alt);
  color: var(--ink-2);
  font-size: var(--t-sm);
}
.page .todo {
  font-family: var(--font-mono);
  font-size: 0.85em;
  padding: 0 0.3em;
  border-radius: 3px;
  background: var(--ground-alt);
  color: var(--ink);
  white-space: normal;
}
.page h3 { font-size: var(--t-base, 1rem); font-weight: 600; color: var(--ink); margin: var(--s5) 0 var(--s2); }
.page table { width: 100%; border-collapse: collapse; margin: var(--s3) 0 var(--s4); font-size: var(--t-sm); }
.page th, .page td { text-align: left; vertical-align: top; padding: var(--s2) var(--s3) var(--s2) 0; border-bottom: var(--hairline) solid var(--rule); color: var(--ink-2); }
.page th { color: var(--ink); font-weight: 600; }
/* A wide table scrolls inside itself on a phone rather than pushing the page
   sideways. */
.page .table-wrap { overflow-x: auto; }
.page .address { white-space: pre-line; color: var(--ink); }
.page ol { padding-left: 1.4em; }
.page ol li { color: var(--ink-2); margin-bottom: var(--s2); }
.page .bullets li { padding: var(--s1) 0 var(--s1) 1em; border-bottom: 0; position: relative; }
.page .bullets li::before { content: "–"; position: absolute; left: 0; color: var(--ink-3); }
.page .form-text { font-family: var(--font-mono); font-size: 0.85em; color: var(--ink-2); }

/* The one action on the withdrawal page, drawn like the site's buttons. */
.page .page-action {
  display: inline-flex; align-items: center;
  min-height: 44px; padding: 0 var(--s5);
  border-radius: var(--radius-control);
  background: var(--ink); color: var(--ground);
  font-weight: 600; text-decoration: none;
}
.page .page-action:hover { background: var(--ink-2); }
.page .page-action { font: inherit; font-weight: 600; border: 0; cursor: pointer; justify-content: center; }
.page .page-action:disabled { opacity: 0.55; cursor: progress; }
.page .page-action:focus-visible, .page .page-ghost:focus-visible, .page .page-pill:focus-visible, .page .page-input:focus-visible {
  outline: 2px solid var(--ink); outline-offset: 2px;
}
/* The quiet second choice beside it: an outline, not a fill. */
.page .page-ghost {
  display: inline-flex; align-items: center; justify-content: center;
  min-height: 44px; padding: 0 var(--s5);
  border: var(--hairline) solid var(--ink-3); border-radius: var(--radius-control);
  background: none; color: var(--ink);
  font: inherit; font-weight: 600; cursor: pointer;
}
.page .page-ghost:hover { border-color: var(--ink); }
.page a.page-ghost { text-decoration: none; }

/* --- forms on the plain pages (the contract page, the checkout) ---------------
   The site's own fields, drawn from the page's ink and ground: a hairline box
   that darkens when it has the focus, never the browser's default. */
.page .page-field { margin: 0 0 var(--s4); }
.page .page-field__label { display: block; margin: 0 0 var(--s1); font-size: var(--t-sm); font-weight: 600; color: var(--ink); }
.page .page-field__hint, .page .page-field__value { margin: var(--s1) 0 0; font-size: var(--t-sm); color: var(--ink-2); }
.page .page-input {
  display: block; width: 100%; box-sizing: border-box;
  min-height: 44px; padding: 0 var(--s3);
  border: var(--hairline) solid var(--ink-3); border-radius: var(--radius-control);
  background: var(--ground); color: var(--ink);
  font: inherit; font-size: 16px; /* 16px keeps a phone from zooming into the field */
  appearance: none; -webkit-appearance: none;
}
.page .page-input:focus { border-color: var(--ink); outline: none; }
/* No grip: the box grows with its words up to about ten lines, then scrolls
   inside itself (js/ui/grow.js where CSS cannot size it alone). */
.page .page-input--area { border-radius: 22px; min-height: 6em; max-height: 16em; padding: var(--s2) var(--s3); line-height: 1.5; resize: none; overflow-y: auto; field-sizing: content; }
.page .page-pills { display: flex; flex-wrap: wrap; gap: var(--s2); }
.page .page-pill {
  min-height: 44px; padding: 0 var(--s4);
  border: var(--hairline) solid var(--ink-3); border-radius: 999px;
  background: none; color: var(--ink-2); font: inherit; font-size: var(--t-sm); cursor: pointer;
}
.page .page-pill[aria-checked="true"] { background: var(--ink); border-color: var(--ink); color: var(--ground); }
.page .page-alert { margin: var(--s3) 0 0; color: var(--ink); font-weight: 600; }
.page .page-alert--quiet { font-weight: 400; padding: var(--s3) var(--s4); background: var(--ground-alt); border-left: 2px solid var(--ink-3); }

/* The contract page: the two choices one under the other, each with its own
   button (side by side, the statutory labels broke over two lines), then the
   one confirmation step. */
.page .contract-who { color: var(--ink); }
.page .contract-choices { display: grid; grid-template-columns: 1fr; gap: 0; margin: var(--s5) 0 var(--s6); border-top: var(--hairline) solid var(--rule); }
.page .contract-choice { padding: var(--s5) 0; border-bottom: var(--hairline) solid var(--rule); display: flex; flex-direction: column; align-items: flex-start; }
.page .contract-choice h2 { margin-top: 0; }
.page .contract-choice .page-action { margin-top: var(--s2); white-space: nowrap; }
.page .contract-note { color: var(--ink); font-size: var(--t-sm); }
.page .contract-form, .page .contract-done { margin: 0 0 var(--s6); padding: var(--s5) 0; border-bottom: var(--hairline) solid var(--rule); }
.page .contract-form h2, .page .contract-done h2 { margin-top: 0; }
.page .contract-form h2:focus, .page .contract-done h2:focus { outline: none; }
.page .contract-go { display: flex; flex-wrap: wrap; gap: var(--s3); margin-top: var(--s5); }
.page .contract-record { max-height: none; }

/* The checkout page: the boxes are the site's own (a button that says it is
   a checkbox, never pre-ticked), the summary sits directly above the order
   button, in the page's ink, and Stripe's frame has a quiet rule round it. */
.page .checkout-plan { color: var(--ink); font-weight: 600; }
.page .checkout-plan a { margin-left: var(--s2); font-weight: 400; font-size: var(--t-sm); }
.page .checkout-element { padding: var(--s4) 0; min-height: 120px; }
.page .page-consents { margin: var(--s6) 0 var(--s4); display: grid; gap: var(--s3); }
.page .page-consent { display: grid; grid-template-columns: 24px 1fr; gap: var(--s3); align-items: start; }
.page .page-consent__text { margin: 0; color: var(--ink); cursor: pointer; }
.page .page-tick {
  width: 24px; height: 24px; margin-top: 1px; padding: 0;
  border: 1.5px solid var(--ink-2); border-radius: 6px;
  background: var(--ground); cursor: pointer;
  display: grid; place-items: center;
}
.page .page-tick[aria-checked="true"] { background: var(--ink); border-color: var(--ink); }
.page .page-tick[aria-checked="true"]::after {
  content: ""; width: 6px; height: 11px; margin-top: -2px;
  border: solid var(--ground); border-width: 0 2px 2px 0; transform: rotate(45deg);
}
.page .page-tick:focus-visible { outline: 2px solid var(--ink); outline-offset: 2px; }
.page .checkout-summary { margin: var(--s5) 0 0; padding: var(--s4) 0 0; border-top: var(--hairline) solid var(--rule); }
.page .checkout-summary p { margin: 0 0 var(--s2); color: var(--ink); font-size: var(--t-sm); }
.page .checkout-summary p:first-child { font-size: var(--t-base, 1rem); font-weight: 600; }
.page .checkout-order { white-space: nowrap; }

/* Why a plan ended, after the fact: the reasons in two columns where there is
   room, under a hairline that keeps them apart from the confirmation. */
.page .contract-why { margin-top: var(--s6); padding-top: var(--s5); border-top: var(--hairline) solid var(--rule); }
.page .page-reasons { display: grid; grid-template-columns: 1fr; gap: var(--s3) var(--s5); margin-bottom: var(--s4); }
@media (min-width: 560px) { .page .page-reasons { grid-template-columns: 1fr 1fr; } }

/* "Improvements or a bug", in the account's settings. */
.feedback-kinds { display: flex; flex-wrap: wrap; gap: var(--s2); }
/* A box of several lines keeps the room's corner, but never a capsule's: a
   999px radius on a tall box draws a stadium, not a field. Capped at the
   corner a one-line capsule has. */
.bar--area { align-items: stretch; padding-top: var(--s1); padding-bottom: var(--s1); border-radius: min(var(--rest-bar-radius), 24px); }
/* No grip, as on the contract page and the Slides notes: the box grows with
   its words up to about twelve lines, then scrolls inside itself. It starts
   at the five lines it always had, padding included: sized by its content,
   it no longer takes that from `rows`. */
.bar--area .bar__input { resize: none; min-height: calc(7.5em + 2 * var(--s3)); max-height: 18em; overflow-y: auto; field-sizing: content; line-height: 1.5; font-family: inherit; }
.feedback-details__body {
  margin: var(--s2) 0 0; padding: var(--s3);
  max-height: 14em; overflow: auto;
  border: var(--hairline) solid var(--film-rule); border-radius: 12px;
  background: var(--film-raise); color: var(--film-ink-2);
  font-family: var(--font-mono); font-size: var(--t-xs); line-height: 1.5;
  white-space: pre-wrap; word-break: break-word;
}
.feedback-details__body[hidden] { display: none; }

/* The chip in the top bar that opens the account wears the same shapes as
   the faces it opens (the owner, 6 October 2026): the valley's chamfer, the
   archive's 3px, the canyon's 10px, the fog's frost; the grove's capsule as
   it was. Same size, same words, same place: only its corners, its edge and
   its face move. The cut's hairline and focus ring are drawn by the rule
   above. The allowance beside it is untouched. */
#account-btn {
  border-radius: var(--face-radius);
  clip-path: var(--face-clip);
  border-color: var(--face-line);
  background-color: var(--face-chip-fill);
  box-shadow: var(--face-glow);
  --cut: var(--face-cut);
}
#account-btn:hover {
  background-color: var(--face-chip-fill-hover);
  box-shadow: var(--face-glow-hover);
}
#account-btn:active { box-shadow: var(--face-glow); }
#account-btn:focus-visible {
  --cut-ink: var(--signal);
  --cut-a: 1px;
  --cut-b: calc(var(--face-ring) + 1px);
  box-shadow: inset 0 0 0 var(--face-ring) var(--signal), var(--face-glow);
}

/* The checkout and the contract page wear the shapes of the room that sent
   somebody there, and its heading voice -- not its palette: they stay paper,
   as every page of terms does. `js/page-room.js` names the room on the root
   before the first paint; with no room named (a link from outside, or the
   converter) nothing below applies and the page is the building's. The cut
   corner, its edge and its focus ring are drawn by the same rule as the
   account's faces, above, in the page's ink. */
:root[data-room] .page h1 {
  font-family: var(--face-title-font);
  font-weight: var(--face-title-weight);
  text-transform: var(--face-title-case);
  letter-spacing: var(--face-title-track);
}
:root[data-room] .page :is(.page-action, .page-ghost, .page-pill, .page-input) {
  border-radius: var(--face-radius);
  clip-path: var(--face-clip);
  --cut: var(--face-cut);
  --cut-ink: var(--ink-3);
}
:root[data-room] .page .page-action { --cut-ink: transparent; }
/* A box of several lines keeps the room's corner, capped at the 22px it was
   drawn with, so the fog's capsule never turns it into a stadium. */
:root[data-room] .page .page-input--area { border-radius: min(var(--face-radius), 22px); }
:root[data-room] .page .page-input:focus { --cut-ink: var(--ink); }
:root[data-room] .page .page-tick { border-radius: var(--face-box-radius); }
:root[data-room] .page :is(.page-ghost, .page-pill, .page-input):focus-visible {
  --cut-ink: var(--ink);
  --cut-a: 1px;
  --cut-b: calc(var(--face-ring) + 1px);
  box-shadow: inset 0 0 0 var(--face-ring) var(--ink);
}
:root[data-room] .page .page-action:focus-visible {
  --cut-ink: var(--ground);
  --cut-a: var(--face-ring);
  --cut-b: calc(var(--face-ring) * 2);
  box-shadow: inset 0 0 0 var(--face-ring) var(--ink), inset 0 0 0 calc(var(--face-ring) * 2) var(--ground);
}

/* The links every legal page ends with, the same five as the site's footer. */
.page .page-links { margin-top: var(--s8); padding-top: var(--s4); border-top: var(--hairline) solid var(--rule); font-size: var(--t-xs); color: var(--ink-3); }
.page .page-links a { color: var(--ink-2); }

/* The licences page. A list of credits is a list, not a table of data: each
   entry is a name, who wrote it and under what terms, separated by the same
   hairline rules the rest of the site uses instead of boxes. */
.page ul { list-style: none; padding: 0; }
.page ul li {
  padding: var(--s3) 0;
  border-bottom: var(--hairline) solid var(--rule);
  line-height: var(--lh-body, 1.55);
}
.page ul li:last-child { border-bottom: none; }
.page ul li strong { color: var(--ink); }

/* The licence texts themselves, folded away so the page can be read. They are
   not decoration -- they are the notice -- so they are on the page in full
   rather than linked to somebody else's server. */
.page details {
  border-top: var(--hairline) solid var(--rule);
  padding: var(--s3) 0;
}
.page summary {
  cursor: pointer;
  color: var(--ink);
  font-weight: 600;
}
.page pre {
  margin-top: var(--s3);
  padding: var(--s3);
  overflow-x: auto;
  max-height: 24em;
  overflow-y: auto;
  background: var(--ground-alt, rgba(0, 0, 0, 0.04));
  border-radius: 6px;
  font-family: var(--font-mono);
  font-size: 0.78em;
  line-height: 1.5;
  color: var(--ink-2);
  white-space: pre-wrap;
  word-break: break-word;
}

/* ==========================================================================
   Reduced motion: keep the layout, drop the theatre
   ========================================================================== */

@media (prefers-reduced-motion: reduce) {
  *, *::before, *::after {
    animation-duration: 1ms !important;
    animation-iteration-count: 1 !important;
    transition-duration: 1ms !important;
  }
  .bar__type::after { animation: none; opacity: 1; }
  /* The whole sentence is put down at once here, so on a bar too narrow for
     it the cut gets an ellipsis rather than stopping mid-word. Typed, the
     line follows the caret instead (`typing.js`). */
  .bar__type { text-overflow: ellipsis; }
  /* No lifting, no scaling, no refraction: a plain background change instead. */
  .action:hover:not(:disabled), .ghost:hover, .chip:hover, .back:hover,
  .rail button:hover, .knob:hover, .knob:hover svg {
    transform: none !important;
    box-shadow: none !important;
  }
  [data-lit]::before, [data-lit]::after { display: none; }
  /* The strike is information, not decoration, so it survives reduced motion. */
  .rail button[data-unsupported]::after { display: block; }
  .rail button.is-refusing { animation: none; }
  [data-tilt] { transform: none !important; }
  .panel__est[data-state="working"] {
    color: var(--film-ink-3);
    -webkit-text-fill-color: var(--film-ink-3);
    animation: none;
    background-image: none;
  }
  /* The dots are the only motion kept: they say "still working" without
     implying progress, and they replace the sweep rather than joining it. */
  .panel__est[data-state="working"]::after { content: "..."; animation: none; }
  .panel__est[data-phase="out"], .panel__est[data-phase="in"] { animation: none; }
  .panel__remain-letter { animation: none; transform: none; }
  .panel__remain, .panel__remain[data-phase="arriving"], .panel__remain[data-state="working"] {
    animation: none;
    max-height: none;
    transform: none;
    clip-path: none;
    opacity: 1;
    /* Without the sweep running, transparent fill over a static gradient is a
       coin toss on which stop lands under the text. Paint it plainly. */
    color: var(--film-ink-3);
    -webkit-text-fill-color: var(--film-ink-3);
    background-image: none;
  }
  .panel__remain[data-state="working"]::after { content: "..."; animation: none; }
  .shell.is-morphing .shell__refract,
  .shell.is-morphing .shell__refract::after { animation: none; opacity: 0; }
}

/* ==========================================================================
   The dial -- how you move between rooms. PLATFORM.md §6.
   ========================================================================== */

.dial {
  position: relative;
  width: 40px;
  height: 40px;
  flex: 0 0 auto;
}

/* The column hangs from the right-hand edge, not the left.

   This is the whole reason the names used to run off the screen: the dial is
   the last thing on the top bar, so anything that grows rightward grows past
   the window. The buttons are right-aligned with the face above them and open
   towards the middle of the page, where there is room. */
.dial__column {
  position: absolute;
  top: 0;
  right: 0;
  display: flex;
  flex-direction: column;
  align-items: flex-end;
  gap: var(--s3);
  padding-top: 48px;
  opacity: 0;
  pointer-events: none;
  transition: opacity var(--dur-face) var(--ease);
}
.dial[data-open="true"] .dial__column { opacity: 1; pointer-events: auto; }

/* The dial's buttons carry their own room, not the one you are standing in.

   Every one of these read `--film-shell`, `--film-rule` and `--radius-control`
   -- which are whichever room you happen to be in -- so the column was three
   buttons in one room's clothes wearing three different accent colours. In
   the fog, which is a light room, every other room's button turned pale too.
   That is precisely backwards: the column is a list of places you could go,
   and each entry should look like the place it goes to.

   So each button takes its own room's surface, edge and silhouette from
   `themes.css`, and falls back to the room you are in for any room that has
   not said. The face -- the button you press to open the column -- is the
   exception and keeps following the current room, because it is not a
   destination: it is where you are. */
.dial__face,
.dial__room {
  display: flex;
  align-items: center;
  justify-content: center;
  min-width: 40px;
  height: 40px;
  padding: 0 9px;
  border-radius: var(--dial-radius, var(--radius-control));
  border: var(--hairline) solid var(--dial-rule, var(--film-rule));
  /* Colour and image, not `background`.

     The hover state used to set `background`, which replaces the surface
     rather than covering it -- so a room whose button is a near-opaque pale
     disc had it swapped for a nineteen-per-cent wash the moment the pointer
     arrived. The button all but vanished, and its dark type was left standing
     on whatever film was behind. Separating the two means hover puts a
     highlight *over* the room's own surface, which is what a highlight is. */
  background-color: var(--dial-face, var(--film-shell));
  /* A room may put its own film behind its button -- see `--dial-film` in
     `themes.css`. What shows through the glass is then that room rather than
     whichever room you happen to be standing in, which is the difference
     between a button that is a colour and a button that is a window. */
  background-image: var(--dial-film, none);
  background-size: cover;
  background-position: center;
  backdrop-filter: blur(var(--blur-shell));
  -webkit-backdrop-filter: blur(var(--blur-shell));
  color: var(--accent, var(--signal));
  cursor: pointer;
  text-decoration: none;
  transition: background var(--dur-state) var(--ease),
              box-shadow var(--dur-state) var(--ease),
              opacity var(--dur-face) var(--ease);
}

.dial__face { position: relative; z-index: 2; width: 40px; padding: 0; }

/* The button *becomes* the name rather than putting a second slab beside it.
   The mark stays where it was -- the column is right-aligned, so the pill can
   only grow leftward -- and the name opens out from underneath it. */
.dial__room {
  position: relative;
  flex-direction: row-reverse;
  justify-content: flex-start;
  opacity: 0;
  transform: translateY(-14px);
  transition: opacity var(--dur-face) var(--ease),
              transform var(--dur-face) var(--ease-soft),
              background var(--dur-state) var(--ease),
              box-shadow var(--dur-state) var(--ease);
}
.dial[data-open="true"] .dial__room { opacity: 1; transform: translateY(0); }
.dial[data-open="true"] .dial__room:nth-child(1) { transition-delay: 0ms; }
.dial[data-open="true"] .dial__room:nth-child(2) { transition-delay: 40ms; }
.dial[data-open="true"] .dial__room:nth-child(3) { transition-delay: 80ms; }
.dial[data-open="true"] .dial__room:nth-child(4) { transition-delay: 120ms; }

/* And each button reacts the way its own room does.

   The lift was `--lift-1`, which is whichever room you are standing in -- so
   in a room that does not lift, nothing in the column lifted, and in one that
   does, all of them did. A room's reaction is half its personality and the
   column is the one place all of them are on screen at once, so this is where
   the difference is worth the most: the grove rises, the canyon opens its
   chromatic split, the fog thickens instead of moving. */
.dial__face, .dial__room { box-shadow: var(--dial-edge, none); }
.dial__face:hover, .dial__room:hover {
  /* The highlight goes on top of the film, not instead of it. */
  background-image:
    linear-gradient(var(--dial-hover, rgba(255, 255, 255, 0.14)) 0 100%),
    var(--dial-film, none);
  box-shadow: var(--dial-lift, var(--lift-1));
}

/* --- what each button does while you are looking at it ----------------------

   A ring in the room's colour tells two buttons apart at a glance. It does
   not say what the room is *like*, and the column is the one place in the
   building where every room is on screen at once -- so it is the one place
   worth spending a little motion.

   Each of these is the room's own idiom, not a decoration invented for the
   dial: the canyon's light splitting through stone, the fog thickening.
   Both stop dead under `prefers-reduced-motion`, and both are drawn on a
   pseudo-element inside the button's own rounded box, so nothing escapes the
   silhouette that makes the button that room's. */
.dial__room::after,
.dial__face::after {
  content: "";
  position: absolute;
  inset: 0;
  border-radius: inherit;
  pointer-events: none;
  opacity: 0;
  transition: opacity var(--dur-state) var(--ease);
}
.dial__room, .dial__face { overflow: hidden; }

/* The canyon: light through crystal, turning rather than travelling. The hues
   are the room's own -- the warm centre and the cool edge of its split -- and
   they turn slowly enough to read as light moving rather than as a spinner. */
[data-room="photo"] .dial__face::after,
.dial__room[data-room="photo"]::after {
  background: conic-gradient(from var(--refract-angle),
    transparent 0deg,
    rgba(216, 162, 60, 0.30) 40deg,
    rgba(150, 190, 220, 0.26) 90deg,
    transparent 150deg,
    transparent 210deg,
    rgba(216, 162, 60, 0.26) 260deg,
    rgba(150, 190, 220, 0.22) 310deg,
    transparent 360deg);
  animation: auto-refract 11s linear infinite;
  opacity: 0.85;
}
[data-room="photo"] .dial__face:hover::after,
.dial__room[data-room="photo"]:hover::after { opacity: 1; animation-duration: 5s; }

/* The fog's button is in `themes.css`, beside its `.dial__room` block: a
   frosted lens, which is all one object and is kept in one place. */

@keyframes dial-drift {
  from { background-position: 150% 0; }
  to   { background-position: -150% 0; }
}

/* The grove: water, and the light crossing it.

   Its button is a bead of the grove's water with two leaves holding it (in
   `themes.css`, beside its `.dial__room` block). Water cannot rely on a
   surface for contrast, so the mark and the name carry a halo, exactly as
   the title and the eyebrow do where they sit over the film, and the same
   slow light crosses it as crosses the island. */
[data-room="convert"] .dial__face::after,
.dial__room[data-room="convert"]::after {
  background: linear-gradient(100deg,
    transparent 30%,
    var(--dial-sheen, rgba(255, 255, 255, 0.17)) 48%,
    transparent 66%);
  background-size: 300% 100%;
  animation: dial-drift 8s var(--ease-soft) infinite;
  opacity: 1;
}
[data-room="convert"] .dial__face:hover::after,
.dial__room[data-room="convert"]:hover::after { animation-duration: 4.5s; }

/* The valley's window: cut, rimmed in brass, and lit as you go past.

   Two corners knocked off, the leading top and the trailing bottom, as every
   cut control in the room has them, scaled to forty pixels. The rim is a
   one-pixel ring cut to the same chamfer (an even-odd polygon: the outline
   and the same outline a pixel in), brightest on the leading edge where the
   side light catches it. On hover, one short streak of the window light runs
   backwards across the glass -- once, quickly, the way a light at the
   trackside passes a carriage window -- and the mark's own light trails six
   pixels behind it. */
[data-room="video"] .dial__face,
.dial__room[data-room="video"] {
  clip-path: polygon(9px 0, 100% 0, 100% calc(100% - 9px), calc(100% - 9px) 100%, 0 100%, 0 9px);
}
[data-room="video"] .dial__face::before,
.dial__room[data-room="video"]::before {
  content: "";
  position: absolute;
  inset: 0;
  z-index: 0;
  pointer-events: none;
  background: linear-gradient(90deg, rgba(117, 97, 54, 0.30) 0%, rgba(178, 160, 119, 0.55) 50%, rgba(242, 232, 204, 0.95) 100%);
  clip-path: polygon(evenodd,
    9px 0, 100% 0, 100% calc(100% - 9px), calc(100% - 9px) 100%, 0 100%, 0 9px, 9px 0,
    9.41px 1px, 1px 9.41px, 1px calc(100% - 1px), calc(100% - 9.41px) calc(100% - 1px), calc(100% - 1px) calc(100% - 9.41px), calc(100% - 1px) 1px, 9.41px 1px);
  transition: opacity var(--dur-state) var(--ease);
}
[data-room="video"] .dial__face::after,
.dial__room[data-room="video"]::after {
  background: linear-gradient(100deg, transparent 38%, rgba(237, 225, 197, 0.08) 46%, rgba(255, 248, 228, 0.36) 52%, rgba(178, 160, 119, 0.14) 60%, transparent 72%);
  background-size: 320% 100%;
  background-position: 120% 0;
  opacity: 1;
}
[data-room="video"] .dial__face:hover::after,
.dial__room[data-room="video"]:hover::after,
.dial__room[data-room="video"]:focus-visible::after { animation: dial-pass 620ms var(--ease) 1 both; }
@keyframes dial-pass {
  from { background-position: 120% 0; }
  to   { background-position: -20% 0; }
}
[data-room="video"] .dial__face > svg,
.dial__room[data-room="video"] > svg {
  filter: drop-shadow(0 0 1px rgba(8, 6, 4, 0.85));
  transition: filter var(--dur-state) var(--ease);
}
[data-room="video"] .dial__face:hover > svg,
.dial__room[data-room="video"]:hover > svg {
  filter: drop-shadow(0 0 1px rgba(8, 6, 4, 0.85)) drop-shadow(-4px 0 3px rgba(237, 225, 197, 0.45));
}

/* A mark standing on glass rather than on a surface. */
.dial__room[data-dial-halo] > svg,
.dial__face[data-dial-halo] > svg { filter: var(--dial-halo); }
.dial__room[data-dial-halo] .dial__name > span,
.dial__face[data-dial-halo] .dial__name > span {
  /* Not `--shade-rgb`, which is the room you are standing in -- and in a light
     room that is a pale halo behind pale type, which is no halo.

     Tight before soft. A glass button has no surface of its own to hold its
     contrast, so what carries the type is a dark edge hugging the letters,
     with a wider glow behind it to seat them. One soft shadow alone was
     enough over the grove's own dark film and not nearly enough over the
     fog, which is the case a transparent button has to survive. */
  text-shadow:
    0 0 1px rgba(6, 9, 12, 0.95),
    0 0 2px rgba(6, 9, 12, 0.9),
    0 1px 3px rgba(6, 9, 12, 0.85),
    0 0 12px rgba(6, 9, 12, 0.7);
}

/* The grove's mark is made of plant.

   The canyon's is crystal, so these two should not both be "a stroke in the
   room's colour": what a mark is *made of* is the cheapest way to say what a
   room is at a size where nothing else fits. The grove's colour runs along
   the stroke itself -- bark at the tail, leaf through the body, low sun on
   the tip -- and the rest is here. */
[data-room="convert"] .dial__face > svg,
.dial__room[data-room="convert"] > svg {
  /* The colour is in the stroke now -- see `marks.js` -- so what is left here
     is the dark edge that lets it stand on glass, and a breath of green
     around it for the damp. */
  filter:
    drop-shadow(0 0 1px rgba(6, 9, 12, 0.95))
    drop-shadow(0 1px 4px rgba(6, 9, 12, 0.8))
    drop-shadow(0 0 6px rgba(126, 148, 16, 0.45));
}

/* The canyon's mark is cut from the same crystal the room is.

   A single stroke in one colour is a drawing of a thing; the canyon's whole
   idea is what happens to light on the way through something. So the stroke
   is near-white and its two edges are the room's own split -- warm one side,
   cool the other -- which is the same effect the panel edges carry, at the
   size of a glyph. */
[data-room="photo"] .dial__face > svg,
.dial__room[data-room="photo"] > svg {
  color: #F2F6FA;
  filter:
    drop-shadow(1.1px 0 0 rgba(216, 162, 60, 0.85))
    drop-shadow(-1.1px 0 0 rgba(132, 186, 224, 0.8))
    drop-shadow(0 0 3px rgba(216, 162, 60, 0.35));
}

/* The mark sits above the weather. */
.dial__room > svg, .dial__face > svg, .dial__name { position: relative; z-index: 1; }

@media (prefers-reduced-motion: reduce) {
  .dial__room::after, .dial__face::after { animation: none; }
}
.dial__face:focus-visible, .dial__room:focus-visible { outline: 2px solid var(--dial-ink, var(--film-ink)); outline-offset: 3px; }

.mark { width: 20px; height: 20px; display: block; flex: 0 0 auto; }

/* Opens from nothing to exactly the name's width.

   `max-width` only caps -- it never reserves -- so a short name leaves no gap
   after it and a long one is not cut off, and the ceiling is a number no room
   name will reach. The obvious alternative, animating a `0fr` grid track, does
   not collapse here: a zero track still keeps its item's minimum size, and the
   padding after the name is part of that, so the pill stayed 12px too wide. */
.dial__name {
  display: block;
  max-width: 0;
  overflow: hidden;
  transition: max-width var(--dur-face) var(--ease-soft);
}
.dial__name > span {
  display: block;
  white-space: nowrap;
  font-family: var(--font-ui);
  font-size: var(--t-xs);
  /* The button's own ink, not the page's.

     This was `--film-ink`, which is whichever room you are standing in -- so
     reading the grove's name from inside the fog wrote near-black type on the
     grove's dark warm button and the name simply was not there. Every room
     sets `--dial-ink` beside its surface, because a surface and the type on
     it are one decision and splitting them across two rooms is how you get
     black on black. */
  color: var(--dial-ink, var(--film-ink));
  padding-inline-end: var(--s3);
}
.dial__room:hover .dial__name,
.dial__room:focus-visible .dial__name { max-width: 12rem; }

@media (prefers-reduced-motion: reduce) {
  .dial__column, .dial__room, .dial__name { transition: none; }
  .dial[data-open="true"] .dial__room { transform: none !important; transition-delay: 0ms; }
  .dial__name { max-width: 12rem; }
}

/* ==========================================================================
   The journey between rooms. PLATFORM.md §5.

   Three beats on one clock: the interface lets go, the world takes the screen,
   the interface re-forms on the other side.

   The clock is here and only here. `journey.js` reads these three numbers off
   the root rather than keeping its own copy, because the version that kept its
   own copy had the arrival lasting 800ms in JavaScript and 900ms in CSS, and
   the flag came off mid-animation.
   ========================================================================== */

:root {
  --beat-depart: 800ms;
  --beat-travel: 400ms;
  --beat-arrive: 800ms;
  /* What someone who asked for no motion gets instead of the flight. §5.2:
     a cross-dissolve, not a cut and not two seconds of nothing. */
  --beat-dissolve: 200ms;
  /* How long the console takes to form once the camera has landed. Longer than
     the arrive beat on purpose: the place is there first and the interface
     assembles inside it, rather than the two racing. */
  --beat-assemble: 900ms;
}

/* The sheet the world plays on. Over everything, including the room's own
   backdrop -- during travel the screen belongs to the world, not to the room. */
.journey {
  position: fixed;
  inset: 0;
  z-index: 200;
  opacity: 0;
  pointer-events: none;
  background: rgb(var(--shade-rgb) / 1);
  /* The colour as well as the opacity: the shade changes to the next room's
     at the moment the sheet starts to lift, and as a cut that was a full
     screen of one colour swapped for another -- graphite to paper, paper to
     black -- in a single frame. */
  transition: opacity var(--dur-face) var(--ease),
              background-color var(--dur-face) var(--ease);
}
.journey[data-showing="true"] { opacity: 1; }
.journey__film { width: 100%; height: 100%; object-fit: cover; display: block; }

/* --- departing ------------------------------------------------------------

   Not a fade. §5.1: panels *release*, in the direction the camera is leaving,
   and in the language of the place being left -- so this is the arrival's
   counterpart and a room owns both ends of it.

   The order is the reading order run backwards: the top bar goes, then the
   eyebrow, then the title, and the bar you were actually working in holds on
   longest. The head is furthest from what you were doing and the camera is
   rising away from it. */
[data-journey="depart"] .topbar   { animation-delay: 0ms;   }
[data-journey="depart"] .eyebrow  { animation-delay: 40ms;  }
[data-journey="depart"] .title    { animation-delay: 90ms;  }
[data-journey="depart"] .shell    { animation-delay: 150ms; }

[data-journey="depart"] .topbar,
[data-journey="depart"] .eyebrow,
[data-journey="depart"] .title,
[data-journey="depart"] .shell {
  animation-duration: 600ms;
  animation-timing-function: var(--ease-soft);
  /* Forwards, not both: they stay gone until the world has the screen.
     Without it each one snapped back for the frame between its animation
     ending and the travel beat covering it. */
  animation-fill-mode: forwards;
}

/* --- the chrysalis grove: furl -------------------------------------------- */
[data-exit="furl"][data-journey="depart"] .topbar,
[data-exit="furl"][data-journey="depart"] .eyebrow,
[data-exit="furl"][data-journey="depart"] .title,
[data-exit="furl"][data-journey="depart"] .shell { animation-name: leave-furl; }
@keyframes leave-furl {   /* closes back into itself, which is the unfurl undone */
  to { opacity: 0; transform: translateY(-14px) scaleY(0.94); filter: blur(7px); }
}

/* --- the prism canyon: disperse ------------------------------------------- */
[data-exit="disperse"][data-journey="depart"] .topbar,
[data-exit="disperse"][data-journey="depart"] .eyebrow,
[data-exit="disperse"][data-journey="depart"] .title,
[data-exit="disperse"][data-journey="depart"] .shell { animation-name: leave-disperse; }
@keyframes leave-disperse {   /* the split opens instead of closing, and takes the panel with it */
  to {
    opacity: 0;
    transform: translateX(12px);
    text-shadow: 14px 0 0 rgba(216, 162, 60, .5), -14px 0 0 rgba(90, 150, 190, .45);
    box-shadow: 14px 0 0 rgba(216, 162, 60, .14), -14px 0 0 rgba(90, 150, 190, .12);
  }
}

/* --- the rooms not built yet ---------------------------------------------- */
[data-exit="fog"][data-journey="depart"] .topbar,
[data-exit="fog"][data-journey="depart"] .eyebrow,
[data-exit="fog"][data-journey="depart"] .title,
[data-exit="fog"][data-journey="depart"] .shell { animation-name: leave-fog; }
@keyframes leave-fog { to { opacity: 0; filter: blur(14px); transform: scale(1.012); } }

[data-exit="close"][data-journey="depart"] .topbar,
[data-exit="close"][data-journey="depart"] .eyebrow,
[data-exit="close"][data-journey="depart"] .title,
[data-exit="close"][data-journey="depart"] .shell {
  animation-name: leave-close;
  transform-origin: top center;
}
/* The archive's departure is the fold run the other way: each sheet tips
   back on its top edge and is gone, a page turned over rather than a panel
   slid off. */
@keyframes leave-close {
  60% { opacity: 1; }
  to  { opacity: 0; transform: perspective(900px) rotateX(-38deg); }
}

[data-exit="sink"][data-journey="depart"] .topbar,
[data-exit="sink"][data-journey="depart"] .eyebrow,
[data-exit="sink"][data-journey="depart"] .title,
[data-exit="sink"][data-journey="depart"] .shell { animation-name: leave-sink; }
@keyframes leave-sink {   /* one last swell, then under */
  40% { opacity: 1; transform: scale(1.008); }
  to  { opacity: 0; transform: scale(0.972); }
}

[data-exit="pass"][data-journey="depart"] .topbar,
[data-exit="pass"][data-journey="depart"] .eyebrow,
[data-exit="pass"][data-journey="depart"] .title,
[data-exit="pass"][data-journey="depart"] .shell { animation-name: leave-pass; }
@keyframes leave-pass { to { opacity: 0; transform: translateX(-46px); } }

[data-exit="exhale"][data-journey="depart"] .topbar,
[data-exit="exhale"][data-journey="depart"] .eyebrow,
[data-exit="exhale"][data-journey="depart"] .title,
[data-exit="exhale"][data-journey="depart"] .shell { animation-name: leave-exhale; }
@keyframes leave-exhale { to { opacity: 0; transform: scale(0.988); } }

[data-exit="unbuild"][data-journey="depart"] .topbar,
[data-exit="unbuild"][data-journey="depart"] .eyebrow,
[data-exit="unbuild"][data-journey="depart"] .title,
[data-exit="unbuild"][data-journey="depart"] .shell { animation-name: leave-unbuild; }
@keyframes leave-unbuild { to { opacity: 0; clip-path: inset(0 0 100% 0); } }

/* --- travelling: no interface at all --------------------------------------

   No transition, and that matters. The departure holds everything at nothing
   through `animation-fill-mode: forwards`; the moment the beat becomes
   `travel` those rules stop matching, the animation is dropped, and the
   elements revert to their ordinary selves -- which is fully visible. A
   transition from there to nothing then played that revert as a fade *in*
   before the fade out, so the console flashed back for a tenth of a second on
   the way out of every room.

   Going straight to nothing means the style recalculation that drops the
   animation is the same one that sets this, and there is no frame in between
   for anything to appear in. */
[data-journey="travel"] .stage,
[data-journey="travel"] .topbar { opacity: 0; transition: none; }

/* --- arriving -------------------------------------------------------------

   Nothing here. The arrival *is* the room's entrance character, below, and
   there used to be a generic `journey-settle` competing with it: two animations
   on the same elements at the same moment, the later rule quietly winning, and
   `.stage__head` running one while its own children ran the other. The room
   owns its arrival, so this beat only has to keep out of the way. */

@media (prefers-reduced-motion: reduce) {
  /* The flight goes; the dissolve stays. Cutting straight from one room to
     another is not gentler than travelling, it is only more abrupt, and §5.2
     asks for 200ms of cross-dissolve rather than nothing. */
  .journey { transition: opacity var(--beat-dissolve) linear; }
  .journey__film { display: none; }
  [data-journey] .stage__head,
  [data-journey] .eyebrow,
  [data-journey] .title,
  [data-journey] .shell,
  [data-journey] .topbar { animation: none; transform: none; opacity: 1; transition: none; }
}

/* ==========================================================================
   Coming into a room. PLATFORM.md §5.1.

   The same panel dissolves in one place's language and reassembles in
   another's -- that is what sells "a different place" rather than "a different
   tab", and it is much easier to build than a continuous morph.

   Three things arrive, always in this order and never together: the eyebrow,
   then the title, then the bar you work in. Reading before doing. The stagger
   is what makes it feel composed rather than dropped, and it is the same
   stagger in every room -- only the *character* of the movement changes, which
   is the §4.1 rule again: a room owns its motion, not the choreography.
   ========================================================================== */

[data-entering] .eyebrow  { animation-delay: 60ms;  }
[data-entering] .title    { animation-delay: 170ms; }
[data-entering] .shell    { animation-delay: var(--enter-shell-at, 330ms); }
[data-entering] .topbar   { animation-delay: 0ms;   }

[data-entering] .eyebrow,
[data-entering] .title,
[data-entering] .shell,
[data-entering] .topbar {
  animation-duration: var(--beat-assemble);
  animation-timing-function: var(--ease-soft);
  animation-fill-mode: both;
  /* A default name, so that this block hides the console on its own.

     `data-entering` is in the markup, which is the only way to be in the
     from-state at the *first* paint: setting it from JavaScript meant the page
     drew the headline, and then the animation took it away and brought it
     back. Reloading the site showed the title, lost it, and had it fade in
     again -- which reads as a stutter rather than as an arrival.

     The room replaces this name as soon as it knows which room it is. If the
     script never runs at all, `both` leaves the to-state in place when the
     animation finishes, so the page still ends up readable. */
  animation-name: enter-unfurl;
}

/* --- the chrysalis grove: unfurl ------------------------------------------
   Things here are mid-change, so the console opens out of itself: a little
   short, a little tight, and it lets go into its own shape. */
[data-entrance="unfurl"][data-entering] .eyebrow,
[data-entrance="unfurl"][data-entering] .title,
[data-entrance="unfurl"][data-entering] .shell,
[data-entrance="unfurl"][data-entering] .topbar {
  animation-name: enter-unfurl;
}
@keyframes enter-unfurl {
  from { opacity: 0; transform: translateY(14px) scaleY(0.94); filter: blur(6px); }
  60%  { opacity: 1; }
  to   { opacity: 1; transform: none; filter: none; }
}

/* --- the prism canyon: refract --------------------------------------------
   Light split by stone, resolving as it comes through: the panel arrives with
   its edges thrown apart in the room's own warm and cool, and they close. */
/* Split light resolving, and it matters *what* is split.

   One keyframe did both the type and the panels, with a `box-shadow` in it. A
   box-shadow on a line of text is a solid rectangle the size of the text's own
   box, so the headline arrived with a coloured bar standing at each end of it
   -- brief, but plainly wrong, and not what "refract" means. Text splits
   through its letterforms, which is `text-shadow`; a panel splits at its edge,
   which is `box-shadow`. They are two different animations now. */
[data-entrance="refract"][data-entering] .eyebrow,
[data-entrance="refract"][data-entering] .title {
  animation-name: enter-refract-type;
}
[data-entrance="refract"][data-entering] .shell,
[data-entrance="refract"][data-entering] .topbar {
  animation-name: enter-refract-panel;
}
@keyframes enter-refract-type {
  from {
    opacity: 0;
    transform: translateX(-10px);
    text-shadow: 9px 0 0 rgba(216, 162, 60, .55), -9px 0 0 rgba(90, 150, 190, .5);
  }
  55% { opacity: 1; }
  to {
    opacity: 1;
    transform: none;
    text-shadow: 0 0 0 rgba(216, 162, 60, 0), 0 0 0 rgba(90, 150, 190, 0);
  }
}
@keyframes enter-refract-panel {
  from {
    opacity: 0;
    transform: translateX(-10px);
    box-shadow: 9px 0 0 -1px rgba(216, 162, 60, .16), -9px 0 0 -1px rgba(90, 150, 190, .14);
  }
  55% { opacity: 1; }
  to {
    opacity: 1;
    transform: none;
    box-shadow: var(--react-edge);
  }
}

/* --- the rooms not built yet ----------------------------------------------
   Written now because the character is decided in §4 and writing it down while
   the reasoning is to hand costs nothing; the rooms are what is missing, not
   the motion. */
[data-entrance="resolve"][data-entering] .eyebrow,
[data-entrance="resolve"][data-entering] .title,
[data-entrance="resolve"][data-entering] .shell,
[data-entrance="resolve"][data-entering] .topbar { animation-name: enter-resolve; }
@keyframes enter-resolve {   /* out of fog, as it nears */
  from { opacity: 0; filter: blur(14px); transform: scale(1.012); }
  to   { opacity: 1; filter: none; transform: none; }
}
/* The top bar spans the window, so grown by even a hundredth it stood past
   both edges, and for the first frames of every load the page could be
   scrolled sideways -- a horizontal scrollbar flashing in where scrollbars
   are always shown. It comes out of the fog without the nearing. */
[data-entrance="resolve"][data-entering] .topbar { animation-name: enter-resolve-flat; }
@keyframes enter-resolve-flat {
  from { opacity: 0; filter: blur(14px); }
  to   { opacity: 1; filter: none; }
}

/* --- the chalk archive: fold ---------------------------------------------
   Paper, which folds rather than slides. Each piece opens down from its own
   top edge, the way a sheet folded over a desk is laid flat: it starts tipped
   back a third of the way and it is already opaque before it lands, because
   a sheet of paper is not a mist that thickens into place. The hinge is on
   the rule rather than inside the keyframe -- a `transform-origin` written in
   a `from` is interpolated back to the default on the way to `to`, so the
   hinge slid down the sheet as it opened. */
[data-entrance="fold"][data-entering] .eyebrow,
[data-entrance="fold"][data-entering] .title,
[data-entrance="fold"][data-entering] .shell,
[data-entrance="fold"][data-entering] .topbar {
  animation-name: enter-fold;
  transform-origin: 50% 0;
}
@keyframes enter-fold {
  from { opacity: 0; transform: perspective(900px) rotateX(-34deg); }
  40%  { opacity: 1; }
  to   { opacity: 1; transform: none; }
}

[data-entrance="ripple"][data-entering] .eyebrow,
[data-entrance="ripple"][data-entering] .title,
[data-entrance="ripple"][data-entering] .shell,
[data-entrance="ripple"][data-entering] .topbar { animation-name: enter-ripple; }
@keyframes enter-ripple {    /* still water answering a source */
  from { opacity: 0; transform: scale(0.972); }
  58%  { opacity: 1; transform: scale(1.008); }
  to   { opacity: 1; transform: none; }
}

[data-entrance="drift"][data-entering] .eyebrow,
[data-entrance="drift"][data-entering] .title,
[data-entrance="drift"][data-entering] .shell,
[data-entrance="drift"][data-entering] .topbar { animation-name: enter-drift; }
@keyframes enter-drift {     /* landscape passing a moving window */
  from { opacity: 0; transform: translateX(46px); }
  to   { opacity: 1; transform: none; }
}

[data-entrance="breathe"][data-entering] .eyebrow,
[data-entrance="breathe"][data-entering] .title,
[data-entrance="breathe"][data-entering] .shell,
[data-entrance="breathe"][data-entering] .topbar { animation-name: enter-breathe; }
@keyframes enter-breathe {   /* slow light in a hollow */
  from { opacity: 0; transform: scale(0.988); }
  to   { opacity: 1; transform: none; }
}

[data-entrance="build"][data-entering] .eyebrow,
[data-entrance="build"][data-entering] .title,
[data-entrance="build"][data-entering] .shell,
[data-entrance="build"][data-entering] .topbar { animation-name: enter-build; }
@keyframes enter-build {     /* wireframe first, then the surfaces fill in */
  from { opacity: 0; clip-path: inset(0 0 100% 0); }
  to   { opacity: 1; clip-path: inset(0 0 0 0); }
}

/* A room's address, before the room's own script has put its island in.

   The page is the one page, and the island written into it is the converter's
   bar. The worker dresses everything else as the room before the page is sent
   (the colours, the film, the headline, the tab -- `src/rooms.js`), but the
   room's island is built by its script, so until that has run the converter's
   is held out of sight rather than shown under another room's headline. No
   animation while held, so that the room's entrance starts from its beginning
   the moment the flag comes off, rather than having run its course unseen. */
[data-room-pending] .shell { visibility: hidden; animation: none !important; }

/* The headline held the same way, on a reload of a room that was last in the
   middle of something and may come back to it (`early.js`): if it does, the
   head is folded away and must not have been seen first; if it does not, it
   comes in from the start of its entrance, ahead of the island as always. */
[data-head-pending] .eyebrow,
[data-head-pending] .title { visibility: hidden; animation: none !important; }
/* And folded away at once when the room does come back to work, rather than
   over the head's own transition -- which was still running when the hold
   came off, so the eyebrow's entrance showed through it for a tenth of a
   second. */
[data-head-pending] .stage__head { transition: none; }

/* Nothing moves for anyone who asked for nothing to move. The room still
   arrives -- it is simply already there. */
@media (prefers-reduced-motion: reduce) {
  [data-entering] .eyebrow,
  [data-entering] .title,
  [data-entering] .shell,
  [data-entering] .topbar { animation: none !important; }
}

/* ==========================================================================
   The button.

   Framework rather than room, because the account panel uses it in every room
   and the photo room uses it in its own. It lived in the photo room's
   stylesheet until that file was cut back to only what that room has, and then
   it lived nowhere at all -- Save a copy came out as Arial on a grey slab,
   which is what a missing component looks like and why one belongs here.

   The same material as `.chip`: the console's raised surface, a hairline, the
   control radius. `--bright` is the one that carries the weight of a decision.
   ========================================================================== */

.btn {
  font: inherit; font-size: var(--t-sm); font-weight: 600; line-height: 1;
  min-height: 44px; padding: 0 var(--s5);
  display: inline-flex; align-items: center; justify-content: center;
  border: var(--hairline) solid var(--film-rule); border-radius: var(--radius-control);
  background: var(--film-raise); color: var(--film-ink);
  cursor: pointer; white-space: nowrap; text-decoration: none;
  transition: background-color var(--dur-state) var(--ease),
              box-shadow var(--dur-state) var(--ease);
}
.btn:hover:not(:disabled) { background: var(--film-raise-hi); box-shadow: var(--lift-1); }
.btn:disabled { opacity: .45; cursor: default; }
.btn:focus-visible { outline: 2px solid var(--film-ink); outline-offset: 2px; }

.btn--bright {
  background: var(--knob-face); color: var(--knob-ink); border-color: transparent;
}
.btn--bright:hover:not(:disabled) { background: var(--knob-face-hi); }

@media (prefers-reduced-motion: reduce) { .btn { transition: none; } }

/* --- the pill: the smallest control there is -----------------------------

   `.chip` with the weight taken out of it. For the things that sit *on* a
   surface rather than at the foot of a panel -- Fit, 100%, the crop ratios --
   where there may be six in a row and none of them is the thing you came to
   press. Same family as `.ghost` and `.chip`, two sizes down.

   It is here rather than in the photo room's stylesheet because it is a
   component and components are the building's. It was in neither: seventeen
   pills in that room were rendering as browser default buttons, with only the
   pressed state styled, which made the selected one look deliberate and the
   rest look broken. The same slip as `.btn` before it, and for the same
   reason -- a control invented inside a room and never given a home. */
.pill {
  font: inherit; font-size: var(--t-xs); font-weight: 600; line-height: 1;
  min-height: 30px; padding: 0 var(--s3);
  display: inline-flex; align-items: center; justify-content: center;
  border: var(--hairline) solid var(--film-rule); border-radius: var(--radius-control);
  background: var(--film-raise); color: var(--film-ink-2);
  cursor: pointer; white-space: nowrap;
  transition: background-color var(--dur-state) var(--ease),
              border-color var(--dur-state) var(--ease),
              box-shadow var(--dur-state) var(--ease),
              color var(--dur-state) var(--ease);
}
/* Under the pointer. The background lifts and the edge comes up with it: the
   fill alone moved by so little against the panel behind it that on a pill
   which was not currently chosen there was no visible answer to the pointer at
   all, and the only button on the row that looked like it did anything was the
   one already pressed. */
.pill:hover:not(:disabled) {
  background: var(--film-raise-hi);
  border-color: var(--film-ink-3);
  color: var(--film-ink);
}
.pill:disabled { opacity: .4; cursor: default; }
.pill:focus-visible { outline: 2px solid var(--film-ink); outline-offset: 2px; }

/* Chosen. Reads as a pressed key rather than as a coloured one: the accent is
   for the one thing on a face worth doing, and six ratio buttons are not it.

   Both spellings. A pill that toggles says `aria-pressed`; one of a set that
   picks a single answer says `aria-checked`, because that is what a `radio`
   means. Only the first was styled, so the transcribe room's Fast and Careful
   -- a radio group -- showed no selection at all, which is the one thing a
   choice has to show. */
.pill[aria-pressed="true"],
.pill[aria-checked="true"] {
  background: var(--knob-face); color: var(--knob-ink); border-color: transparent;
}

/* A chosen pill under the pointer.

   It had no hover rule of its own, so the general one -- which sets a
   background and a colour -- was outranked by the chosen rule and the pointer
   got no answer from the very buttons most likely to be pointed at. It cannot
   go brighter, being already the lightest thing on the panel, so it draws a
   ring instead: the same "something is about to happen" without pretending to
   a state it is not in. */
.pill[aria-pressed="true"]:hover:not(:disabled),
.pill[aria-checked="true"]:hover:not(:disabled) {
  background: var(--knob-face);
  color: var(--knob-ink);
  box-shadow: 0 0 0 2px rgb(var(--shade-rgb) / 0.35), 0 0 0 3px var(--knob-face);
}
/* Saying a number rather than offering to do something. */
.pill--quiet {
  background: none; cursor: default; color: var(--film-ink-3);
  border-color: transparent;
}

@media (prefers-reduced-motion: reduce) { .pill { transition: none; } }

/* --- the cog, and what is behind it -----------------------------------------

   At the far end of the account's own head row, opposite the way back. Quiet,
   because settings are not a place anybody needs to go to use this site --
   they are where you look when something about it is bothering you. */
.cogbtn {
  margin-left: auto;
  width: 34px;
  height: 34px;
  display: inline-grid;
  place-items: center;
  border: var(--hairline) solid var(--film-rule);
  border-radius: 50%;
  background: none;
  color: var(--film-ink-3);
  cursor: pointer;
  transition: color var(--dur-state) var(--ease), border-color var(--dur-state) var(--ease);
}
.cogbtn:hover { color: var(--film-ink); border-color: var(--film-ink-3); }
.cogbtn:focus-visible { outline: 2px solid var(--film-ink); outline-offset: 2px; }

.cogbtn .cog {
  width: 17px; height: 17px;
  fill: none; stroke: currentColor; stroke-width: 1.7;
  stroke-linecap: round; stroke-linejoin: round;
  transition: transform 420ms var(--ease-soft);
}
.cogbtn:hover .cog { transform: rotate(32deg); }
@media (prefers-reduced-motion: reduce) {
  .cogbtn .cog { transition: none; }
  .cogbtn:hover .cog { transform: none; }
}

/* One setting: what it is on the left, the switch on the right, on one
   line -- what the switch does is its tip (room-chrome.js), not a
   paragraph under its name. */
.setting {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: var(--s4);
  margin-top: var(--s5);
}
.setting__what { min-width: 0; }
.setting__name { font-size: var(--t-sm); color: var(--film-ink); font-weight: 600; }
.setting__why {
  margin-top: var(--s1);
  font-size: var(--t-xs);
  color: var(--film-ink-3);
  max-width: 34em;
}
.setting .pill { flex: none; min-width: 3.4em; }

/* A chip that has not been told yet. It keeps its shape so the top bar does
   not shuffle when the answer arrives, and says nothing rather than saying
   something that may be wrong. */
.chip[data-state="asking"] {
  min-width: 5.2em;
  color: transparent;
}
.chip[data-state="asking"]::after {
  content: "";
  position: absolute;
  left: 50%;
  top: 50%;
  width: 3.2em;
  height: 1px;
  margin: -1px 0 0 -1.6em;
  background: var(--film-rule);
}
.chip { position: relative; }

/* --- part of the plan ---------------------------------------------------------
   One small mark beside a control whose file needs a plan (plan-features.js):
   a 5px diamond in the room's own accent, which is all it says until the
   file is asked for and the sheet says the rest (ui/plan-sheet.js). Only for
   somebody known to have no plan -- `data-plan-free` on the root is set by
   the account once the session has answered -- so a subscriber never sees
   it, not even for the first frame. Its name is read out; it shows nothing
   else.

   A real element (`markPlan` in ui/plan-sheet.js), because ::before and
   ::after on the lit controls already carry the sheen and the refraction.
   The Video room's pills are not lit, so theirs is drawn by selector and
   needs nothing in that room's code. */
.plan-mark { display: none; }
[data-plan-free] .plan-mark,
[data-plan-free] :is([data-set="project.size"][data-v="2160"], [data-opt="short"][data-v="2160"], [data-set="bigger"][data-v="fast"], [data-set="bigger"][data-v="best"])::after {
  display: inline-block;
  flex: none;
  width: 5px;
  height: 5px;
  margin-inline-start: 0.5em;
  transform: rotate(45deg);
  border-radius: 1px;
  background: var(--accent, currentColor);
  vertical-align: middle;
}
/* Inside a pill that already spaces its parts. */
[data-plan-free] :is([data-set="project.size"][data-v="2160"], [data-opt="short"][data-v="2160"], [data-set="bigger"][data-v="fast"], [data-set="bigger"][data-v="best"])::after {
  content: "" / "part of the plan";
  margin-inline-start: 0;
}
/* On the room's light knob the accent can vanish; the knob's own ink does not. */
[data-plan-free] .action .plan-mark { background: currentColor; opacity: 0.55; }
/* The signature tool on the PDF editor's toolbar is an icon alone: its mark
   sits at the top right corner, so the icon does not move. */
[data-plan-free] .ed-iconbtn[data-cmd="tool.sign"] { position: relative; }
[data-plan-free] .ed-iconbtn[data-cmd="tool.sign"]::after {
  content: "" / "part of the plan";
  position: absolute;
  top: 5px;
  right: 5px;
  width: 5px;
  height: 5px;
  transform: rotate(45deg);
  border-radius: 1px;
  background: var(--accent, currentColor);
  pointer-events: none;
}

/* A tip's controls (ui/keys.js tipKeysHtml): under the sentence that says what
   the thing is, one line per action, the action in key caps on the left and
   what it does on the right. A real list, in whatever ink its tip has. */
.tip-keys {
  display: grid;
  grid-template-columns: max-content minmax(0, 1fr);
  gap: 2px var(--s2);
  margin: var(--s1) 0 0;
  padding: var(--s1) 0 0;
  list-style: none;
  border-top: 1px solid color-mix(in srgb, currentColor 18%, transparent);
}
.tip-keys > li { display: grid; grid-column: 1 / -1; grid-template-columns: subgrid; align-items: baseline; }
.tip-keys .tip-keys__how {
  margin: 0;
  font-family: var(--font-mono);
  font-size: 0.92em;
  white-space: nowrap;
  opacity: 0.8;
}
.tip-keys__does { overflow-wrap: break-word; }

/* ==========================================================================
   A finger's reach: 44 px on touch
   ========================================================================== */

/* DESIGN.md 2.4 and 2.5: where the main pointer is a finger, every control
   gives it at least 44 x 44 px (the owner's choice of 9 October 2026: "44 px
   on touch only"). With a mouse none of this applies and nothing moves.

   The look stays where it can. A control drawn smaller than 44 keeps its
   drawing and reaches further than it shows: an empty pseudo-element centred
   on it, as wide and as tall as `--hit` or as the control, whichever is the
   larger. Percentages in `inset` are the containing block's height for top
   and bottom and its width for left and right, so the one value centres it
   both ways, and `min(0px, ...)` leaves a side that is already long enough
   alone.

   The building's own controls are listed here; each room lists its own in its
   stylesheet with the same three declarations and `--hit-reach`, lending
   `::before` where its `::after` is taken (a ring, a plan mark). The lists
   are `:where()`, which weighs nothing: any rule of a room's own about these
   pseudo-elements, or about where the control is placed, wins. A control
   placed absolutely keeps its place (a containing block already); only one
   left static is made relative, so the reach centres on it.

   Where two reaches would meet, a room adds space between the controls, or
   lets them grow a little, in its own `(pointer: coarse)` block sized with
   `--hit`. Where a box that clips would cut the reach (a toolbar that
   scrolls, a bar that hides what overflows it), the control grows instead.
   scripts/touch-qa measures every control in every room the way a finger
   meets it, reach included and clipping taken off, and finds any two that
   meet. */
@media (pointer: coarse) {
  :root {
    --hit: 44px;
    /* The reach's inset: put it on an empty, absolutely placed pseudo-element
       and it is centred on its control and --hit across, or the control's
       own size where that is larger. The rooms use it for their controls in
       their own (pointer: coarse) blocks, the same way as here. */
    --hit-reach: min(0px, calc(50% - var(--hit) / 2));
  }

  :where(.topbar__mark, .cogbtn, .quietlink, .footer a, .pill:not(.pill--auto)) { position: relative; }
  /* The foot's links sit in a sentence that wraps: its lines are a finger
     apart, so the reaches of two links on two lines never meet, and a link
     never breaks across a line (its reach would span both). */
  .footer p { line-height: var(--hit); }
  .footer a { white-space: nowrap; }
  /* The cog is cut to the room's shape in the account's faces (a clip-path,
     which would cut its reach too), so it is a finger across itself. */
  .cogbtn { width: var(--hit); height: var(--hit); }
  :where(.topbar__mark, .cogbtn, .quietlink, .footer a, .pill:not(.pill--auto))::after {
    content: "";
    position: absolute;
    inset: var(--hit-reach);
  }

  /* The dial's buttons are drawn objects with no pseudo-element to spare (the
     room's light is on both) and a clip of their own, so they grow instead:
     a tenth, all of a piece, which keeps every drawing in proportion. `scale`
     is its own property, so the dial's opening and the rooms' reactions,
     which use `transform`, are untouched. */
  .dial :is(.dial__face, .dial__room) { scale: 1.1; }

  /* A slider cannot lend a pseudo-element (an input has none), so the input
     itself is the full height, its track still drawn in the middle, and a
     negative margin gives back what it took: nothing around it moves. */
  .slider input[type="range"] { height: var(--hit); margin-block: calc((28px - var(--hit)) / 2); }
}
