/* Same opt-out at the stylesheet level (some engines only read the CSS form),
   and it also pins form controls and scrollbars to the light palette
   instead of letting them switch to a system dark variant mid-page. */
:root {
    color-scheme: only light;
}
/* High-contrast / forced dark palettes must not repaint the drawing either:
   the system palette replaces author colours, which would flatten her skin,
   hair, apron and eyes into two system colours. The bust is artwork, so it
   keeps its own palette; the UI text and buttons around it still honour the
   user's system colours. */
@media (forced-colors: active) {
    .doll-portrait,
    .doll-portrait .dp-svg {
        forced-color-adjust: none;
    }
}
* {
    box-sizing: border-box;
    font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
}
body {
    background-color: #2c1d11;
    color: #f4e8c1;
    display: flex;
    justify-content: center;
    /* Step 27: a flex-centred item that is taller than its container is pushed off
       the TOP of an area the page cannot scroll to. The ending's recap did exactly
       that: the card grew past the window, and the doll, the title and the HUD were
       gone with it. `safe center` centres her while she fits and falls back to
       top-aligning the moment she does not, so every pixel stays reachable; the
       plain declaration above it is the fallback for engines that lack `safe`. */
    align-items: flex-start;
    align-items: safe center;
    min-height: 100vh;
    margin: 0;
    padding: 20px;
}
.game-card {
    background-color: #3d2b1f;
    border: 2px solid #8c6d53;
    border-radius: 12px;
    padding: 25px;
    width: 100%;
    max-width: 500px;
    box-shadow: 0 8px 24px rgba(0,0,0,0.5);
    text-align: center;
}
h1 {
    color: #d4a373;
    margin-top: 0;
    font-size: 1.5rem;
    border-bottom: 1px solid #8c6d53;
    padding-bottom: 10px;
}
.log-box {
    background-color: #1e130c;
    border: 1px solid #5c4033;
    border-radius: 8px;
    padding: 15px;
    text-align: left;
    font-size: 0.95rem;
    line-height: 1.5;
    min-height: 140px;
    margin-bottom: 20px;
}
/* Step 27: the ENDING screen's log is the whole run (Step 11 still keeps every
   MID-RUN screen to its own scene), which used to make the card about twice the
   height of a laptop window. The recap now scrolls inside its own pane instead of
   stretching the card, so the portrait, the closing text and the replay button are
   all on screen at once. Measured before the fix: the recap ran to 1742px on a
   900px window and pushed the card's top 462px above the viewport. A mid-run
   screen is far shorter than 30vh, so nothing changes for those. */
.log-box.log-recap {
    max-height: 30vh;
    overflow-y: auto;
    overscroll-behavior: contain;
    scrollbar-width: thin;
}
.btn-group {
    display: flex;
    flex-wrap: wrap;          /* Step 8: a scene can offer 1-4 options */
    gap: 10px;
    justify-content: center;
}
/* Choice buttons size to their label and wrap onto extra rows, so a
   single forced "⏩ Συνέχεια" button stretches full width while a
   four-option scene degrades to two rows instead of four slivers. */
.btn-group button {
    flex: 1 1 auto;
    min-width: 140px;
}
button {
    background-color: #d4a373;
    color: #1e130c;
    border: none;
    padding: 12px 20px;
    border-radius: 6px;
    font-weight: bold;
    cursor: pointer;
    transition: background 0.2s, transform 0.1s;
    flex: 1;
}
button:hover {
    background-color: #faedcd;
}
button:active {
    transform: scale(0.98);
}
button:disabled {
    background-color: #5c4033;
    color: #8c6d53;
    cursor: not-allowed;
}
.status-badge {
    margin-top: 15px;
    padding: 8px;
    border-radius: 5px;
    font-weight: bold;
    display: none;
}
.badge-warning {
    background-color: #721c24;
    color: #f8d7da;
}
.badge-success {
    background-color: #155724;
    color: #d4edda;
}
/* --- Step 3: reputation counter, achievements panel, unlock toasts --- */
.hud {
    display: flex;
    gap: 10px;
    align-items: center;
    justify-content: center;
    flex-wrap: wrap;
    margin-bottom: 14px;
}
.reputation {
    background-color: #1e130c;
    border: 1px solid #8c6d53;
    border-radius: 999px;
    padding: 5px 14px;
    font-size: 0.85rem;
    color: #f4e8c1;
}
.reputation b {
    color: #d4a373;
}
.reputation.rep-good {
    border-color: #d4a373;
}
.reputation.rep-bad {
    border-color: #721c24;
    color: #f8d7da;
}
/* Step 3 keyframe, extended in Step 5 instead of duplicated: same
   background flash, plus a small scale nudge, and shortened to the
   150-400ms budget the other Step 5 animations use. */
.reputation.rep-flash {
    animation: repFlash 0.4s ease-out;
}
@keyframes repFlash {
    0% { background-color: #8c6d53; transform: scale(1.06); }
    100% { background-color: #1e130c; transform: scale(1); }
}
.hud-btn {
    flex: 0 0 auto;
    background-color: transparent;
    border: 1px solid #8c6d53;
    color: #d4a373;
    padding: 5px 14px;
    border-radius: 999px;
    font-size: 0.85rem;
}
.hud-btn:hover {
    background-color: #1e130c;
    border-color: #d4a373;
    color: #faedcd;
}
/* Step 5: the panel is collapsed/expanded with a CSS transition instead
   of display:none, so opening and closing can be animated. The `open`
   class is toggled by toggleAchievements(); the delayed visibility keeps
   the reset button out of the tab order while the panel is shut. */
.achievements-panel {
    background-color: #1e130c;
    border: 0 solid #5c4033;
    border-radius: 8px;
    padding: 0 12px;
    margin-bottom: 16px;
    text-align: left;
    max-height: 0;
    opacity: 0;
    visibility: hidden;
    overflow: hidden;
    transition: max-height 0.3s ease, opacity 0.3s ease, padding 0.3s ease,
                border-width 0.3s ease, visibility 0s linear 0.3s;
}
.achievements-panel.open {
    border-width: 1px;
    padding: 12px;
    /* Generous cap: taller than any realistic content, so long Greek
       descriptions can never be clipped on narrow screens. The fade
       (opacity) carries the animation, so the exact height is not
       visible as a timing artefact. */
    max-height: 1000px;
    opacity: 1;
    visibility: visible;
    transition: max-height 0.3s ease, opacity 0.3s ease, padding 0.3s ease,
                border-width 0.3s ease, visibility 0s;
}
.achievements-title {
    color: #d4a373;
    font-weight: bold;
    font-size: 0.9rem;
    margin-bottom: 8px;
}
.achievements-list {
    list-style: none;
    margin: 0;
    padding: 0;
    display: flex;
    flex-direction: column;
    gap: 6px;
}
.achievement {
    font-size: 0.82rem;
    line-height: 1.4;
    padding: 6px 8px;
    border-radius: 6px;
    border: 1px dashed #5c4033;
    color: #8c6d53;
}
.achievement b {
    display: block;
    color: #8c6d53;
}
.achievement.unlocked {
    border-style: solid;
    border-color: #8c6d53;
    background-color: #2c1d11;
    color: #f4e8c1;
}
.achievement.unlocked b {
    color: #d4a373;
}
.toast-stack {
    position: fixed;
    left: 50%;
    bottom: 20px;
    transform: translateX(-50%);
    display: flex;
    flex-direction: column;
    align-items: center;
    gap: 8px;
    pointer-events: none;
    z-index: 50;
}
.toast {
    background-color: #1e130c;
    border: 1px solid #d4a373;
    color: #f4e8c1;
    border-radius: 999px;
    padding: 8px 16px;
    font-size: 0.85rem;
    font-weight: bold;
    box-shadow: 0 6px 18px rgba(0,0,0,0.5);
    animation: toastIn 0.25s ease-out;
}
@keyframes toastIn {
    from { opacity: 0; transform: translateY(8px); }
    to { opacity: 1; transform: translateY(0); }
}
/* --- Step 4: reset-progress button inside the achievements panel --- */
.panel-btn {
    flex: 0 0 auto;
    width: 100%;
    margin-top: 10px;
    background-color: transparent;
    border: 1px solid #8c6d53;
    color: #d4a373;
    padding: 8px 14px;
    border-radius: 6px;
    font-size: 0.8rem;
}
.panel-btn:hover {
    background-color: #2c1d11;
    border-color: #721c24;
    color: #f8d7da;
}
/* --- Step 5: lightweight sound-animation polish (CSS only) --- */
/* Log box: fade + slide in whenever a stage redraws the story text. */
.log-box.log-enter {
    animation: logEnter 0.2s ease-out;
}
@keyframes logEnter {
    from { opacity: 0.25; transform: translateY(6px); }
    to { opacity: 1; transform: translateY(0); }
}
/* Status badge: small bounce as it appears at the end of a run. */
.status-badge.badge-pop {
    animation: badgePop 0.24s ease-out;
}
@keyframes badgePop {
    0% { transform: scale(0.94); }
    60% { transform: scale(1.03); }
    100% { transform: scale(1); }
}
/* --- Step 6: fade the old log text out before the new text fades in --- */
/* The pair to updateLogBox(): the swap waits for THIS animation (its
   `finished` promise, see updateLogBox()), not for a stopwatch started
   when the class was added — the animation clock only begins on the next
   frame, so a fixed timer used to cut the fade off early. `forwards` holds
   the old text at opacity 0 while the browser paints the beat between the
   two, and paintLog() then removes the class before the Step 5 fade/slide-in. */
.log-box.log-exit {
    animation: logExit 0.18s ease-out forwards;
}
@keyframes logExit {
    from { opacity: 1; }
    to { opacity: 0; }
}
/* ==========================================================
   Step 13/14 — animated portrait of the run's barista.

   The drawing is plain inline SVG — flat vector: solid fills, a couple
   of strokes, no gradients, no filters, no external images — and the
   motion is pure CSS on three groups inside it:

     .dp-sway   the whole figure: slow breathing + a very small lean
     .dp-head   head + hair + face: a slow, composed head tilt
     .dp-eye    one group per eye: squashed vertically = the blink

   Step 14 generates that SVG per doll (portraitSvg() in the script), so
   nothing here is tied to one barista: the same three classes animate
   whoever is on shift, and a doll without a look on file simply leaves
   the box hidden.
   ========================================================== */
.doll-portrait {
    display: block;
    width: 150px;          /* ~120-180px: a bust, not a full figure */
    height: 150px;
    margin: 0 auto 12px;   /* centred, with air above the text card */
    line-height: 0;        /* kills the inline-element gap under the SVG */
}
/* The author rule above would otherwise beat the UA's own
   [hidden] { display: none }, so the portrait has to be hidden
   explicitly — without this it would follow every barista instead of
   only the one it was drawn for. */
.doll-portrait[hidden] {
    display: none;
}
.dp-svg {
    display: block;
    width: 100%;
    height: 100%;
}
/* Breathing: 7s, eased in and out, and never more than ~1.4 user units of
   travel — a composed sway rather than a bounce. transform-box: fill-box
   makes transform-origin a percentage of the GROUP'S OWN bounding box
   (SVG has no CSS box of its own), so the figure leans around its base,
   the way a standing person does. */
.dp-sway {
    transform-box: fill-box;
    transform-origin: 50% 100%;
    animation: dpSway 7s ease-in-out infinite;
}
@keyframes dpSway {
    0%   { transform: translateY(0) rotate(0deg); }
    50%  { transform: translateY(-1.4px) rotate(-0.4deg); }
    100% { transform: translateY(0) rotate(0deg); }
}
/* Head tilt: slower than the breathing (11s) and off-phase with it, so
   the two never read as one mechanical loop. The 0% pose holds the head
   a fraction of a degree off dead-level — her default, composed bearing
   — and the base `transform` below repeats it, so `animation: none` (the
   reduced-motion case) freezes her in exactly that pose. */
.dp-head {
    transform-box: fill-box;
    transform-origin: 50% 96%;   /* the neck: the tilt pivots there */
    transform: rotate(0.6deg);   /* the pose reduced motion freezes to */
    animation: dpTilt 11s ease-in-out infinite;
}
@keyframes dpTilt {
    0%   { transform: rotate(0.6deg); }
    35%  { transform: rotate(-1.1deg); }
    70%  { transform: rotate(0.9deg); }
    100% { transform: rotate(0.6deg); }
}
/* Blink: one 5.2s cycle, open for ~90% of it and shut for ~0.3s — an
   occasional, unhurried blink. Each eye is its own group, so the squash
   happens about that eye's own centre; both groups carry the same
   declaration and start together, so the pair stays in sync. Squashing
   the whole eye (whites + iris + lash line) turns it into a straight
   lash line, which is what a closed eye reads as at this size. */
.dp-eye {
    transform-box: fill-box;
    transform-origin: 50% 50%;
    animation: dpBlink 5.2s ease-in-out infinite;
}
@keyframes dpBlink {
    0%, 90%   { transform: scaleY(1); }
    93%       { transform: scaleY(0.07); }   /* squashed = the closed lid */
    96%, 100% { transform: scaleY(1); }
}
/* Step 25 — the nine score-reactive moods.
   The portrait box carries `data-mood`, one of nine values from -4 to +4 (the
   running politeness sum clamped to the spectrum). This table is the whole
   spectrum and the ONLY thing the score changes: every state sets the same
   eight custom properties, so a state can never silently inherit a neighbour's
   value, and a portrait with no `data-mood` at all (or the identity column 0)
   renders exactly the drawing Steps 13-23 signed off.

     level  brows                 mouth               blush  eyes  sparkle
     -4     inner ends slammed    narrow, pressed flat 0.30   0.78  —
     -3     down (the angry V)    progressively       0.45   0.83  —
     -2     eases inward/down     rounder again       0.62   0.88  —
     -1                           nearly hers         0.82   0.94  —
      0     HER OWN brows, as drawn  HER OWN mouth, 1:1   1.00   1.00  —
     +1     inner ends lift,      a shade wider,      1.12   1.00  —
     +2     outer ends drop       fuller each step    1.24   0.98  —
     +3     (the warm brow)       rounder             1.36   0.95  —
     +4     raised and softened   widest, fullest     1.50   0.92  yes

   `--dp-brow-tilt` is signed: positive pulls the INNER ends down (each brow
   rotating about its own inner end, so the pair reads as a V rather than both
   leaning one way), negative lifts the inner ends and lets the outer ones drop.
   `--dp-mouth-y`/`--dp-mouth-x` scale her authored lips about their own centre,
   which presses the curve out of them or rounds the same curve up — the layer
   deforms HER expression instead of replacing it. Blush is a multiplier on her
   own base (so a sultry doll stays the blushier one at every level), the eye
   squint sits inside the blink group, and the sparkle is the +4 flourish. */
.doll-portrait[data-mood="-4"] { --dp-brow-tilt: 26deg; --dp-brow-lift: 3.2px; --dp-brow-squeeze: 1.6px; --dp-mouth-x: 0.84; --dp-mouth-y: 0.42; --dp-mouth-drop: 1.1px; --dp-blush-mood: 0.28; --dp-eye-squint: 0.7; --dp-furrow: 1; --dp-crease-down: 1; --dp-crease-up: 0; --dp-sparkle: 0; --dp-lid-mood: 1.5; --dp-smoke-mood: 1.6; --dp-sway-speed: 0.7; }
.doll-portrait[data-mood="-3"] { --dp-brow-tilt: 20deg; --dp-brow-lift: 2.5px; --dp-brow-squeeze: 1.2px; --dp-mouth-x: 0.89; --dp-mouth-y: 0.58; --dp-mouth-drop: 0.85px; --dp-blush-mood: 0.42; --dp-eye-squint: 0.76; --dp-furrow: 0.7; --dp-crease-down: 0.75; --dp-crease-up: 0; --dp-sparkle: 0; --dp-lid-mood: 1.36; --dp-smoke-mood: 1.45; --dp-sway-speed: 0.78; }
.doll-portrait[data-mood="-2"] { --dp-brow-tilt: 13deg; --dp-brow-lift: 1.6px; --dp-brow-squeeze: 0.8px; --dp-mouth-x: 0.93; --dp-mouth-y: 0.72; --dp-mouth-drop: 0.55px; --dp-blush-mood: 0.6; --dp-eye-squint: 0.83; --dp-furrow: 0.35; --dp-crease-down: 0.45; --dp-crease-up: 0; --dp-sparkle: 0; --dp-lid-mood: 1.22; --dp-smoke-mood: 1.28; --dp-sway-speed: 0.88; }
.doll-portrait[data-mood="-1"] { --dp-brow-tilt: 6deg; --dp-brow-lift: 0.8px; --dp-brow-squeeze: 0.4px; --dp-mouth-x: 0.97; --dp-mouth-y: 0.86; --dp-mouth-drop: 0.3px; --dp-blush-mood: 0.8; --dp-eye-squint: 0.92; --dp-furrow: 0.12; --dp-crease-down: 0.15; --dp-crease-up: 0.1; --dp-sparkle: 0; --dp-lid-mood: 1.1; --dp-smoke-mood: 1.12; --dp-sway-speed: 0.96; }
.doll-portrait[data-mood="0"] { --dp-brow-tilt: 0deg; --dp-brow-lift: 0px; --dp-brow-squeeze: 0px; --dp-mouth-x: 1; --dp-mouth-y: 1; --dp-mouth-drop: 0px; --dp-blush-mood: 1; --dp-eye-squint: 1; --dp-furrow: 0; --dp-crease-down: 0; --dp-crease-up: 0; --dp-sparkle: 0; --dp-lid-mood: 1; --dp-smoke-mood: 1; --dp-sway-speed: 1; }
.doll-portrait[data-mood="1"] { --dp-brow-tilt: -8deg; --dp-brow-lift: -1.1px; --dp-brow-squeeze: -0.3px; --dp-mouth-x: 1.03; --dp-mouth-y: 1.14; --dp-mouth-drop: -0.15px; --dp-blush-mood: 1.14; --dp-eye-squint: 1.02; --dp-furrow: 0; --dp-crease-down: 0; --dp-crease-up: 0.3; --dp-sparkle: 0; --dp-lid-mood: 0.95; --dp-smoke-mood: 0.95; --dp-sway-speed: 1.04; }
.doll-portrait[data-mood="2"] { --dp-brow-tilt: -14deg; --dp-brow-lift: -2px; --dp-brow-squeeze: -0.6px; --dp-mouth-x: 1.07; --dp-mouth-y: 1.25; --dp-mouth-drop: -0.25px; --dp-blush-mood: 1.28; --dp-eye-squint: 1.04; --dp-furrow: 0; --dp-crease-down: 0; --dp-crease-up: 0.55; --dp-sparkle: 0; --dp-lid-mood: 0.9; --dp-smoke-mood: 0.9; --dp-sway-speed: 1.07; }
.doll-portrait[data-mood="3"] { --dp-brow-tilt: -20deg; --dp-brow-lift: -3px; --dp-brow-squeeze: -0.9px; --dp-mouth-x: 1.12; --dp-mouth-y: 1.38; --dp-mouth-drop: -0.15px; --dp-blush-mood: 1.42; --dp-eye-squint: 1.05; --dp-furrow: 0; --dp-crease-down: 0; --dp-crease-up: 0.8; --dp-sparkle: 0.6; --dp-lid-mood: 0.85; --dp-smoke-mood: 0.85; --dp-sway-speed: 1.1; }
.doll-portrait[data-mood="4"] { --dp-brow-tilt: -27deg; --dp-brow-lift: -4.2px; --dp-brow-squeeze: -1.2px; --dp-mouth-x: 1.18; --dp-mouth-y: 1.5; --dp-mouth-drop: 0px; --dp-blush-mood: 1.55; --dp-eye-squint: 1.06; --dp-furrow: 0; --dp-crease-down: 0; --dp-crease-up: 1; --dp-sparkle: 1; --dp-lid-mood: 0.8; --dp-smoke-mood: 0.8; --dp-sway-speed: 1.12; }
/* The reaction itself: 200ms sits inside the game's 150-300ms budget, short
   enough that the mood landing between two clicks still reads as a reply to
   the one just made. Only the mood wrappers transition — the idle sway, tilt
   and blink keep their own, much slower loops, untouched. */
.doll-portrait[data-mood] .dp-mood-brow-l,
.doll-portrait[data-mood] .dp-mood-brow-r,
.doll-portrait[data-mood] .dp-mood-mouth,
.doll-portrait[data-mood] .dp-mood-eye {
    transition: transform 200ms ease-out;
}
.doll-portrait[data-mood] .dp-blush,
.doll-portrait[data-mood] .dp-sparkle,
.doll-portrait[data-mood] .dp-furrow,
.doll-portrait[data-mood] .dp-crease-down,
.doll-portrait[data-mood] .dp-crease-up,
.doll-portrait[data-mood] .dp-smoke {
    transition: opacity 200ms ease-out;
}
.doll-portrait[data-mood] .dp-lid {
    transition: stroke-width 200ms ease-out;
}
.doll-portrait .dp-mood-brow,
.doll-portrait .dp-mood-mouth,
.doll-portrait .dp-mood-eye {
    transform-box: fill-box;   /* SVG has no CSS box: use the group's own */
}
.doll-portrait .dp-mood-brow-l {
    transform-origin: 100% 50%;   /* pivots on the inner end of her left brow */
    transform: translate(var(--dp-brow-squeeze, 0px), var(--dp-brow-lift, 0px)) rotate(var(--dp-brow-tilt, 0deg));
}
.doll-portrait .dp-mood-brow-r {
    transform-origin: 0% 50%;     /* the right one, mirrored about its inner end */
    transform: translate(calc(-1 * var(--dp-brow-squeeze, 0px)), var(--dp-brow-lift, 0px)) rotate(calc(-1 * var(--dp-brow-tilt, 0deg)));
}
.doll-portrait .dp-mood-mouth {
    transform-origin: 50% 50%;
    transform: translateY(var(--dp-mouth-drop, 0px)) scale(var(--dp-mouth-x, 1), var(--dp-mouth-y, 1));
}
.doll-portrait .dp-mood-eye {
    transform-origin: 50% 50%;
    transform: scaleY(var(--dp-eye-squint, 1));
}
/* Her own values, scaled rather than replaced — so the level never flattens the
   sultry/plain difference: the blush (0.45/0.6), the smoky wash (0.28/0.38, plus
   the sultry-only wide pass at 0.16) and the lid weight. */
.doll-portrait .dp-blush {
    opacity: calc(var(--dp-blush-base, 0.45) * var(--dp-blush-mood, 1));
}
.doll-portrait .dp-smoke {
    opacity: calc(var(--dp-smoke-base, 0.28) * var(--dp-smoke-mood, 1));
}
.doll-portrait .dp-lid {
    stroke-width: calc(var(--dp-lid-base, 2.5) * var(--dp-lid-mood, 1));
}
.doll-portrait .dp-sparkle {
    opacity: var(--dp-sparkle, 0);
}
/* The two features that exist only at the ends of the spectrum: the scowl furrow
   between the brows, and the mouth creases — pulled down at the hostile end, lifted
   into smile lines at the charmed one. Drawn once, invisible everywhere else. */
.doll-portrait .dp-furrow {
    opacity: var(--dp-furrow, 0);
}
.doll-portrait .dp-crease-down {
    opacity: var(--dp-crease-down, 0);
}
.doll-portrait .dp-crease-up {
    opacity: var(--dp-crease-up, 0);
}
/* Even her idle motion carries the mood: a quicker, agitated sway and tilt at the
   hostile end, a slower settled one when she is charmed. Both loops are switched
   off BY NAME under reduced motion, so these durations play nothing there. */
.doll-portrait[data-mood] .dp-sway {
    animation-duration: calc(7s * var(--dp-sway-speed, 1));
}
.doll-portrait[data-mood] .dp-head {
    animation-duration: calc(11s * var(--dp-sway-speed, 1));
}
/* Step 28 — she actually spits.
   The roster has always carried the count (every barista's `spits`, 0-5) and the
   ending has always reported it, but the act itself only ever existed as text.
   `data-spit` on the portrait box plays a one-shot burst: the lips purse and push
   (`.dp-spit-lips`, a wrapper INSIDE the mood mouth, so the mood's shaping
   multiplies with it instead of being replaced), a puff of spray leaves them, three
   droplets arc down past her shoulder and a wet mark is left on the counter.

   Layer, not replacement — the same contract as the mood above: one group and one
   wrapper are drawn once by portraitSvg() for all thirteen dolls, the SVG is never
   rebuilt when she spits (so the sway, the tilt and the blink keep running through
   it), and a portrait with no `data-spit` renders exactly the drawing Steps 13-27
   signed off. The count is roster data, so the burst is fired from the script
   (playSpitBurst -> spitsForDoll); each spit replays by toggling the attribute
   through a forced reflow, the trick restartAnimation() already uses for the log
   fade. One spit is 620ms, asserted equal to DP_SPIT_MS in the harness. */
.doll-portrait .dp-spit .dp-puff,
.doll-portrait .dp-spit .dp-drop,
.doll-portrait .dp-spit .dp-splat {
    opacity: 0;   /* drawn once, invisible until she does it */
}
/* Step 29 — the cup she spits into, and the count made visible.
   The cup is porcelains in her own uniform colours, so it belongs to the doll rather
   than to one shared café prop. It is shown only on the screen where the coffee is
   being made (`data-coffee`, set by renderScene from the same `spit: true` flag) —
   which keeps it off every other screen, so the portraits the earlier steps signed
   off (and the pixel audits that measure them) are untouched. It settles onto the
   counter as it arrives, like a cup being set down. */
.doll-portrait .dp-cup {
    opacity: 0;
}
.doll-portrait[data-coffee] .dp-cup {
    transform-box: fill-box;
    transform-origin: 50% 100%;
    animation: dpCupIn 240ms ease-out both;
}
@keyframes dpCupIn {
    0%   { opacity: 0; transform: translateY(3px) scale(0.94); }
    100% { opacity: 1; transform: translateY(0) scale(1); }
}
/* The count, in the coffee: one ring per spit, revealed as each one lands. The rule
   per count lists every ring up to it, because setting data-spit-count REPLACES the
   previous value — so count 3 has to reveal rings 1, 2 and 3 itself. A three-spit
   doll therefore leaves three rings, exactly the number the ending reports. */
.doll-portrait .dp-ripple {
    opacity: 0;
    transition: opacity 140ms ease-out;
}
.doll-portrait[data-spit-count="1"] .dp-ripple-1,
.doll-portrait[data-spit-count="2"] .dp-ripple-1,
.doll-portrait[data-spit-count="3"] .dp-ripple-1,
.doll-portrait[data-spit-count="4"] .dp-ripple-1,
.doll-portrait[data-spit-count="5"] .dp-ripple-1 {
    opacity: 0.9;
}
.doll-portrait[data-spit-count="2"] .dp-ripple-2,
.doll-portrait[data-spit-count="3"] .dp-ripple-2,
.doll-portrait[data-spit-count="4"] .dp-ripple-2,
.doll-portrait[data-spit-count="5"] .dp-ripple-2 {
    opacity: 0.9;
}
.doll-portrait[data-spit-count="3"] .dp-ripple-3,
.doll-portrait[data-spit-count="4"] .dp-ripple-3,
.doll-portrait[data-spit-count="5"] .dp-ripple-3 {
    opacity: 0.9;
}
.doll-portrait[data-spit-count="4"] .dp-ripple-4,
.doll-portrait[data-spit-count="5"] .dp-ripple-4 {
    opacity: 0.9;
}
.doll-portrait[data-spit-count="5"] .dp-ripple-5 {
    opacity: 0.9;
}
.doll-portrait[data-spit] .dp-spit-lips {
    transform-box: fill-box;
    transform-origin: 50% 50%;
    animation: dpSpitLips 0.62s ease-in-out both;
}
.doll-portrait[data-spit] .dp-puff {
    transform-box: fill-box;
    transform-origin: 50% 50%;
    animation: dpSpitPuff 0.62s ease-out both;
}
/* Every droplet carries its own travel AND its own stagger in custom properties, so
   three drops leave the same lips on three different arcs and land in three
   different places — one keyframe to keep in sync instead of three rules that can
   drift apart. transform-box: fill-box puts each scale about its own droplet. */
.doll-portrait[data-spit] .dp-drop {
    transform-box: fill-box;
    transform-origin: 50% 50%;
    animation: dpSpitDrop 0.62s cubic-bezier(0.33, 0.02, 0.72, 0.44) both;
}
.doll-portrait[data-spit] .dp-drop-2 {
    animation-delay: 0.06s;
}
.doll-portrait[data-spit] .dp-drop-3 {
    animation-delay: 0.12s;
}
.doll-portrait[data-spit] .dp-splat {
    transform-box: fill-box;
    transform-origin: 50% 50%;
    animation: dpSpitSplat 0.62s ease-out both;
}
@keyframes dpSpitLips {
    0%   { transform: translateY(0) scale(1, 1); }
    20%  { transform: translateY(0.5px) scale(0.86, 0.68); }    /* pursed */
    34%  { transform: translateY(-0.5px) scale(1.14, 1.12); }   /* the push */
    100% { transform: translateY(0) scale(1, 1); }
}
@keyframes dpSpitPuff {
    0%   { opacity: 0; transform: scale(0.3); }
    18%  { opacity: 0.8; transform: scale(1); }
    60%  { opacity: 0; transform: scale(1.7); }
    100% { opacity: 0; transform: scale(1.7); }
}
@keyframes dpSpitDrop {
    0%   { opacity: 0; transform: translate(0, 0) scale(0.35); }
    14%  { opacity: 1; transform: translate(calc(var(--dp-drop-x, 30px) * 0.24), calc(var(--dp-drop-y, 66px) * 0.1)) scale(1); }
    100% { opacity: 0; transform: translate(var(--dp-drop-x, 30px), var(--dp-drop-y, 66px)) scale(0.45); }
}
@keyframes dpSpitSplat {
    0%, 46% { opacity: 0;    transform: scale(0.45); }
    60%     { opacity: 0.9;  transform: scale(1); }
    80%     { opacity: 0.5;  transform: scale(1.05); }
    100%    { opacity: 0;    transform: scale(1.1); }   /* the splash, then the ring */
}
/* These animations are decorative and never gate input — the buttons stay
   clickable while they play. Players who ask for less motion get the
   plain, instant version of the same screen. */
@media (prefers-reduced-motion: reduce) {
    .log-box.log-enter,
    .log-box.log-exit,
    .status-badge.badge-pop,
    .reputation.rep-flash,
    .toast,
    /* Step 13: the portrait's idle animations — she freezes to the pose
       the SVG is authored in (chin raised, eyes open, shoulders still). */
    .doll-portrait .dp-sway,
    .doll-portrait .dp-head,
    .doll-portrait .dp-eye {
        animation: none;
    }
    /* Step 28: the spit still happens — it just does not travel. She gets the plain,
       instant version of the same beat: the spray at her lips, one droplet's worth
       of drops parked mid-arc and the wet mark on the counter, held for the length
       of a single burst (which is exactly how long the script's reduced-motion path
       keeps them). No strobe of five, no motion, and never a missing reaction. */
    .doll-portrait[data-spit] .dp-spit-lips,
    .doll-portrait[data-spit] .dp-puff,
    .doll-portrait[data-spit] .dp-drop,
    .doll-portrait[data-spit] .dp-splat {
        animation: none;
        opacity: 1;
    }
    .doll-portrait[data-spit] .dp-drop {
        transform: translate(calc(var(--dp-drop-x, 30px) * 0.5), calc(var(--dp-drop-y, 66px) * 0.5));
    }
    /* Step 29: the cup arrives without travelling, and the rings appear without
       blooming — the count is still told, just not animated. */
    .doll-portrait[data-coffee] .dp-cup {
        animation: none;
        opacity: 1;
    }
    .doll-portrait .dp-ripple {
        transition: none;
    }
    .achievements-panel,
    .achievements-panel.open {
        transition: none;
    }
    /* Step 25/26: the mood layer still REACTS under reduced motion — she still
       changes state with the score, she just snaps to it instead of gliding
       into it. Reduced motion means no animated transition, not a portrait
       frozen at neutral. (The mood's animation-duration overrides above cannot
       restart the idle loops here: the rule below sets animation-name to none,
       and a duration with no name plays nothing.) */
    .doll-portrait[data-mood] .dp-mood-brow-l,
    .doll-portrait[data-mood] .dp-mood-brow-r,
    .doll-portrait[data-mood] .dp-mood-mouth,
    .doll-portrait[data-mood] .dp-mood-eye,
    .doll-portrait[data-mood] .dp-blush,
    .doll-portrait[data-mood] .dp-sparkle,
    .doll-portrait[data-mood] .dp-furrow,
    .doll-portrait[data-mood] .dp-crease-down,
    .doll-portrait[data-mood] .dp-crease-up,
    .doll-portrait[data-mood] .dp-smoke,
    .doll-portrait[data-mood] .dp-lid {
        transition: none;
    }
}

/* ==========================================================
   Reactions, steam, the spoon, and the text/button entrances.
   Every rule here is decorative: input is never blocked while it plays, and
   the reduced-motion block at the end turns all of it into the still version.
   ========================================================== */

/* --- her gestures (data-react, played by playReactions in portrait.js) ---
   Three wrappers carry them: .dp-react (the whole figure, pivoting at its base),
   .dp-react-head (inside the head, pivoting at the neck) and .dp-gaze (the iris,
   pupil and glint of each eye). At rest they have no transform at all. The
   durations match DP_REACTIONS in portrait.js. */
.doll-portrait .dp-react {
    transform-box: fill-box;
    transform-origin: 50% 100%;
}
.doll-portrait .dp-react-head {
    transform-box: fill-box;
    transform-origin: 50% 96%;
}
.doll-portrait .dp-gaze {
    transform-box: fill-box;
    transform-origin: 50% 50%;
}
/* glance: eyes and head turn toward the sound, hold, come back */
.doll-portrait[data-react="glance"] .dp-gaze { animation: dpGazeSide 1000ms ease-in-out both; }
.doll-portrait[data-react="glance"] .dp-react-head { animation: dpHeadTurn 1000ms ease-in-out both; }
/* startle: a flinch back with a small shake, the head thrown back */
.doll-portrait[data-react="startle"] .dp-react { animation: dpStartle 700ms ease-out both; }
.doll-portrait[data-react="startle"] .dp-react-head { animation: dpHeadBack 700ms ease-out both; }
/* nod: a short double nod */
.doll-portrait[data-react="nod"] .dp-react-head { animation: dpNod 800ms ease-in-out both; }
/* huff: head and eyes turned away from you, held, then back */
.doll-portrait[data-react="huff"] .dp-react-head { animation: dpHeadAway 1400ms ease-in-out both; }
.doll-portrait[data-react="huff"] .dp-gaze { animation: dpGazeAway 1400ms ease-in-out both; }
.doll-portrait[data-react="huff"] .dp-react { animation: dpLeanAway 1400ms ease-in-out both; }
/* serve: she leans in to set the cup down */
.doll-portrait[data-react="serve"] .dp-react { animation: dpServe 900ms ease-in-out both; }
.doll-portrait[data-react="serve"] .dp-react-head { animation: dpServeHead 900ms ease-in-out both; }
/* beam: a pleased little bounce */
.doll-portrait[data-react="beam"] .dp-react { animation: dpBeam 900ms ease-out both; }
.doll-portrait[data-react="beam"] .dp-react-head { animation: dpBeamHead 900ms ease-out both; }

@keyframes dpGazeSide {
    0%, 100% { transform: translateX(0); }
    22%, 70% { transform: translateX(calc(1.7px * var(--dp-gaze-dir, 1))); }
}
@keyframes dpGazeAway {
    0%, 100% { transform: translateX(0); }
    20%, 75% { transform: translateX(calc(-2px * var(--dp-gaze-dir, 1))); }
}
@keyframes dpHeadTurn {
    0%, 100% { transform: rotate(0deg); }
    25%, 68% { transform: rotate(2.4deg); }
}
@keyframes dpHeadAway {
    0%, 100% { transform: rotate(0deg); }
    22%, 75% { transform: rotate(-4deg) translateY(-0.6px); }
}
@keyframes dpLeanAway {
    0%, 100% { transform: rotate(0deg); }
    25%, 72% { transform: rotate(-0.9deg); }
}
@keyframes dpStartle {
    0%   { transform: translateY(0) rotate(0deg); }
    12%  { transform: translateY(-3px) scale(1.02) rotate(0deg); }
    30%  { transform: translateY(-1.5px) rotate(-1.4deg); }
    45%  { transform: translateY(-1px) rotate(1.2deg); }
    60%  { transform: translateY(-0.5px) rotate(-0.7deg); }
    78%  { transform: translateY(0) rotate(0.3deg); }
    100% { transform: translateY(0) rotate(0deg); }
}
@keyframes dpHeadBack {
    0%, 100% { transform: rotate(0deg); }
    14%      { transform: rotate(-3deg) translateY(-1px); }
    50%      { transform: rotate(-1deg); }
}
@keyframes dpNod {
    0%, 100% { transform: translateY(0) rotate(0deg); }
    25%      { transform: translateY(2.4px) rotate(0.6deg); }
    48%      { transform: translateY(0) rotate(0deg); }
    70%      { transform: translateY(1.3px) rotate(0.3deg); }
}
@keyframes dpServe {
    0%, 100% { transform: translateY(0) scale(1); }
    40%, 60% { transform: translateY(3px) scale(0.985); }
}
@keyframes dpServeHead {
    0%, 100% { transform: rotate(0deg); }
    40%, 60% { transform: rotate(3deg) translateY(1px); }
}
@keyframes dpBeam {
    0%, 100% { transform: translateY(0); }
    22%      { transform: translateY(-3px); }
    45%      { transform: translateY(0); }
    65%      { transform: translateY(-1.4px); }
    82%      { transform: translateY(0); }
}
@keyframes dpBeamHead {
    0%, 100% { transform: rotate(0deg); }
    30%, 65% { transform: rotate(2.2deg); }
}

/* --- life in the coffee --- */
/* Steam: three wisps rising off the cup whenever it is on the counter, out of step. */
.doll-portrait .dp-steam path {
    opacity: 0;
    transform-box: fill-box;
    transform-origin: 50% 100%;
}
.doll-portrait[data-coffee] .dp-steam path {
    animation: dpSteam 3s ease-in-out infinite;
}
.doll-portrait[data-coffee] .dp-steam path:nth-child(2) { animation-delay: -1s; }
.doll-portrait[data-coffee] .dp-steam path:nth-child(3) { animation-delay: -2s; }
@keyframes dpSteam {
    0%   { opacity: 0;    transform: translateY(4px) scaleX(0.85); }
    30%  { opacity: 0.42; }
    70%  { opacity: 0.2; }
    100% { opacity: 0;    transform: translateY(-9px) scaleX(1.2); }
}
/* Slosh: the surface wobbles as each droplet lands (the drop reaches it near the
   end of its 620ms flight, so the wobble sits at the end of the same clock). */
.doll-portrait .dp-coffee,
.doll-portrait .dp-crema {
    transform-box: fill-box;
    transform-origin: 50% 50%;
}
.doll-portrait[data-spit] .dp-coffee,
.doll-portrait[data-spit] .dp-crema {
    animation: dpSlosh 620ms ease-out both;
}
@keyframes dpSlosh {
    0%, 72% { transform: scale(1, 1); }
    82%     { transform: scale(1.08, 0.62); }
    92%     { transform: scale(0.97, 1.18); }
    100%    { transform: scale(1, 1); }
}
/* The spoon of the finale and its glint. */
.doll-portrait .dp-spoon {
    opacity: 0;
}
.doll-portrait[data-spoon] .dp-spoon {
    opacity: 1;
}
.doll-portrait .dp-spoon-glint {
    transform-box: fill-box;
    transform-origin: 50% 50%;
    opacity: 0;
}
.doll-portrait[data-spoon] .dp-spoon-glint {
    animation: dpGlint 2.4s ease-in-out infinite;
}
@keyframes dpGlint {
    0%, 55%, 100% { opacity: 0;   transform: scale(0.2) rotate(0deg); }
    70%           { opacity: 1;   transform: scale(1.15) rotate(45deg); }
    85%           { opacity: 0.4; transform: scale(0.6) rotate(90deg); }
}

/* --- the text arrives line by line --- */
/* renderLog() wraps every mid-run line in .log-line with its place in the queue
   (--i); inline-block so a line can rise while still wrapping inside the box. */
.log-box .log-line {
    display: inline-block;
}
.log-box.log-enter .log-line {
    animation: logLine 0.32s ease-out backwards;
    animation-delay: calc(var(--i, 0) * 70ms);
}
@keyframes logLine {
    from { opacity: 0; transform: translateY(5px); }
    to   { opacity: 1; transform: translateY(0); }
}

/* --- the buttons come in one after another, lift on hover, give on press --- */
/* `backwards`, not `both`: once a button has arrived the animation lets go of its
   transform, so the hover lift and the press below still work. */
.btn-group button {
    animation: btnIn 0.28s ease-out backwards;
    animation-delay: calc(150ms + var(--i, 0) * 80ms);
    transition: background 0.2s, transform 0.12s, box-shadow 0.12s;
}
@keyframes btnIn {
    from { opacity: 0; transform: translateY(8px) scale(0.97); }
    to   { opacity: 1; transform: translateY(0) scale(1); }
}
.btn-group button:hover {
    transform: translateY(-1px);
    box-shadow: 0 4px 10px rgba(0, 0, 0, 0.35);
}
.btn-group button:active {
    transform: translateY(1px) scale(0.97);
    box-shadow: 0 1px 3px rgba(0, 0, 0, 0.4);
}

@media (prefers-reduced-motion: reduce) {
    /* No gestures (the script does not even start them), no rising text, no
       sliding buttons. The steam stays as a faint still wisp, and the spoon
       keeps a still glint, so the scene still reads — it just does not move. */
    .doll-portrait[data-react] .dp-react,
    .doll-portrait[data-react] .dp-react-head,
    .doll-portrait[data-react] .dp-gaze,
    .doll-portrait[data-spit] .dp-coffee,
    .doll-portrait[data-spit] .dp-crema,
    .log-box.log-enter .log-line,
    .btn-group button {
        animation: none;
    }
    .doll-portrait[data-coffee] .dp-steam path {
        animation: none;
        opacity: 0.25;
    }
    .doll-portrait[data-spoon] .dp-spoon-glint {
        animation: none;
        opacity: 0.9;
    }
    .btn-group button:hover,
    .btn-group button:active {
        transform: none;
    }
}
