/* ═══════════════════════════════════════════════════════════════════════════
   VESPER, the design system.

   Authored from design/mockups/look-v1.html. He signed off Dusk on 2026-08-27
   and CHANGED IT TO HARBOUR the same day, at the end of the screens sheet. This
   file is the source of truth for the values. design/DECISIONS.md carries the
   reasons; this carries the values. A change here that contradicts DECISIONS.md
   is a bug in one of the two, and DECISIONS.md wins.

   Committed to one world on purpose. He wakes at 18:00 and goes out into the
   night, and the thing he opens in that hour is dark. There is no light theme
   and it is not an omission.

   NOTHING IN THIS APP IS STILL. His ruling 2026-08-27, DECISIONS.md section 10.
   Section 11 below is the motion layer and it is not decoration: screens travel,
   content assembles, the ground is alive, and gestures move real pixels. The one
   rule it answers to is that MOTION CARRIES CONTINUITY AND LIFE AND NEVER CARRIES
   PRAISE, which is why the four marks land at identical speed whatever they say.

   THERE IS DELIBERATELY NO RULE IN THIS FILE for a streak, a flame, a chain, a
   consecutive-day count, a score out of anything, a percentage, or a progress
   ring toward a total. None of those may ever exist in this interface.
   CHARACTER.md sections 4 and 5, DESIGN-BRIEF.md section 1.

   HYGIENE PASS 2026-08-27, CHECKLIST.md OWED 19. The component layer used to
   carry twenty two raw hex and rgba values while this header called that a bug.
   It carries none now. Everything a component paints with is a token in section
   1, the spacing layer is a real scale rather than two tokens against several
   dozen hand written rem values, the intake selected state and the rest of the
   furniture the mockups had are section 12, and the tab icons are one named set
   at design/system/icons.svg rather than pasted inline seven times.

   MEASURE, DO NOT ASSERT. `node design/system/contrast.mjs` reports every ink
   step against the worst part of the ground and exits non-zero if one is short.
   That script exists because this header once claimed a contrast floor the file
   did not meet. A new ink token gets a row in it before it ships.
   ═══════════════════════════════════════════════════════════════════════════ */

/* ── 1. Tokens ──────────────────────────────────────────────────────────────
   Three tiers, and components only ever read the third. A raw hex or rgba in a
   component is a bug: it will not survive the hour-shift ground or a contrast
   fix. That rule is enforceable rather than aspirational, see
   design/system/.stylelintrc.json. */

:root {
  /* 1a. Ground, the HARBOUR world. His ruling 2026-08-27, replacing Dusk, and
     DECISIONS.md section 2 carries it. Cool all the way down: deep teal at the
     top of the screen, colder teal through the middle, a pale grey-blue low
     band, and blue at the foot.

     THE LOW BAND IS THE THING TO WATCH. Dusk's band was ember at 44 percent
     lightness. Harbour's is a pale grey-blue at 52 percent, so it is the
     BRIGHTEST ground stop this system has ever carried and it is what every
     contrast figure is measured against. contrast.mjs was re-run on the change
     and the numbers moved. Never change a stop without running it. */
  --vsp-base:        #060b0d;
  --vsp-sky:         196 44% 30%;   /* deep teal, the high sky */
  --vsp-middle:      188 34% 22%;   /* colder teal, the middle */
  --vsp-band:        195 18% 52%;   /* pale grey-blue, the low band */
  --vsp-last:        210 38% 26%;   /* blue, the last of it */

  /* 1b. Ink. Warm off-white, never pure white: pure white on an OLED at 18:00
     is a torch.

     CORRECTED 2026-08-27. This block used to claim every step was "verified at
     or above 4.5:1 on glass over the brightest part of the ground". It was
     measured and it was false. At the old alphas, over the ember band on glass,
     secondary body ink came out at 2.82:1 and labels at 2.31:1. Reading screens
     were always fine because .vsp-deep darkens them; the four tab screens are
     not deep, and they are where most of this ink lives.

     His ruling, CHECKLIST.md OWED 17: take the fix. Two things do it together,
     because neither is enough alone. These alphas, which are the values that
     were already sitting unused in the prefers-contrast block, plus the scrim
     in section 2 on every screen that is not .vsp-deep.

     Measured by design/system/contrast.mjs, worst case across all four ground
     stops, each step on the surface it actually lives on. The four tab screens,
     on glass, worst over the ember band in every case:
       ink        6.68:1        ink-2      5.43:1
       ink-small  5.10:1        ink-3      4.79:1
     The corridor, which is .vsp-deep, where the prose and his own typing live:
       ink-read  11.84:1        ink-write 12.06:1
     ink-3 is 0.78 rather than 0.74 for one reason: 0.74 comes out at 4.49:1,
     which is a hundredth short, and a token that nearly passes is a token that
     fails. Anything added here gets measured the same way before it ships.

     --vsp-hair is deliberately NOT in that list. It is a separator between rows
     that are already separated by space, it carries no information on its own,
     and holding a divider to 3:1 would put a bright line through every screen. */
  --vsp-ink:         #F4F0E9;
  --vsp-ink-2:       rgba(244, 240, 233, 0.86);  /* secondary body */
  --vsp-ink-3:       rgba(244, 240, 233, 0.78);  /* labels, uppercase only */
  --vsp-ink-small:   rgba(244, 240, 233, 0.82);  /* small print on glass */
  --vsp-ink-read:    rgba(244, 240, 233, 0.94);  /* lesson prose, the correction */
  --vsp-ink-write:   rgba(244, 240, 233, 0.95);  /* what he has typed */
  --vsp-hair:        rgba(244, 240, 233, 0.22);  /* the only divider weight */

  /* The quiet step, and it went from 0.50 to 0.58 in the hygiene pass. It was
     the idle tab icon and the label on the unlit Done control, both written as
     a raw 0.5 in the component layer where nobody measured them. Measured: at
     0.5 the idle tab is 2.94:1 against a 3:1 floor for a non-text control, and
     the Done label is 4.23:1 against 4.5:1. At 0.58 they are 3.15:1 and 5.15:1.
     This is not a new decision. It is the row that was already in his OWED 17
     table, finished. */
  --vsp-ink-quiet:   rgba(244, 240, 233, 0.58);

  /* Placeholder only, and the one ink step deliberately below the floor. It is
     never information: the same words are in the screen's own title, and it is
     gone on the first keystroke. Nothing that has to be read may use it. */
  --vsp-ink-ghost:   rgba(244, 240, 233, 0.42);

  /* 1c. Tint. One quiet accent, and it is used for the kind line, the caret and
     the correction rule. It is never a fill and never a button. */
  --vsp-tint:        #A9C0C4;
  --vsp-tint-soft:   rgba(169, 192, 196, 0.6);   /* the only fill it is allowed: a switch that is on */
  --vsp-tint-select: rgba(169, 192, 196, 0.28);  /* text selection */

  /* 1d. The marks. Three states, never a number. Desaturated on purpose: the
     character never delivers a verdict on him, so the missed colour must read
     as information rather than as an alarm. */
  --vsp-got:         #9DC48A;
  --vsp-half:        #DCB86A;
  --vsp-missed:      #D89286;

  /* 1e. Glass. Apple's rule holds: never stack a light translucent surface on
     another. That is why the primary control below is a solid fill and not more
     glass, and it is the single most load-bearing line in this file.

     Two weights. Heavy is for the one surface that floats above everything,
     which is the tab bar, and bigger surfaces read as thicker. */
  --vsp-glass-hi:    rgba(255, 255, 255, 0.115);
  --vsp-glass-lo:    rgba(255, 255, 255, 0.045);
  --vsp-glass-edge:  rgba(255, 255, 255, 0.115);
  --vsp-glass-lip:   rgba(255, 255, 255, 0.18);  /* the light catching the top */
  --vsp-glass-blur:  18px;

  --vsp-heavy-hi:    rgba(255, 255, 255, 0.155);
  --vsp-heavy-lo:    rgba(255, 255, 255, 0.065);
  --vsp-heavy-edge:  rgba(255, 255, 255, 0.14);
  --vsp-heavy-lip:   rgba(255, 255, 255, 0.24);
  --vsp-heavy-blur:  26px;

  /* What glass becomes when the phone asks for less of it. Not absent: frosty. */
  --vsp-glass-solid:    rgba(20, 20, 28, 0.92);
  --vsp-glass-solid-hc: rgba(16, 16, 24, 0.94);

  /* The lit fill. One value, and it is the same object in three places: the
     pill under the current tab, a chosen intake option, and any surface that
     means "this one". */
  --vsp-lit:         rgba(255, 255, 255, 0.155);
  --vsp-lit-hi:      rgba(255, 255, 255, 0.2);
  --vsp-lit-lo:      rgba(255, 255, 255, 0.1);
  --vsp-lit-edge:    rgba(255, 255, 255, 0.26);
  --vsp-lit-lip:     rgba(255, 255, 255, 0.3);

  /* 1f. Type. Newsreader for everything he reads, because it carries six
     minutes on a phone; Instrument Sans for the interface. Bodoni Moda is a
     STAND-IN for the wordmark. Casko Luxury is his pick, the linked cut is a
     demo, and the licence is unchecked. Do not ship the stand-in as if it were
     the decision, and do not ship the demo as if it were free. */
  --vsp-read:  "Newsreader", Georgia, "Times New Roman", serif;
  --vsp-ui:    "Instrument Sans", ui-sans-serif, system-ui, -apple-system, sans-serif;
  --vsp-mark:  "Bodoni Moda", Didot, "Times New Roman", serif;

  /* Sizes are rem so his phone's text-size setting scales the whole layout with
     the type rather than bursting it. Tracking is size-specific: display text
     reads too loose as it grows, small text too tight. One letter-spacing value
     for everything is wrong somewhere. */
  --vsp-t-display: 2rem;      --vsp-tr-display: -0.032em;  --vsp-lh-display: 1.04;
  --vsp-t-title:   1.72rem;   --vsp-tr-title:   -0.026em;  --vsp-lh-title:   1.16;
  --vsp-t-ask:     1.5rem;    --vsp-tr-ask:     -0.014em;  --vsp-lh-ask:     1.24;
  --vsp-t-read:    1.058rem;  --vsp-tr-read:     0;        --vsp-lh-read:    1.66;
  --vsp-t-body:    0.945rem;  --vsp-tr-body:    -0.008em;  --vsp-lh-body:    1.34;
  --vsp-t-small:   0.78rem;   --vsp-tr-small:    0.012em;  --vsp-lh-small:   1.45;
  --vsp-t-label:   0.63rem;   --vsp-tr-label:    0.17em;

  /* 1g. Space. A scale, not a habit. OWED 19: this layer used to be two tokens
     against several dozen hand written rem values in the mockups, which is how
     a system quietly stops being one. Eight steps, and the two names that were
     already load-bearing keep their exact values so nothing moves.

     A measurement that is not on this scale needs a reason written next to it.
     Optical corrections on a single glyph are a reason. "It looked better" on a
     gap between two cards is not. */
  --vsp-s-1: 0.25rem;
  --vsp-s-2: 0.35rem;
  --vsp-s-3: 0.55rem;
  --vsp-s-4: 0.85rem;
  --vsp-s-5: 1.05rem;
  --vsp-s-6: 1.25rem;
  --vsp-s-7: 1.6rem;
  --vsp-s-8: 2.1rem;

  --vsp-pad:       var(--vsp-s-6);   /* the screen gutter, every screen */
  --vsp-gap:       var(--vsp-s-3);   /* between siblings in a stack */
  --vsp-tabs-clear: 6.4rem;          /* bottom space a screen leaves for the tab bar */

  /* 1h. Shape. */
  --vsp-r-card:    22px;
  --vsp-r-btn:     15px;
  --vsp-r-field:   13px;   /* the smaller tap targets: a stepper key, a chip */
  --vsp-r-bar:     10px;   /* a skeleton bar */
  --vsp-r-pill:    999px;

  /* 1i. Elevation. Shadow is depth, never emphasis, and there are four of them
     because there are four heights in the app: a card, the floating bar, a
     control that takes a press, and a knob that sits on a track. */
  --vsp-sh-card:  0 10px 26px -10px rgba(0, 0, 0, 0.6);
  --vsp-sh-bar:   0 16px 34px -12px rgba(0, 0, 0, 0.75);
  --vsp-sh-btn:   0 8px 22px -8px rgba(0, 0, 0, 0.7);
  --vsp-sh-knob:  0 2px 5px rgba(0, 0, 0, 0.5);

  /* 1j. Motion. Three curves and nothing else, because the app reads as one
     hand moving things rather than as a pile of effects. Response is not
     duration: a spring has no fixed length, its settle comes out of the
     parameters, and the tail is what makes it feel physical.

     --vsp-sp-carry is the ONLY curve that overshoots, and it is reserved for a
     gesture that carried momentum. Nothing overshoots on its own. */
  --vsp-sp-move:   cubic-bezier(0.32, 0.72, 0, 1);      /* travel */
  --vsp-sp-settle: cubic-bezier(0.22, 1, 0.36, 1);      /* arrive and rest */
  --vsp-sp-carry:  cubic-bezier(0.18, 0.9, 0.24, 1.06); /* earned overshoot only */
  --vsp-ease:      var(--vsp-sp-settle);                /* the old name, kept */

  --vsp-t-press:   130ms;   /* feedback on pointer-down, never on release */
  --vsp-t-fade:    220ms;
  --vsp-t-move:    420ms;   /* a screen crossing the phone */
  --vsp-t-stagger:  62ms;   /* between siblings as a screen assembles */
  --vsp-t-world:   700ms;

  /* 1k. The shade. One dark, used at three strengths: the scrim on the four tab
     screens, the veil that makes a reading screen deep, and nothing else. */
  /* 0.35 under Dusk, 0.46 under HARBOUR. Not a taste change: Harbour's low band
     is a pale grey-blue at 52 percent lightness against Dusk's ember at 44, so
     it is the brightest ground this system has carried and three ink steps fell
     under the floor the moment the world changed. Measured, not guessed:
     --vsp-ink-small 4.44:1, --vsp-ink-3 4.19:1, the idle tab icon 2.85:1.
     0.44 clears all three and 0.46 clears them with margin, which is the lesson
     of --vsp-ink-3: a token that nearly passes is a token that fails. */
  --vsp-scrim:     rgba(4, 5, 9, 0.46);   /* his OWED 17 ruling, every non-deep screen */
  --vsp-veil: linear-gradient(180deg,
    rgba(4, 5, 9, 0.62) 0%, rgba(4, 5, 9, 0.74) 46%, rgba(4, 5, 9, 0.9) 100%);

  /* 1l. THE ONE SATURATED ELEMENT. Every value the iridescent control paints
     with lives here, so there is exactly one place to change it and no way to
     grow a second one by copying a gradient. */
  --vsp-iris-sweep: conic-gradient(from 205deg,
    #6FC3FF 0deg, #A98BF2 62deg, #E893C0 126deg, #F0A972 190deg,
    #F5D49B 250deg, #8FD6E0 306deg, #6FC3FF 360deg);
  --vsp-iris-trace:   0.14;  /* never zero: a dead grey control reads as broken */
  --vsp-iris-lit-at:  0.68;  /* the point the ink flips; the script reads this */
  --vsp-iris-idle-hi:   rgba(255, 255, 255, 0.075);
  --vsp-iris-idle-lo:   rgba(255, 255, 255, 0.03);
  --vsp-iris-idle-edge: rgba(255, 255, 255, 0.1);
  --vsp-iris-lip:       rgba(255, 255, 255, 0.42);
  --vsp-iris-glow:      rgba(200, 160, 220, 0.5);
  --vsp-on-iris:        rgba(22, 16, 30, 0.92);

  /* 1m. The solid light fill, which is the primary control and the switch knob.
     It is the only light-on-dark inversion in the app. */
  --vsp-solid-hi:  #FBF8F3;
  --vsp-solid-lo:  #E8E2D8;
  --vsp-knob-lo:   #E2DCD2;
  --vsp-solid-lip: rgba(255, 255, 255, 0.7);
  --vsp-on-solid:  #14110F;

  /* 1n. Two surfaces that are neither glass nor solid: a skeleton bar while the
     mark is thinking, and the track of a switch that is off. */
  --vsp-skel:       rgba(244, 240, 233, 0.11);
  --vsp-skel-sheen: rgba(244, 240, 233, 0.16);
  --vsp-track:      rgba(244, 240, 233, 0.16);
  --vsp-track-edge: rgba(255, 255, 255, 0.12);
}

/* ── 2. Ground ──────────────────────────────────────────────────────────────
   Layered radial stops rather than blurred divs. Same look at a fraction of the
   cost on his phone, and no compositor thrash while a lesson scrolls. */

.vsp-ground {
  position: absolute;
  inset: -18%;
  z-index: 0;
  background:
    radial-gradient(58% 38% at 22% 4%,  hsl(var(--vsp-sky)    / 0.92) 0%, transparent 68%),
    radial-gradient(52% 32% at 86% 20%, hsl(var(--vsp-middle) / 0.85) 0%, transparent 66%),
    radial-gradient(72% 34% at 44% 56%, hsl(var(--vsp-band)   / 0.78) 0%, transparent 70%),
    radial-gradient(64% 30% at 16% 78%, hsl(var(--vsp-last)   / 0.72) 0%, transparent 68%),
    var(--vsp-base);
  animation: vsp-drift var(--vsp-ground-dur, 78s) ease-in-out infinite alternate;
}

/* Two layers at different rates, because one moving image reads as one moving
   image and two reads as weather. 78s is 0.013Hz, well clear of the 0.2Hz band
   that reads as a pulse, and .vsp-ground-b is slower still.

   The ground has a pace: it quickens while something is happening and slows
   while he reads. That is the app breathing and it is the cheapest life in it. */
@keyframes vsp-drift {
  from { transform: translate3d(0, 0, 0) scale(1); }
  to   { transform: translate3d(-2.5%, 2.2%, 0) scale(1.06); }
}
@keyframes vsp-drift-b {
  from { transform: translate3d(1.8%, -1.4%, 0) scale(1.05) rotate(0deg); }
  to   { transform: translate3d(-1.6%, 2.6%, 0) scale(1) rotate(2.5deg); }
}
.vsp-ground-b {
  position: absolute; inset: 0;
  background:
    radial-gradient(52% 32% at 86% 20%, hsl(var(--vsp-middle) / 0.85) 0%, transparent 66%),
    radial-gradient(64% 30% at 16% 78%, hsl(var(--vsp-last)   / 0.72) 0%, transparent 68%);
  animation: vsp-drift-b var(--vsp-ground-b-dur, 104s) ease-in-out infinite alternate;
}
[data-pace="busy"] { --vsp-ground-dur: 26s;  --vsp-ground-b-dur: 34s; }
[data-pace="calm"] { --vsp-ground-dur: 128s; --vsp-ground-b-dur: 168s; }

/* The scrim, OWED 17. On every screen that is NOT .vsp-deep. Without it the
   labels and small print on the four tab screens sit at 2.31:1. */
.vsp-scrim {
  position: absolute; inset: 0; z-index: 1; pointer-events: none;
  background: var(--vsp-scrim);
  transition: background var(--vsp-t-move) var(--vsp-sp-settle);
}
.vsp-deep .vsp-scrim { background: none; }

/* Reading screens sit deeper so prose holds 4.5:1 without the type gaining
   weight to fight the ground. Any screen carrying more than two lines of
   Newsreader takes this. Measured at 11.84:1. */
.vsp-deep .vsp-ground::after {
  content: "";
  position: absolute;
  inset: 0;
  background: var(--vsp-veil);
}

/* Grain. A gradient this wide bands visibly on an OLED phone, and the noise is
   what makes it read as a photograph rather than as CSS. It is not texture for
   its own sake and it is not optional. */
.vsp-grain {
  position: absolute;
  inset: 0;
  z-index: 1;
  pointer-events: none;
  opacity: 0.4;
  mix-blend-mode: overlay;
  background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='160' height='160'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.85' numOctaves='3' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='160' height='160' filter='url(%23n)' opacity='0.42'/%3E%3C/svg%3E");
}

/* ── 3. Type ────────────────────────────────────────────────────────────── */

.vsp-display { font: 500 var(--vsp-t-display)/var(--vsp-lh-display) var(--vsp-ui);
               letter-spacing: var(--vsp-tr-display); }
.vsp-title   { font: 500 var(--vsp-t-title)/var(--vsp-lh-title) var(--vsp-ui);
               letter-spacing: var(--vsp-tr-title); text-wrap: balance; }

/* Every question the app puts to him is in the reading face, so "it is asking
   about you" is visibly a different act from "it is instructing you". */
.vsp-ask     { font: 300 var(--vsp-t-ask)/var(--vsp-lh-ask) var(--vsp-read);
               letter-spacing: var(--vsp-tr-ask); text-wrap: balance; }
.vsp-read    { font: 300 var(--vsp-t-read)/var(--vsp-lh-read) var(--vsp-read);
               color: var(--vsp-ink-read); text-wrap: pretty; }
.vsp-body    { font: 500 var(--vsp-t-body)/var(--vsp-lh-body) var(--vsp-ui);
               letter-spacing: var(--vsp-tr-body); }
.vsp-small   { font: 400 var(--vsp-t-small)/var(--vsp-lh-small) var(--vsp-ui);
               letter-spacing: var(--vsp-tr-small); color: var(--vsp-ink-small); }

/* Uppercase only ever at label size. Anywhere larger it reads as shouting, and
   this app does not shout. */
.vsp-label   { font: 600 var(--vsp-t-label)/1.2 var(--vsp-ui);
               letter-spacing: var(--vsp-tr-label); text-transform: uppercase;
               color: var(--vsp-ink-3); }

.vsp-wordmark { font: 400 3.4rem/0.92 var(--vsp-mark); letter-spacing: -0.03em; }
.vsp-wordmark .stop { color: var(--vsp-tint); }

/* ── 4. Glass ───────────────────────────────────────────────────────────── */

.vsp-glass {
  background: linear-gradient(180deg, var(--vsp-glass-hi), var(--vsp-glass-lo));
  -webkit-backdrop-filter: blur(var(--vsp-glass-blur)) saturate(150%);
  backdrop-filter: blur(var(--vsp-glass-blur)) saturate(150%);
  border: 1px solid var(--vsp-glass-edge);
  box-shadow:
    0 1px 0 var(--vsp-glass-lip) inset,
    var(--vsp-sh-card);
  border-radius: var(--vsp-r-card);
}

/* Bigger surfaces read as thicker. The floating bar carries more blur and a
   deeper shadow than a card, because it sits above everything. */
.vsp-glass-heavy {
  --vsp-glass-blur: var(--vsp-heavy-blur);
  background: linear-gradient(180deg, var(--vsp-heavy-hi), var(--vsp-heavy-lo));
  border-color: var(--vsp-heavy-edge);
  box-shadow:
    0 1px 0 var(--vsp-heavy-lip) inset,
    var(--vsp-sh-bar);
}

/* ── 5. Controls ────────────────────────────────────────────────────────── */

/* The primary is a solid light fill, not glass. Two reasons and both are hard:
   glass on glass collapses legibility, and the action has to be unmistakable in
   a corridor where there is exactly one way forward. */
.vsp-btn {
  font: 600 0.93rem/1.2 var(--vsp-ui);
  letter-spacing: -0.004em;
  color: var(--vsp-on-solid);
  border: 0;
  background: linear-gradient(180deg, var(--vsp-solid-hi), var(--vsp-solid-lo));
  box-shadow: 0 1px 0 var(--vsp-solid-lip) inset, var(--vsp-sh-btn);
  border-radius: var(--vsp-r-btn);
  padding: var(--vsp-s-4) var(--vsp-s-5);
  width: 100%;
  min-height: 48px;
  cursor: pointer;
  transition: transform var(--vsp-t-press) var(--vsp-ease), filter var(--vsp-t-press) ease;
}
.vsp-btn:active { transform: scale(0.978); filter: brightness(1.06); }

.vsp-btn-quiet {
  background: none;
  box-shadow: none;
  color: var(--vsp-ink-2);
  font-weight: 500;
  border: 1px solid var(--vsp-hair);
}

/* THE ONE SATURATED ELEMENT IN THE APPLICATION, and there is never a second.
   It belongs to whichever control takes his production: today the Done button
   on the writing surface, and the record button when he chooses to speak.

   His OWED 11 ruling, 2026-08-27: IT DOES NOT SWITCH ON. It holds a low,
   desaturated trace of the iridescence while it waits, and turns UP to full
   saturation as he writes. Drive --vsp-iris from 0 to 1 as the production
   fills; the control never drops below the trace and never moves, because it
   is the same object throughout.

   It marks that the action is available. It is not a celebration, it never
   fires on attendance, and it MUST NOT pulse for attention while it waits.
   DECISIONS.md section 10 rule 2. */
.vsp-btn-iris {
  --vsp-iris: 0;
  position: relative;
  overflow: hidden;
  color: var(--vsp-ink-quiet);
  background: linear-gradient(180deg, var(--vsp-iris-idle-hi), var(--vsp-iris-idle-lo));
  border: 1px solid var(--vsp-iris-idle-edge);
  box-shadow: 0 1px 0 var(--vsp-iris-idle-edge) inset;
  transition: color 460ms var(--vsp-sp-settle), box-shadow 620ms var(--vsp-sp-settle);
}
.vsp-btn-iris > * { position: relative; z-index: 1; }
.vsp-btn-iris::before {
  content: "";
  position: absolute; inset: 0; z-index: 0;
  background: var(--vsp-iris-sweep);
  opacity: calc(var(--vsp-iris-trace) + (1 - var(--vsp-iris-trace)) * var(--vsp-iris));
  filter:
    saturate(calc(0.25 + 0.75 * var(--vsp-iris)))
    brightness(calc(0.6 + 0.4 * var(--vsp-iris)));
  transition: opacity 620ms var(--vsp-sp-settle), filter 620ms var(--vsp-sp-settle);
}
/* set once --vsp-iris passes --vsp-iris-lit-at, so the ink flips while the
   colour is already most of the way up rather than at the same instant */
.vsp-btn-iris.is-lit {
  color: var(--vsp-on-iris);
  border-color: transparent;
  box-shadow:
    0 1px 0 var(--vsp-iris-lip) inset,
    0 0 30px -6px var(--vsp-iris-glow),
    var(--vsp-sh-btn);
}

/* 44x44 is the floor for anything tappable, with 8px between neighbours. */
.vsp-circ {
  width: 46px; height: 46px;
  border-radius: 50%;
  display: grid; place-items: center;
  color: var(--vsp-ink);
  background: linear-gradient(180deg, var(--vsp-glass-hi), var(--vsp-glass-lo));
  -webkit-backdrop-filter: blur(16px) saturate(150%);
  backdrop-filter: blur(16px) saturate(150%);
  border: 1px solid var(--vsp-glass-edge);
  box-shadow: 0 1px 0 var(--vsp-glass-lip) inset;
  flex: none;
}

/* Feedback on pointer-down, never on release. The moment it waits for touch-up
   the whole thing stops feeling direct. */
.vsp-press { transition: transform 140ms var(--vsp-ease), filter 140ms ease; }
.vsp-press:active { transform: scale(0.985); filter: brightness(1.08); }

/* ── 6. Navigation ──────────────────────────────────────────────────────────
   Four tabs and no more.

   IT IS ABSENT FROM THE LESSON, THE PRODUCTION AND THE MARK, and that is not a
   layout preference. The cold explain-back is the only memory test in the
   system, and a test whose answer is one gesture away measures nothing. Adding
   a tab bar, a back gesture, a peek or a bottom sheet to those three screens
   breaks the product, not the design. DESIGN-BRIEF.md section 7. */

/* WHERE THE BAR IS, settled 2026-08-27. On the four tab destinations AND their
   sub-screens. Absent from the first run, from Sunday, and from the corridor.
   This replaces the earlier "Tonight and You and nowhere else", which was
   narrower than the mockup he signed off and would have left no way to reach
   Record or Ask from Record or Ask. DECISIONS.md section 11. */
.vsp-tabs {
  position: absolute;
  left: 50%; bottom: var(--vsp-s-7);
  transform: translateX(-50%);
  z-index: 5;
  transition: transform var(--vsp-t-move) var(--vsp-sp-move),
              opacity var(--vsp-t-fade) ease;
  display: flex;
  gap: 0.18rem;
  padding: 0.42rem;
  border-radius: var(--vsp-r-pill);
}
.vsp-tab {
  width: 58px; height: 46px;
  display: grid; place-items: center;
  border-radius: var(--vsp-r-pill);
  color: var(--vsp-ink-quiet);
  /* a tab is a button, and a button on iOS arrives with a fill and a border it
     was never asked for */
  background: none; border: 0; padding: 0; cursor: pointer;
  -webkit-appearance: none; appearance: none;
  transition: color 200ms ease, background var(--vsp-t-move) var(--vsp-ease);
}
.vsp-tab.is-on { color: var(--vsp-ink); }
.vsp-tab[aria-current="page"] { color: var(--vsp-ink); }

/* The lit pill SLIDES between tabs. It does not appear under the new one, and
   this is the clearest single piece of continuity in the app: the thing that
   says where you are is visibly the same object that was somewhere else. */
.vsp-tab-pill {
  position: absolute; top: 0.42rem; left: 0.42rem; z-index: 0;
  width: 58px; height: 46px;
  border-radius: var(--vsp-r-pill);
  background: var(--vsp-lit);
  box-shadow: 0 1px 0 var(--vsp-lit-lo) inset;
  transition: transform var(--vsp-t-move) var(--vsp-sp-move);
}
.vsp-tab { position: relative; z-index: 1; }

/* Entering the corridor, the bar LEAVES DOWNWARD. It does not vanish, because
   a thing that vanishes was never really there and he needs to feel that the
   way out has been taken away rather than hidden. */
.vsp-tabs.is-gone {
  transform: translateX(-50%) translateY(150%);
  opacity: 0;
  pointer-events: none;
}

/* Icons are drawn SVG at one stroke weight, 1.55, round caps and joins. Never a
   unicode glyph and never an emoji. The eight the app uses are one named set at
   design/system/icons.svg; OWED 19 closed the version where the four tab
   drawings were pasted inline seven times. Use them, do not retype them. */

/* ── 7. The marks ───────────────────────────────────────────────────────────
   Got it, half, missed. Three words, no fourth state, no number, no total.
   Colour is never the only carrier: the word is always present. */

.vsp-mark-row {
  display: flex; align-items: baseline; justify-content: space-between;
  gap: 1rem;
  padding: 0.82rem 0;
  border-bottom: 1px solid var(--vsp-hair);
}
.vsp-mark-name { font: 400 var(--vsp-t-body)/1.3 var(--vsp-ui); color: var(--vsp-ink-2); }
.vsp-mark-val  { font: 600 0.895rem/1.3 var(--vsp-ui); }
.vsp-got { color: var(--vsp-got); } .vsp-half { color: var(--vsp-half); }
.vsp-missed { color: var(--vsp-missed); }

/* The correction, and the only number he is ever shown, which is his own past
   record on the same mark. A 1px rule, never a coloured bar. */
.vsp-correction {
  font: 300 1.06rem/1.56 var(--vsp-read);
  color: var(--vsp-ink-read);
  border-left: 1px solid var(--vsp-tint);
  padding-left: var(--vsp-s-5);
  text-wrap: pretty;
}

/* ── 8. Writing, which is the default production ────────────────────────────
   He works nights in public for twelve hours. Writing is the default and
   speaking is one glass circle away, never blocked, and the default is his to
   flip in the You tab. */

.vsp-write { flex: 1; min-height: 0; padding-top: var(--vsp-s-7); }
.vsp-written { font: 300 1.1rem/1.6 var(--vsp-read); color: var(--vsp-ink-write);
               display: inline; text-wrap: pretty; }
.vsp-write-field {
  width: 100%; flex: 1; min-height: 0;
  background: none; border: 0; outline: none; resize: none;
  font: 300 1.1rem/1.6 var(--vsp-read);
  color: var(--vsp-ink-write);
  caret-color: var(--vsp-tint);
}
.vsp-write-field::placeholder { color: var(--vsp-ink-ghost); }
.vsp-caret {
  display: inline-block;
  width: 2px; height: 1.15em;
  margin-left: 2px; vertical-align: -0.2em;
  background: var(--vsp-tint);
  animation: vsp-blink 1.15s steps(1) infinite;
}
@keyframes vsp-blink { 0%, 55% { opacity: 1; } 56%, 100% { opacity: 0; } }

/* ── 9. Browser surfaces ────────────────────────────────────────────────────
   The parts nobody draws still carry the design. */

::selection { background: var(--vsp-tint-select); color: var(--vsp-ink); }
:focus-visible { outline: 2px solid var(--vsp-tint); outline-offset: 3px; border-radius: 6px; }
* { -webkit-tap-highlight-color: transparent; }

/* ── 10. Accessibility, three independent signals ───────────────────────────
   Reduced motion is not no feedback, it is gentler feedback. Reduced
   transparency makes the glass frosty rather than absent. */

/* Reduced motion is GENTLER FEEDBACK, NOT NO FEEDBACK. Travel becomes a
   cross-fade and the loops stop; nothing that carries information is removed,
   because the mark landing and the control lighting are information. */
@media (prefers-reduced-motion: reduce) {
  .vsp-ground, .vsp-ground-b { animation: none; }
  .vsp-caret { animation: none; opacity: 1; }
  .vsp-skel, .vsp-skel::after, .vsp-thinking i { animation: none; }
  .vsp-stagger > *, .vsp-mark-row, .vsp-correction { opacity: 1; transform: none; animation: none; }
  .vsp-screen, .vsp-tabs, .vsp-tab-pill { transition-duration: 1ms !important; }
  *, *::before, *::after {
    animation-duration: 1ms !important;
    transition-duration: 1ms !important;
  }
}

@media (prefers-reduced-transparency: reduce) {
  .vsp-glass, .vsp-glass-heavy, .vsp-circ {
    -webkit-backdrop-filter: none;
    backdrop-filter: none;
    background: var(--vsp-glass-solid);
  }
}

/* These used to be the fix and are now the baseline, so this block has to go
   further than it did or it would quietly LOWER the contrast it exists to
   raise. Checked: every value here is at or above the default above it. */
@media (prefers-contrast: more) {
  :root {
    --vsp-ink-2:     rgba(244, 240, 233, 0.94);
    --vsp-ink-3:     rgba(244, 240, 233, 0.9);
    --vsp-ink-small: rgba(244, 240, 233, 0.92);
    --vsp-ink-quiet: rgba(244, 240, 233, 0.72);
    --vsp-hair:      rgba(244, 240, 233, 0.4);
    --vsp-scrim:     rgba(4, 5, 9, 0.62);   /* must go FURTHER than the 0.46 baseline, not merely differ */
  }
  .vsp-glass, .vsp-glass-heavy { background: var(--vsp-glass-solid-hc); }
}

/* ── 11. Motion ─────────────────────────────────────────────────────────────
   His ruling, DECISIONS.md section 10: "I want nothing still, want the app to
   feel fluid alive moving about cool animations, transitions and everything
   inbetween."

   Everything in this section answers to one line, and a cold agent adding to
   it answers to the same line:

       MOTION CARRIES CONTINUITY AND LIFE. IT NEVER CARRIES PRAISE.

   Working reference: design/mockups/motion-v1.html, which is a real prototype
   rather than a picture of one. If something here is ambiguous, that file is
   what it means.

   THE FOUR THINGS MOTION MAY NEVER DO, which are section 9 restated as physics:
     1. Nothing bounces to congratulate him. The marks land at identical speed
        and on the identical curve whether they say got it or missed. A springier
        good mark is a score with the number taken off.
     2. Nothing pulses to demand him. No attention loop on an unpressed control.
     3. Nothing animates a total filling. No count-up, no bar growing, no ring
        closing. A number easing toward a figure is a progress ring in disguise.
     4. Nothing moves in the corridor that offers a way out. See section 6. */

/* 11a. Screens travel. A screen arrives from the direction he came from and
   leaves the way he goes. Nothing in this app appears. */
.vsp-screen {
  position: absolute; inset: 0;
  will-change: transform;
  transition: transform var(--vsp-t-move) var(--vsp-sp-move);
}
/* The screen underneath a pushed one drifts back and dims, so depth is felt
   rather than implied by a shadow. */
.vsp-screen.is-behind {
  transform: translate3d(-22%, 0, 0);
  filter: brightness(0.72);
  transition: transform var(--vsp-t-move) var(--vsp-sp-move),
              filter var(--vsp-t-move) ease;
}
/* The corridor is the exception: what is behind it is GONE, not dimmed. He
   must not be able to see Tonight through the writing surface. Cold means
   cold, and it means visually as well as structurally. */
.vsp-screen.is-gone-behind { opacity: 0; }

/* 11b. Content assembles. A screen stitches itself in a few tens of
   milliseconds at a time instead of blinking on whole. Set --vsp-i per child. */
.vsp-stagger > * { opacity: 0; transform: translate3d(0, 14px, 0); }
.vsp-stagger.is-live > *, .is-live .vsp-stagger > *, .is-in .vsp-stagger > * {
  animation: vsp-rise 520ms var(--vsp-sp-settle) forwards;
  animation-delay: calc(var(--vsp-i, 0) * var(--vsp-t-stagger));
}
@keyframes vsp-rise {
  from { opacity: 0; transform: translate3d(0, 14px, 0); }
  to   { opacity: 1; transform: translate3d(0, 0, 0); }
}

/* A screen is revealed the first time it can be seen, INCLUDING part way
   through a drag. An incoming screen that is still blank while his finger is
   on it gives the whole gesture away. Re-stagger on a tab tap, never on a
   swipe: the swipe already shows him the content arriving. */

/* 11c. Gestures move real pixels. The finger drags the screen, and letting go
   finishes the move it was already making, at the speed it was going. Both
   ends of the pager resist at about a third, so an edge feels like an edge. */

/* 11d. The marks. Identical delay step, identical curve, no per-state
   variation. This is rule 1 and it is the one an enthusiastic future agent
   will try to break. */
.vsp-mark-row { opacity: 0; transform: translate3d(0, 10px, 0); }
.vsp-mark-row.is-in {
  animation: vsp-rise 460ms var(--vsp-sp-settle) forwards;
  animation-delay: calc(var(--vsp-i, 0) * 110ms);
}
.vsp-correction { opacity: 0; transform: translate3d(0, 10px, 0); }
.vsp-correction.is-in { animation: vsp-rise 520ms var(--vsp-sp-settle) 560ms forwards; }
/* The way on arrives last, after the mark has actually landed. He cannot skip
   past a mark he has not been shown. */

/* 11e. Marking, the wait. Bars where the four marks will land and a block
   where the correction goes, breathing in width with a sheen crossing them, so
   it reads as thinking rather than as a spinner. Then it HANDS OVER: the
   skeleton lifts and fades as the real thing rises into the same place. */
.vsp-skel {
  height: 13px;
  border-radius: var(--vsp-r-bar);
  background: var(--vsp-skel);
  position: relative;
  overflow: hidden;
  transform-origin: left center;
  animation: vsp-breathe 2.6s ease-in-out infinite;
  animation-delay: calc(var(--vsp-i, 0) * 180ms);
}
.vsp-skel-tall { height: 64px; border-radius: 16px; }
.vsp-skel::after {
  content: "";
  position: absolute; inset: 0;
  background: linear-gradient(100deg, transparent 18%, var(--vsp-skel-sheen) 50%, transparent 82%);
  transform: translateX(-110%);
  animation: vsp-sheen 1.9s var(--vsp-sp-move) infinite;
  animation-delay: calc(var(--vsp-i, 0) * 150ms);
}
@keyframes vsp-sheen { to { transform: translateX(110%); } }
@keyframes vsp-breathe {
  0%, 100% { transform: scaleX(1);     opacity: 0.9; }
  50%      { transform: scaleX(1.035); opacity: 1; }
}

/* 11f. Confirmation lives on the thing that changed. His OWED 8 ruling: no
   toast. The row's own second line becomes the new value, holds the got-it
   colour, and settles back. Nothing floats over the interface to tell him
   something he can already see. */
.vsp-row-sub { transition: color 420ms var(--vsp-sp-settle); }
.vsp-row-sub.is-saved { color: var(--vsp-got); }

/* 11g. Touch feedback is on pointer-down, never on release, and a control that
   is being dragged tracks the finger. A switch knob stretches under the thumb
   and settles with --vsp-sp-carry, which is momentum he supplied himself and
   is the only overshoot in the system. */

/* ── 12. The furniture ──────────────────────────────────────────────────────
   Added 2026-08-27, CHECKLIST.md OWED 19. Everything here already existed in a
   mockup he has seen and ruled against, and was living as loose CSS in an HTML
   file where a second screen would have had to retype it. Nothing here is a new
   decision.

   WHAT IS DELIBERATELY NOT HERE, because he has not ruled on it: the
   confirmation that lets him leave the corridor early (OWED 18), the control
   that sets the end of an Away (DECISIONS.md 6.4 names two different controls),
   and the delete affordance on a line in "What it knows" (OWED 9). A cold agent
   that needs one of those asks him. It does not invent one. */

/* 12a. A settings row. The sub-line is the confirmation: his OWED 8 ruling. */
.vsp-row {
  display: flex; align-items: center; justify-content: space-between;
  gap: var(--vsp-s-4);
  padding: 0.82rem var(--vsp-s-4);
}
.vsp-row-t { font: 500 0.875rem/1.32 var(--vsp-ui); letter-spacing: -0.008em; }
.vsp-row-sub {
  display: block; margin-top: 0.15rem;
  font: 400 0.7rem/1.4 var(--vsp-ui);
  color: var(--vsp-ink-small);
}

/* 12b. A card that goes somewhere, and the chevron that says so. */
.vsp-card { padding: var(--vsp-s-4) 0.95rem; display: flex; align-items: center; gap: 0.7rem; }
.vsp-card-body { display: grid; gap: 0.18rem; min-width: 0; flex: 1; }
.vsp-chev { flex: none; color: var(--vsp-ink-quiet); }

/* 12c. The intake option. Tap to choose, never type: his ruling, DECISIONS.md
   section 4. .is-picked is the state that was in look-v1.html and never made it
   into the system, which is the bug OWED 19 names. */
.vsp-opt {
  padding: 0.95rem var(--vsp-s-5);
  display: flex; align-items: center; justify-content: space-between;
  gap: 0.8rem;
  font: 500 0.985rem/1.3 var(--vsp-ui);
  letter-spacing: -0.008em;
  /* A button centres its text by default, and an option with a why-line under
     it then reads as two centred lines of different widths. Caught by drawing
     the fourteen intake questions, 2026-08-27, the same way the tab-as-button
     bug was caught: the mockup used a div and the real control is a button. */
  text-align: left;
  cursor: pointer;
  transition: transform 140ms var(--vsp-sp-settle), filter 140ms ease;
}
.vsp-opt:active { transform: scale(0.985); }
.vsp-opt-why { display: block; font: 400 0.765rem/1.4 var(--vsp-ui);
               color: var(--vsp-ink-small); margin-top: 0.22rem; }
.vsp-opt.is-picked {
  border-color: var(--vsp-lit-edge);
  background: linear-gradient(180deg, var(--vsp-lit-hi), var(--vsp-lit-lo));
  box-shadow: 0 1px 0 var(--vsp-lit-lip) inset, var(--vsp-sh-card);
}
/* The tick is a state, not a decoration: colour alone never carries a choice. */
.vsp-opt-tick { opacity: 0; transform: scale(0.8);
                transition: opacity 220ms var(--vsp-sp-settle), transform 220ms var(--vsp-sp-carry); }
.vsp-opt.is-picked .vsp-opt-tick { opacity: 1; transform: scale(1); }

/* 12d. The switch. The knob stretches under the thumb and settles on the carry
   curve, which is the one overshoot in the system and he supplied the momentum
   for it himself. */
.vsp-switch {
  width: 44px; height: 26px; border-radius: var(--vsp-r-pill); flex: none; position: relative;
  background: var(--vsp-track);
  border: 1px solid var(--vsp-track-edge);
  cursor: pointer;
  transition: background 300ms var(--vsp-sp-settle);
}
.vsp-switch::after {
  content: ""; position: absolute; top: 2px; left: 2px;
  width: 20px; height: 20px; border-radius: 50%;
  background: linear-gradient(180deg, var(--vsp-solid-hi), var(--vsp-knob-lo));
  box-shadow: var(--vsp-sh-knob);
  transition: transform 320ms var(--vsp-sp-carry), width 200ms var(--vsp-sp-settle);
}
.vsp-switch.is-on { background: var(--vsp-tint-soft); }
.vsp-switch.is-on::after { transform: translateX(18px); }
.vsp-switch:active::after { width: 25px; }
.vsp-switch.is-on:active::after { transform: translateX(13px); }

/* 12e. The stepper, which is how the push hour moves. The digits CHANGE, they
   never count up: motion rule 3. */
.vsp-stepper { display: flex; gap: var(--vsp-s-3); margin-top: 1.1rem; }
.vsp-step {
  flex: 1; padding: 0.7rem; border-radius: var(--vsp-r-field); text-align: center;
  font: 600 0.82rem/1.2 var(--vsp-ui); cursor: pointer; color: var(--vsp-ink-2);
  background: linear-gradient(180deg, var(--vsp-glass-hi), var(--vsp-glass-lo));
  border: 1px solid var(--vsp-glass-edge);
  transition: transform var(--vsp-t-press) var(--vsp-sp-settle), color 200ms ease;
}
.vsp-step:active { transform: scale(0.95); color: var(--vsp-ink); }
.vsp-figure { font: 500 2.7rem/1 var(--vsp-ui); letter-spacing: -0.042em;
              font-variant-numeric: tabular-nums; }

/* 12f. A text field. The one he types a question into. */
.vsp-field { padding: 0.9rem var(--vsp-s-5); display: flex; align-items: center;
             gap: var(--vsp-s-3); cursor: text; }
.vsp-field input {
  flex: 1; background: none; border: 0; outline: none; color: var(--vsp-ink);
  font: 300 0.98rem/1.4 var(--vsp-read);
  caret-color: var(--vsp-tint);
}
.vsp-field input::placeholder { color: var(--vsp-ink-ghost); }

/* 12g. The back control on a You sub-screen. One of his three ways out, OWED
   12: this, the lit tab, and the swipe. It is never in the corridor. */
.vsp-back {
  display: flex; align-items: center; gap: 0.4rem;
  padding: 1.1rem 0 0; flex: none; align-self: flex-start;
  color: var(--vsp-ink-2); font: 500 0.85rem/1.2 var(--vsp-ui); cursor: pointer;
  background: none; border: 0;
  transition: transform var(--vsp-t-press) var(--vsp-sp-settle), color 200ms ease;
}
.vsp-back:active { transform: translateX(-3px); color: var(--vsp-ink); }

/* 12h. Empty states. His OWED 4 ruling: THE SENTENCE AND A QUIET WAY IN, both,
   never a cheerful call to action and never a bare sentence with no door. The
   way in is a text control at label weight, not a button, because an empty
   screen must not shout louder than a full one. */
.vsp-empty { margin: auto 0; display: grid; gap: 1.1rem; justify-items: start; padding: var(--vsp-s-8) 0; }
.vsp-empty p { font: 300 1.02rem/1.56 var(--vsp-read); color: var(--vsp-ink-read);
               margin: 0; text-wrap: pretty; }
.vsp-quiet-in {
  font: 400 0.75rem/1.3 var(--vsp-ui); color: var(--vsp-ink-3);
  text-decoration: underline; text-underline-offset: 3px;
  background: none; border: 0; padding: 0; cursor: pointer;
}
.vsp-quiet-in:active { color: var(--vsp-ink); }

/* 12i. Thinking. Three dots on the tint, and it is the ONLY loop in the app
   that runs while nothing is on screen. It is allowed because it is telling him
   something true: a model is working. It stops the instant the answer lands. */
.vsp-thinking { display: flex; gap: 5px; align-items: center; height: 10px; }
.vsp-thinking i {
  width: 6px; height: 6px; border-radius: 50%; background: var(--vsp-tint); display: block;
  animation: vsp-think 1.25s ease-in-out infinite;
}
.vsp-thinking i:nth-child(2) { animation-delay: 0.14s; }
.vsp-thinking i:nth-child(3) { animation-delay: 0.28s; }
@keyframes vsp-think {
  0%, 100% { opacity: 0.28; transform: translateY(0); }
  40%      { opacity: 0.95; transform: translateY(-4px); }
}

/* 12j. An answer arrives a clause at a time rather than as a block, which is
   11b applied to a sentence. Set --vsp-i per span. */
.vsp-clauses span {
  opacity: 0; display: inline;
  animation: vsp-rise 460ms var(--vsp-sp-settle) forwards;
  animation-delay: calc(var(--vsp-i, 0) * 120ms);
}
