/* ==========================================================================
   Design System v2.1 — COMPONENTS
   The dashboard shell: wallpaper, greeting, dock, status bar, pills — plus the
   library controls the panels are built from: cards, choice cards, buttons and
   text fields.
   --------------------------------------------------------------------------
   Source: Figma "🖥️ Homepage" (798:407) — Dashboard_Light 808:5045 /
   Dashboard_Dark 808:3988, and the Dock (813:146), StatusBar (812:146) and
   SecondaryButtons (811:76) components. The controls come from the panel
   frames, which reuse them: ProductTypeCard and Section/FormField appear on the
   Lead Times, Resources and Register-a-patient screens alike, which is why they
   live here rather than with any one panel.

   Consumes role tokens from semantic.css and primitives from scales.css.
   Where a value comes straight off the Figma node it's noted with the node id
   so it can be re-checked in dev mode.

   ONE DEVIATION RUNS THROUGH THIS FILE: the design fixes widths and heights in
   px (dock 1201px, pills 185px, bar 404px). Those are replaced with
   min-width / min-height plus the designed padding, because the status-bar
   panel has an "AA" font-size control — text that scales inside a fixed box
   clips. Same rendering at the default scale, but it survives being turned up.
   ========================================================================== */

/* ── Utilities ───────────────────────────────────────────────────────────
   Text for assistive tech only. Used where the design conveys something
   visually that a screen reader would otherwise miss — e.g. the notification
   badge, where "2" alone doesn't say two of what.

   Not display:none, which removes it from the accessibility tree too, and not
   a negative text-indent, which breaks in RTL. The clip-path/1px pattern is
   the one that survives every combination. */
.v2-visually-hidden {
    position: absolute;
    width: 1px;
    height: 1px;
    margin: -1px;
    padding: 0;
    overflow: hidden;
    clip-path: inset(50%);
    white-space: nowrap;
    border: 0;
}


/* Skip link. Hidden until focused, then a real, visible control — a skip link
   that never becomes visible is worse than none, because keyboard users can't
   tell whether it worked. */
.v2-skip-nav {
    position: absolute;
    top: var(--space-2);
    left: var(--space-2);
    z-index: 100;
    padding: var(--space-2) var(--space-4);
    border-radius: var(--radius-button);
    background: var(--surface-raised);
    color: var(--text-strong);
    font-weight: 600;
    text-decoration: none;
    transform: translateY(-200%);
}

.v2-skip-nav:focus-visible { transform: translateY(0); }


/* ── Glass ───────────────────────────────────────────────────────────────
   Guidelines §8 deliberately doesn't document the effect stack: "these are
   complex and layered, so rather than documenting values here, pull the exact
   effect settings directly from dev mode."

   What the codegen DID give us, exactly, is the fill, border, radius and the
   five-stop shadow. What it did NOT give is the blur radius — Figma's
   background-blur doesn't survive the export. So --glass-blur was derived by eye
   from the rendered frame, and then reviewed against the design on 11-Aug-2026
   and accepted at 20px. Settled, not a placeholder. */
:root {
    /* ── Shell measure ───────────────────────────────────────────────────
       1201px is the Dock's designed width (5 × 205 tiles + 4 × 34 gaps +
       2 × 20 padding). It doubles as the page's content column, because in the
       Figma frame the greeting's left edge (x=120) and the status bar's right
       edge (x=1320) both line up with the dock — the dock IS the measure. So
       one centred column of this width puts all three in the right place, at
       any viewport, instead of three separate gutter guesses. */
    --shell-max: 1201px;
    --shell-gutter: clamp(20px, 3vw, 48px);

    /* The rhythm down the bottom of the page: dock → pills → "Powered by".
       ONE value, used for all three gaps, because the mock reads as equal
       spacing and mine didn't — the dock-to-pills gap was 20px while
       pills-to-footer came to 44px, assembled accidentally out of a row-gap
       plus two paddings. In the frame the measured gaps are 60px and 46px, so
       48px sits in the middle of them and lands on the even-number scale. */
    --nav-stack-gap: var(--space-12);   /* 48px */

    /* ── Glyph translucency: GONE, and deliberately ───────────────────────
       There was a --dock-glyph-opacity here, 0.78 in light and 1 in dark, to make
       the tile glyphs read as translucent in light mode. Its own docblock said
       why it was a guess: the light and dark tile gradients were byte-identical,
       so the effect had to be a layer opacity or blend mode the codegen had
       flattened away, and plain opacity was the predictable stand-in.

       Justin's redraw (26-Aug) settles it. The flat set — now the shipping art —
       draws its glyph as #EFF7F5 at fill-opacity 0.9 IN THE SVG, which is the
       translucency the token was approximating. Its own docblock had even noted
       that.

       So the token was doing it twice (0.9 × 0.78 ≈ 0.70), and worse: every tile
       is a full-bleed plate now, where three used to be bare glyphs, so it faded
       the WHOLE TILE against the wallpaper rather than just the glyph. Daniel,
       26-Aug: "in light mode the icons get washed by the background... in the
       figma spec they seem to be unaffected by the background change?" They are
       unaffected — the spec renders both themes identically, and now so do we. */

    --glass-blur: 20px;                        /* ESTIMATE — confirm in dev mode */

    /* ONE glass fill, and it is the only thing that changes between themes.

       THE CODEGEN'S NUMBERS ARE NOT THE RENDERED RESULT. It reports 25% for light
       and 10% for dark, and those were shipped — but Daniel kept saying dark
       looked like "not light mode" (14-Aug), so I measured the frames instead of
       trusting the flattened values. Sampling the status-bar pill against the
       wallpaper either side of it, in Dashboard_Light 808:5045 and Dashboard_Dark
       808:3988, and solving pill = wall*(1-a) + tint*a:

                        R      G      B     mean     codegen
           light      0.094  0.086  0.068   0.083      0.25
           dark       0.398  0.321  0.320   0.346      0.10

       So light is roughly 3x too opaque and dark roughly 3.5x too weak — which is
       exactly why dark read as washed out. The codegen's figures appear to be the
       fill layer alone, with whatever Figma's background blur composites on top of
       it discarded; see memory/reference_figma_codegen_flattens_effects.md.

       DARK IS CORRECTED HERE. Light is left at 25% deliberately: Daniel said light
       "feels good", and 8% would make it markedly more transparent than what he has
       approved. Flagged for a decision rather than changed underneath him.

       Method caveat, because it bounds how much to trust these: backdrop-filter
       averages a ~20px neighbourhood, so the true backdrop under the pill is not
       quite the strip sampled above and below it. Good to a few percent, not to the
       third decimal.

       Repointed in the theme blocks below.

       The first pass had the dark dock fill and the light pill fill applied at
       the same time, so the page rendered as half of each theme. The dock, the
       status bar and the pills all take the SAME fill within a theme — that is
       what the two Dock variants show, and it is why there is no longer a
       separate pill token to get out of step. */
    --glass-fill: rgb(239 247 245 / 25%);

    /* Hover on glass. This was MISSING: .v2-pill:hover referenced
       --glass-fill-hover with a hardcoded white fallback that nothing ever
       overrode, so pill hover — and the section nav's hover — was the same white
       wash in both themes. On dark glass a white wash is the wrong direction
       entirely; the fill should deepen, not lighten.

       Same base colour as --glass-fill at a stronger alpha, repointed in the
       theme blocks below. Alpha rather than a scale step because this is §8
       glassmorphism, which is opacity by nature — the same reason --glass-fill
       itself is expressed this way. Eyeballed like --glass-blur, so treat it as
       settled-by-review rather than read from the file. */
    --glass-fill-hover: rgb(239 247 245 / 42%);

    /* ── The glass edge ──────────────────────────────────────────────────
       NOT a flat 1px border. Figma reports the stroke as a single
       rgba(255,255,255,0.4), but that is the codegen flattening it — in the
       file it's a gradient stroke reading as a highlight catching the rim of
       the glass: bright along the top, almost gone across the middle, picking
       up again at the bottom edge. A uniform hairline reads as a plastic
       outline instead, which is what mine looked like.

       Applied to the dock panel, the status bar, the pills AND the app tiles.
       Same treatment on all of them, because it's one material. */
    --glass-stroke: linear-gradient(
        170deg,
        rgb(255 255 255 / 72%) 0%,
        rgb(255 255 255 / 26%) 18%,
        rgb(255 255 255 / 7%)  45%,
        rgb(255 255 255 / 10%) 62%,
        rgb(255 255 255 / 46%) 100%
    );
    --glass-stroke-width: 1px;

    /* The five-stop shadow, verbatim from the Dock node. The first stop is
       fully transparent — it's in the design, and it's how Figma expresses a
       shadow that only starts contributing further down the stack. Kept so
       this matches the file exactly rather than looking "cleaned up". */
    --glass-shadow:
        0 119px 33px rgb(0 0 0 / 0%),
        0 76px 30px rgb(0 0 0 / 2%),
        0 43px 26px rgb(0 0 0 / 7%),
        0 19px 19px rgb(0 0 0 / 12%),
        0 5px 10px rgb(0 0 0 / 14%);

    /* THE SAME SHADOW, AS A FILTER. Justin, via Daniel 8-Sep: "adding a shadow
       to the logo. mirroring the shadow effect from the status bar."

       It cannot be the box-shadow above. The logo is artwork on the wallpaper,
       not a surface: box-shadow follows the ELEMENT BOX, so it would draw a
       rectangle behind a round mark. drop-shadow follows the rendered alpha, so
       it traces the disc and the wordmark.

       FOUR PASSES, NOT FIVE. The box-shadow keeps a 0%-alpha first stop to match
       the Figma node verbatim; each drop-shadow here is a real filter pass over
       the element, and one that paints nothing is pure cost. The four that carry
       the weight are kept at their own offsets and alphas.

       Declared beside --glass-shadow rather than inside the brand rules so the
       two stay recognisably the same shadow expressed two ways. */
    --glass-shadow-filter:
        drop-shadow(0 76px 30px rgb(0 0 0 / 2%))
        drop-shadow(0 43px 26px rgb(0 0 0 / 7%))
        drop-shadow(0 19px 19px rgb(0 0 0 / 12%))
        drop-shadow(0 5px 10px rgb(0 0 0 / 14%));

    /* Text over the wallpaper. NOT --text-body: this text sits on a
       photograph, not on a surface, so it takes its own roles.

       These do NOT change between themes, and that is per the design rather
       than an oversight — both Dock variants use Seafoam 60% for labels and
       both SecondaryButtons variants use white 50%. Guidelines §2 says as much:
       "Most colours stay the same across both modes." The wallpaper is the
       constant here, so what sits on it is constant too. */
    --on-wallpaper:        rgb(255 255 255 / 100%);
    --on-wallpaper-muted:  rgb(255 255 255 / 78%);
    --on-glass-label:      rgb(239 247 245 / 85%);
}

/* ── Theme: dark ─────────────────────────────────────────────────────────
   Three states, same pattern as semantic.css, because the portal's theme can be
   'light', 'dark' or 'auto' — and 'auto' stamps no attribute at all, so it has
   to be caught by prefers-color-scheme. Defining the dark fill ONLY under
   [data-theme="dark"] would leave every 'auto' user on the light glass
   regardless of their OS. */
@media (prefers-color-scheme: dark) {
    :root:not([data-theme="light"]) {
        --glass-fill: rgb(58 74 80 / 35%);        /* measured, not the codegen's 10% */
        --glass-fill-hover: rgb(58 74 80 / 46%);   /* deepens, not lightens */
    }
}

:root[data-theme="dark"] {
    --glass-fill: rgb(58 74 80 / 35%);        /* measured, not the codegen's 10% */
    --glass-fill-hover: rgb(58 74 80 / 46%);
}

.v2-glass {
    background-color: var(--glass-fill);
    -webkit-backdrop-filter: blur(var(--glass-blur));
    backdrop-filter: blur(var(--glass-blur));
    box-shadow: var(--glass-shadow);
}

/* The gradient rim. A gradient can't be a `border`, and border-image ignores
   border-radius, so it's drawn as an inset pseudo-element masked to a ring:
   two masks, one clipped to the content box, XOR'd so only the padding-width
   band survives. border-radius: inherit keeps it following each shape — 44px on
   the dock, 999px on the pills, 24px on the tiles — without restating any of
   them. */
/* .v2-modal-panel joins them for the RIM ONLY — see the note on its own rule
   in overlays.css. It keeps an opaque background: a translucent modal sits over
   a dimmed page rather than a photograph, and there is nothing behind it worth
   seeing through at the cost of the text on it. */
.v2-glass,
.v2-pill,
.v2-dock-plate,
.v2-modal-panel { position: relative; }

.v2-glass::before,
.v2-pill::before,
.v2-dock-plate::before,
.v2-modal-panel::before {
    content: '';
    position: absolute;
    inset: 0;
    z-index: 2;                       /* above the tile artwork, below nothing */
    border-radius: inherit;
    padding: var(--glass-stroke-width);
    background: var(--glass-stroke);
    -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;
    pointer-events: none;             /* never intercept a click on the tile */
}

/* Without backdrop-filter the panels are a flat tint over a photograph and the
   labels inside them become unreadable. Firefox shipped it long ago and every
   current target supports it, but the failure mode here is "text on a photo", not
   "slightly flatter panel" — so it gets a real fallback rather than being left to
   chance.

   These stay well above --glass-fill even now that dark is 35%: with no blur there
   is no averaging to mute the photograph, so the tint has to do all the work. */
@supports not ((backdrop-filter: blur(1px)) or (-webkit-backdrop-filter: blur(1px))) {
    .v2-glass { background-color: rgb(58 74 80 / 62%); }
    .v2-pill  { background-color: rgb(58 74 80 / 55%); }
}


/* ── Shell & wallpaper ───────────────────────────────────────────────────
   The wallpaper is a real photograph, full-bleed, and every piece of text on
   this screen sits over it. In Figma it's drawn 1766×1145 inside a 1440×1024
   frame — i.e. deliberately over-scaled and cropped — so object-fit: cover
   reproduces the intent at any viewport.

   It's an <img> inside the shell rather than a CSS background-image so it can
   carry srcset: a background-image can't, and this is the heaviest asset on
   the entry page. */
/* A BUILD STAMP THE PAGE CAN REPORT ON ITSELF, printed by ?qdiag=1 alongside the
   JS one. Not vanity: I have no server access, so "is the deployed file the file I
   wrote" is not a question I can answer, and asking Daniel to search a .js file on
   a phone was a daft thing to ask. Two numbers in a corner settle it instead.

   BUMP BOTH WHEN EITHER CHANGES, or the pair stops meaning anything. */
/* ── THE INCOMING BUBBLE'S FILL, PER THEME ────────────────────────────────────
   Light is the frame's #D8EDE9 = seafoam-300, one step darker than the
   seafoam-200 page so the bubble is distinguishable from it. Dark keeps the
   near-white it already had, which is what its own frame shows.

   Both dark selectors, following the pattern this file uses at line 236: the
   media query for anyone whose theme is auto, and the attribute for an explicit
   choice. layout.php always writes data-theme now, so the media query is belt
   and braces rather than load-bearing — but it costs two lines and it means the
   rule does not depend on that staying true. */
:root { --v2-q-theirs: var(--colour-seafoam-300); }

@media (prefers-color-scheme: dark) {
    :root:not([data-theme="light"]) { --v2-q-theirs: var(--colour-seafoam); }
}

:root[data-theme="dark"] { --v2-q-theirs: var(--colour-seafoam); }

:root { --v2-q-build: 42; }

.v2-shell {
    position: relative;
    min-height: 100vh;
    min-height: 100dvh;   /* dvh so mobile browser chrome doesn't crop the dock */


    overflow: hidden;
    display: grid;
    grid-template-rows: auto 1fr auto;

    /* Everything in this shell sits on a photograph, not on a surface, so the
       DEFAULT text colour here is the on-wallpaper one — not body's --text-body,
       which is near-black Flint in light mode and correct only for text on a
       panel. Setting it once at the shell means anything added later is legible
       by default instead of inheriting the wrong default and needing a patch.
       Element-level rules still win over inheritance, so headings and links
       inside the shell must state their colour explicitly regardless. */
    color: var(--on-wallpaper);
}

/* ══ THE DOCUMENT IS EXACTLY ITS CONTENT ══════════════════════════════════════
   queries.js sets data-v2-q-fit on <html> while a thread fills the screen.

   Daniel, 9-Sep: "keyboard collapsed is fine, but open it pushes the panel up
   off screen... do we treat the top the screen as page edge and allow for scroll
   so you can see the very first message by scrolling and it comes into view on
   the screen?"

   Yes — and it is one declaration rather than a mechanism. min-height is what
   made the page the wrong size in BOTH directions:

     TOO TALL with no keyboard. body is min-height: 100vh, and 100vh on iOS is
     the LARGE viewport — the height the page would have with Safari's toolbars
     retracted, not the height it has — so there was ~75px of nothing below
     everything to scroll into. That is what the lock was for.

     TOO SHORT with one. The lock left NO range at all, so when the panel became
     taller than the visible area its top went above the screen and stayed there:
     "i have no way to scroll the content into view".

   Taking the floor away answers both, because the document then measures itself.
   Panel fits: content is the screen, range is zero, no dead space. Panel
   overshoots: the range IS the overshoot, so scrolling to the top brings the
   first message into view with nothing beyond it. The top of the screen becomes
   the page edge, which is what was asked for.

   AND IT NEEDS NO MEASUREMENT, which after a day of measuring is the part worth
   recording. Every previous attempt computed a number — a margin, a cap, a
   scroll target — and each was wrong in some state I could not see from here. A
   document with no minimum height is right in every state by construction,
   including the ones I have not thought of.

   THE SHELL TOO, or it holds the floor up on the document's behalf: it carries
   its own min-height: 100dvh. The wallpaper is position: fixed so it covers the
   screen regardless, and the panel's height is explicit, so nothing here depends
   on the shell being viewport-sized. */
html[data-v2-q-fit] body,
html[data-v2-q-fit] .v2-shell { min-height: 0; }

/* ══ AND A DEFINITE HEIGHT, SO THE BROWSER CAN DO THE SLIDE ═══════════════════
   Daniel: "it should be a clean singular movement as keyboard comes up from the
   bottom the chat should move up as if sliding."

   His readout is what makes this possible, and it contradicts an assumption a
   day of work rests on: "inner 428  vv 428" — the LAYOUT viewport shrinks for
   the keyboard on this device. interactive-widget=resizes-content in the
   viewport meta is being honoured, which iOS was not supposed to do.

   Which means the browser will relayout the whole page itself, in ONE pass, as
   the keyboard slides — and queries.js setting a pixel height off viewport
   events is a SECOND mechanism moving the same panel a frame later. Two things
   animating one box is the shift he can see. The fix is not a better height; it
   is one fewer thing computing it.

   So the chain gets a definite height and the panel takes 100% of it. dvh
   follows the layout viewport, so when the keyboard arrives every box from the
   shell down resizes in the browser's own single reflow, with no JavaScript in
   the path at all.

   THIS IS THE DESKTOP CHAIN, NOT A NEW ONE: overlays.css:854 does exactly this
   above 1025px — height: 100dvh on the shell, min-height: 0 on .v2-dash-mid and
   .v2-section so the 1fr row can shrink below its content. It was never applied
   on a phone because iOS was assumed not to resize. It does. */
/* ══ NO DEFINITE-HEIGHT CHAIN HERE, AND IT WAS WORTH TRYING ═══════════════════
   The idea was sound and Daniel's own numbers supported it: "inner 428  vv 428"
   means the LAYOUT viewport shrinks for the keyboard on his device, so the
   browser relayouts the page itself and queries.js setting a pixel height is a
   second mechanism moving the same box — which is the shift he can see. Give the
   chain a definite height, let the panel take 100% of it, and the browser does
   the slide in one reflow.

   It put the composer under Safari's bottom bar, twice: once with 100dvh (which
   is not reliably the current viewport on iOS) and again with 100% from the
   root. Something between the shell and the panel does not measure the way I
   think it does, and after two attempts I am not going to find it by reasoning
   from here.

   So the pixel height is back. It was MEASURED and it was right — his readout
   said avail 604, got 604, bot 775 with the fold at 775 — and a correct composer
   position is worth more than a smoother transition. The single-slide is a real
   improvement and it is still available: it needs someone who can watch the
   layout while changing it, not another blind pass at the end of a long day. */

/* ── AND THE PAGE DOES NOT BOUNCE ─────────────────────────────────────────────
   Daniel, 9-Sep: "i am able to scroll and create a bigger gap between the
   composer and the keyboard... composer box shouldnt move".

   Nothing is scrollable here — the document is exactly its content and the
   conversation has its own box — but iOS lets a drag RUBBER-BAND the document
   anyway, and that carries everything with it, composer included. It springs
   back, which is why it reads as a gap appearing rather than a scroll position
   changing.

   NOT SCROLL CHAINING, which was the obvious guess and was already handled:
   .v2-q-messages carries .v2-scroll, and that has overscroll-behavior: contain,
   so reaching the end of the conversation has never dragged the page. This is
   the document's own bounce, which is a separate behaviour and needs the root to
   refuse it.

   WHAT THIS CANNOT DO, stated so the next person does not go looking: if the
   whole view still shifts, that is iOS panning the VISUAL viewport inside a
   layout viewport that stays full height behind the keyboard. No CSS reaches
   that — it is not document scroll — and every attempt to answer it by moving
   the panel instead made things worse. */
html[data-v2-q-fit],
html[data-v2-q-fit] body { overscroll-behavior: none; }

/* ── AND NO PAGE SCROLL, FOR THE WHOLE TIME A THREAD IS UP ────────────────────
   Not merely while a keyboard is, which is where this was and why Daniel could
   still beat it: "still seems to do it if im fast enough. i scroll down and i
   can see the page trying to scroll rather than the chat window."

   Of course he could. Between the tap and iOS reporting the smaller viewport the
   document really is taller than the screen, and iOS decides who owns a gesture
   at TOUCH-START — so a rule that arrives with the keyboard arrives after the
   race is already lost. I tried closing that window from the focus event, and it
   was both still too late and the cause of a second fault (see the note in
   queries.js about the blocky transition).

   There is no window if the rule is never absent. A mobile thread never wants
   the document to scroll — with the keyboard down it is exactly the screen, and
   with one up the conversation scrolls in its own box — so it is simply off
   throughout, and there is no longer anything to time correctly.

   overflow rather than the height caps tried earlier today: capping the heights
   of body and .v2-shell also removes page scroll, and takes the CONVERSATION'S
   scroll with it, because that depends on an unbroken chain of definite heights
   from the shell down. overflow leaves every height alone. */
html[data-v2-q-fit],
html[data-v2-q-fit] body { overflow: hidden; }

/* NO touch-action HERE, AND THE REASON MATTERS. I put it as none on the panel with
   pan-y on the conversation, meaning "only the chat may scroll". It does the
   opposite: the property INTERSECTS down the ancestor chain, so none on an ancestor
   disables panning for the whole subtree and a descendant cannot win it back.
   Daniel, 9-Sep: "there is no scroll for the chat window and only a scroll on the
   panel itself" — exactly backwards, and exactly what the spec says happens.

   So it cannot be done from an ancestor. If gesture confinement is wanted it has to
   go on the siblings that are NOT the conversation — the head, the composer row —
   never on anything the scroller sits inside. Left out rather than half-done: the
   readout says the page gains 347px of range during the keyboard transition (WORST
   max 347, off 347) and that is still unexplained. Better an unexplained transient
   than a chat that cannot scroll at all. */

/* ══ WITH A KEYBOARD UP, THE SCREEN TOP IS THE CHAT TOP ═══════════════════════
   queries.js sets data-v2-q-keyboard on <html> while a keyboard is up under a
   mobile thread. It sets it nowhere else, so no media query is needed here — the
   attribute IS the condition.

   Daniel, 9-Sep: "when keyboard opened, the top of screen becomes the top of
   chat window. scroll bar within that and the bottom. scrolled all up is top of
   chat, all down is bottom of chat, collapse keyboard brings full panel back
   down."

   THREE THINGS GIVE WAY, and they are the three things above the conversation:
   the dots row, the band of wallpaper below it, and the thread's own head. A
   keyboard leaves about 320px of screen; those three were spending roughly 175
   of it on a back button, a photograph and a title that has just been read,
   which left the conversation about a message and a half. That is the squashing.

   IT IS A LAYOUT CHANGE AND NOT A SCROLL ONE, which is why a day of scroll work
   never touched it. Nothing here moves the page: the boxes above the panel stop
   taking room, the panel's measured height (vv.height - its own offset) then
   resolves to the whole visible viewport, and .v2-q-messages scrolls between the
   top of the screen and the composer — scrolled up is the first message,
   scrolled down is the last, and the composer never moves because it is not what
   scrolls.

   ALL OF IT COMES BACK when the keyboard goes, because the attribute goes: "the
   full panel back down". */

html[data-v2-q-keyboard] .v2-dash-top { display: none; }

html[data-v2-q-keyboard] .v2-shell[data-v2-view="section"] .v2-dash-mid {
    padding-top: 0;
}

/* THE WHOLE HEADER BLOCK, not just the title row. It was .v2-q-thread-head when
   that was the only thing above the conversation; the disclosure and the order
   reference joined it and are the same kind of thing — context you want before
   you start typing, not while you are. One element means one rule, and it also
   means this cannot lose on specificity to the mobile show rules the way three
   separate selectors would have. */
html[data-v2-q-keyboard] .v2-q-thread-top { display: none; }

/* Rows are assigned EXPLICITLY rather than left to source order.
   The shell has five children, two of which are the wallpaper layers. Relying
   on order put the <picture> in row 1 and the status bar in the 1fr row, which
   stretched the bar into a 460px-tall lozenge. Naming the rows means adding or
   moving a layer can't silently reshuffle the layout. */
.v2-dash-top   { grid-row: 1; }
.v2-dash-mid   { grid-row: 2; }
.v2-poweredby  { grid-row: 3; }

/* The <picture> must be out of flow too, not just the <img> inside it. A fixed
   child doesn't take its parent out of flow, so the picture itself was still a
   grid item occupying a row. */
.v2-wallpaper-layer {
    position: fixed;
    inset: 0;
    z-index: 0;
    display: block;
}

.v2-wallpaper {
    position: fixed;
    inset: 0;
    z-index: 0;
    width: 100%;
    height: 100%;
    object-fit: cover;
    /* Pale wallpapers would wash out the white greeting, and nurses choose
       their own — so the scrim is not decoration, it's what makes a
       user-chosen background safe to put text on. Kept deliberately gentle;
       it reads as depth rather than a filter. */
    filter: brightness(0.92) saturate(1.02);
}

/* The scrim proper: strongest behind the greeting (top-left), clearing by the
   middle so the photograph is still the photograph. */
.v2-wallpaper-scrim {
    position: fixed;
    inset: 0;
    z-index: 1;
    pointer-events: none;
    background:
        linear-gradient(160deg, rgb(23 30 32 / 38%) 0%, rgb(23 30 32 / 10%) 45%, transparent 70%),
        linear-gradient(0deg, rgb(23 30 32 / 22%) 0%, transparent 35%);
}

/* THE DIMMED WALLPAPER BELONGS TO SECTION VIEW, NOT TO DARK MODE.
   -----------------------------------------------------------------------------
   Solved 14-Aug, and Daniel spotted it: "from the dashboard to the panels the
   background has a deeper fade".

   The Wallpapers canvas (803:2105) holds TEN symbols, not five — every flower has a
   "Dimmed" twin (1038:8188/8192/8195/8199/8202). I first assumed those were dark
   mode's, measured the difference as Flint @ ~22%, and shipped it. That was wrong:
   Dashboard_Dark (808:3988) and Dashboard_Light (808:5045) are pixel-for-pixel
   IDENTICAL in their wallpaper, ratio 1.000, so it was reverted with the note that
   the variants were unexplained.

   They are explained now. Comparing the DASHBOARD frame against a SECTION frame
   (LeadTimes/Desktop/Dark 1300:1644) at three separate wallpaper regions:

       top-left         implied Flint alpha  0.205
       lower-left                            0.208
       far right edge                        0.210
       the Dimmed variants themselves        0.215 / 0.223

   Same treatment, within measurement error. Opening a panel pushes the background
   back — which is why the glass and the panel read with more depth in the section
   frames than the dashboard ones.

   TRANSITIONED, per Daniel's own suggestion, because this is exactly the kind of
   shift his "continuous but not abrupt" note is about: the wallpaper darkening as
   you move into a section is one of the few moments where the whole surface changes
   at once. 260ms — the layout tier rather than the content tier.

   AND THE LAST LINE OF THIS NOTE WAS WRONG. It said "no extra assets: one overlay
   on the scrim reproduces the Dimmed variants, so the wallpaper selector will
   never need to ship them either." The alpha it measured was right; the
   conclusion was not. The Dimmed twins are BLURRED as well as dimmed, which an
   alpha comparison cannot see and which Figma's codegen drops. Daniel, 8-Sep:
   "when navigating away from the dashboard the background blurs which it doesnt
   currently."

   So the dim moved off this element and onto the image that carries the blur with
   it — see .v2-wallpaper--dimmed below. This scrim is back to the two gradients it
   is declared with, and its background-color transition went with the rule that
   used to animate: nothing changes that property any more. */

/* THE SECTION DIM IS NOT THE SCRIM'S JOB ANY MORE. It was
   background-color: rgb(58 74 80 / 21%) here, reproducing the "Dimmed"
   wallpaper twin with an overlay. That twin turns out to be blurred as well as
   dimmed, so it now ships as its own image (see dashboard.php) — and the image
   already carries the dim. Leaving this would darken it twice. */

/* ── Off the dashboard, the wallpaper blurs ─────────────────────────
   Two stacked images, cross-faded on the view. The blurred one is Justin's
   Dimmed twin (1038:8188), so this is an opacity change rather than a
   full-screen filter — cheap to animate, and his artwork rather than my
   approximation of it.

   260ms and the same easing the scrim used, because this is the same moment
   Daniel's "continuous but not abrupt" note was about: the whole background
   changing as you move into a section.

   The sharp one fades OUT rather than the blurred one simply covering it: both
   are opaque, so a straight swap would work visually, but fading the top layer
   means only one property animates and there is no frame where both are at
   full opacity fighting over the same pixels. */
.v2-wallpaper--dimmed {
    opacity: 0;
    transition: opacity 260ms ease;
}

/* data-v2-bg, NOT data-v2-view. The view attribute cannot flip until the
   outgoing panel has finished fading — it also hides .v2-section — so keying
   the wallpaper on it meant coming home waited 140ms before starting its own
   260ms. overlays.js sets both together going in and clears this one first
   coming out, so the two fades overlap. */
.v2-shell[data-v2-bg="dimmed"] .v2-wallpaper--dimmed {
    opacity: 1;
}

/* Both layers take the dark-mode darkening, so the two agree mid-fade. */
[data-theme="dark"] .v2-wallpaper,
[data-theme="dark"] .v2-wallpaper--dimmed {
    filter: brightness(0.9);
}

/* ── Dark mode darkens the photograph ───────────────────────────────
   Justin, via Daniel 8-Sep: "was noted to darken the background around 10/20% for
   darkmode", then "the backdrop darken will be slight" — so 10%, the lower end.

   THIS REVERSES A 14-AUG FINDING, and the note above records why it was reverted
   then: Dashboard_Dark (808:3988) and Dashboard_Light (808:5045) were measured
   pixel-for-pixel identical in their wallpaper, ratio 1.000, so a dark-mode dim
   was removed as unsupported. It is supported now — Justin has asked for it — and
   the earlier measurement was right about the frames as they then stood.

   A FILTER ON THE IMAGES, and on BOTH of them — see the rule above, which is where
   it now lives. Applying it to each layer rather than to a wrapper is what keeps
   the two agreeing halfway through the cross-fade; a single filtered parent would
   work too, but the wallpaper has no wrapper that is not also the scrim's.

   Not transitioned: the theme switch is a page-level change and the wallpaper is
   fixed-position behind everything, so there is no motion to smooth. The blur
   cross-fade IS transitioned, because that one happens under a click. */

.v2-shell > *:not(.v2-wallpaper):not(.v2-wallpaper-scrim) {
    position: relative;
    z-index: 2;
}


/* ── Entrance ────────────────────────────────────────────────────────────
   Daniel's idea (19-Aug): "the image renders first instantly. what if the
   panels fade in 140ms and the Welcome message appears as typed?"

   NOT IN THE FIGMA FILE. Motion isn't in those frames, so this is Daniel's
   call rather than the spec's — recorded here because a feel deviation should
   be findable, and Justin should see it rather than discover it.

   140ms because that is ALREADY the shell's fade duration (FADE_MS in
   overlays.js), so the entrance and the panel swap share one motion
   vocabulary instead of the page having two. The 60ms lead-in is what makes
   the layering legible — the wallpaper is unambiguously first — and it is
   short enough that nothing reads as withheld.

   CSS-ONLY, deliberately, and that decision removes three whole problems
   rather than solving them: no deferred script has to run before content is
   visible (so it cannot stall or flash), there is no caret to stop blinking,
   and prefers-reduced-motion is already handled globally in base.css.

   ONE keyframe serves all three effects, and the fill mode is the load-bearing
   detail. `backwards` holds the from-value during the DELAY and then stops
   applying entirely once the animation is done, so opacity goes back to being
   whatever the cascade says. `both`/`forwards` would keep asserting a value
   from the animation origin, which outranks author declarations AND transitions
   — that is how an entrance animation quietly breaks a panel's own fade
   afterwards. Declaring only `from` for the same reason: nothing here hardcodes
   an end value. */
@keyframes v2-enter {
    from { opacity: 0; }
}

/* The two wallpaper layers are excluded BY NAME. The rule above excludes
   `.v2-wallpaper`, which is the <img> and not a shell child at all — the
   child is the <picture>, `.v2-wallpaper-layer`. Left as it is there because
   it currently renders correctly and stacking order is not something to
   change in passing, but this rule must get it right or the wallpaper fades
   in with the content and the whole point is lost. */
.v2-shell > *:not(.v2-wallpaper-layer):not(.v2-wallpaper-scrim) {
    animation: v2-enter 140ms ease-out 60ms backwards;
}


/* ── Status bar (closed) ─────────────────────────────────────────────────
   812:146. Sits top-right. The open 404×410 panel — calendar, weather detail,
   the five controls and search — is a separate component and not in this file
   yet. */
.v2-statusbar {
    display: flex;
    align-items: center;
    /* flex-none matters: the bar sits in a flex row whose default align-items
       is stretch. Without an explicit cross-axis size it grew to the height of
       its row — with a 999px radius that produced a tall lozenge rather than a
       pill. Belt and braces alongside align-items on .v2-dash-top. */
    flex: 0 0 auto;
    align-self: flex-start;
    gap: var(--space-3);
    min-height: 45px;
    /* Buttons are 33px tall inside a 45px bar, so 6px of vertical padding —
       10px would push the bar past its designed height once the icons grew. */
    padding: 6px 6px 6px var(--space-5);
    border-radius: var(--radius-pill);
    font-size: var(--type-status-size);
    font-weight: var(--type-status-weight);
    /* Design says white 50%. Over a photograph that is well under AA even for
       large text, and this row carries the date and time — lifted to match the
       pills. Same reasoning as --on-glass-label. */
    color: rgb(255 255 255 / 82%);
    white-space: nowrap;
}

.v2-statusbar-date,
.v2-statusbar-time { font-variant-numeric: tabular-nums; }

.v2-statusbar-temp {
    display: flex;
    align-items: center;
    gap: 6px;
}

/* Controls in the bar are real buttons; the bar itself is not clickable. */
.v2-statusbar-btn {
    display: grid;
    place-items: center;
    min-width: 33px;
    min-height: 33px;
    padding: 0;
    border: 0;
    border-radius: var(--radius-pill);
    background: transparent;
    color: inherit;
    cursor: pointer;
}

.v2-statusbar-btn:hover { background: rgb(255 255 255 / 12%); }

/* Explicit sizes. `width: 100%` here was resolving against a shrink-to-fit
   button whose own width came from min-width, so the rendered size was
   ambiguous and came out smaller than intended.
   Figma reports these glyph frames at 18–19px, but its exported SVGs carry
   transparent padding (the vector sits inside negative insets), so drawing them
   at the node size renders smaller than the file looks. 22px is the settled
   value: 19px read too small, 26px overshot, and this matches the mock. The
   avatar is genuinely larger and keeps its own size. */
.v2-statusbar-btn img,
.v2-statusbar-icon {
    width: var(--statusbar-icon-size, 22px);
    height: var(--statusbar-icon-size, 22px);
    display: block;
}

/* The avatar is genuinely bigger in the design — 33px against the others'
   18–19px — so it keeps its own size rather than following the icon token. */
.v2-statusbar-avatar { min-width: 33px; min-height: 33px; }

.v2-statusbar-avatar img {
    width: 33px;
    height: 33px;
}

/* The phone's stand-in for the whole status bar. Circular, 48px — a real touch
   target, and the size the mobile frames draw it at.

   IT WAS TWO CONTROLS SHARING THIS GEOMETRY until 10-Sep: .v2-back, the section
   view's up-arrow, was declared here so it could not drift from the ⋯ it sat
   beside — Daniel asked for "a circle ← button matching the ⋯ button", and one
   rule for both was the only way to guarantee that. It has moved into the panel
   head (.v2-panel-back in overlays.css), where it is a labelled text control
   rather than a circle, so its rules left with it and this describes one
   button again. */
.v2-mobile-menu {
    display: none;                 /* enabled per state in the media queries */
    place-items: center;
    width: 48px;
    height: 48px;
    padding: 0;
    border: 0;
    border-radius: var(--radius-pill);
    color: var(--on-wallpaper-muted);
    cursor: pointer;
}

.v2-mobile-menu svg { display: block; }

/* FULLSCREEN (Jake, 15-Oct): its own glass pill left of the status bar (or
   the phone's ⋯ button), the bar's own height, type, colour and hover wash —
   an icon and "Fullscreen", or "Exit fullscreen" with the inward icon while
   on. No frame draws it. On a phone the label goes and it is the ⋯ button's
   48px circle. Hidden where the browser cannot fullscreen a page. */
.v2-fullscreen {
    display: inline-flex;
    align-items: center;
    gap: var(--space-2);
    flex: 0 0 auto;
    align-self: flex-start;
    min-height: 45px;
    margin-right: var(--space-3);
    padding: 0 var(--space-4);
    border: 0;
    border-radius: var(--radius-pill);
    font: inherit;
    font-size: var(--type-status-size);
    font-weight: var(--type-status-weight);
    color: rgb(255 255 255 / 82%);
    white-space: nowrap;
    cursor: pointer;
    transition: background-color 140ms ease, color 140ms ease;
}
.v2-fullscreen[hidden] { display: none; }
/* IN THE CORNER, NOT BY THE BAR (Jake, 15-Oct: "move to the top right corner
   rather than in the status bar"). From 641px it is pinned to the screen's
   own top-right corner, 16px in, above the status bar's row, and a little
   more compact than the bar. Phones keep it beside the ⋯ button below: their
   top row has no spare corner. */
@media (min-width: 641px) {
    .v2-fullscreen {
        position: fixed;
        top: calc(16px + env(safe-area-inset-top, 0px));
        right: calc(16px + env(safe-area-inset-right, 0px));
        z-index: 30;
        min-height: 36px;
        margin: 0;
        padding: 0 var(--space-3);
    }
}
.v2-fullscreen:hover { background-color: rgb(255 255 255 / 12%); color: var(--on-wallpaper); }
.v2-fullscreen svg { display: block; }
.v2-fullscreen .v2-fullscreen-exit,
.v2-fullscreen[aria-pressed="true"] .v2-fullscreen-enter { display: none; }
.v2-fullscreen[aria-pressed="true"] .v2-fullscreen-exit { display: block; }
@media (max-width: 640px) {
    .v2-fullscreen {
        justify-content: center;
        width: 48px;
        min-height: 48px;
        padding: 0;
    }
    .v2-fullscreen .v2-fullscreen-label {
        position: absolute;
        width: 1px; height: 1px;
        overflow: hidden;
        clip: rect(0 0 0 0);
        white-space: nowrap;
    }
}

/* Hover, matching the status bar's buttons — Daniel asked for the back icon to get
   "a smooth minor hover effect to match that of the hover effect on the status
   bars" (14-Aug). Same 12% white wash those use, and the transition is on the
   status-bar buttons too rather than only on the newer control: they had the
   wash but no transition, so they snapped. "Smooth" applies to the thing being
   matched or they still don't match.

   A white wash rather than --glass-fill-hover: these sit ON glass, where a light
   lift is what reads as pressable. --glass-fill-hover is for the glass surface
   itself, which deepens instead. */
.v2-statusbar-btn,
.v2-mobile-menu { transition: background-color 140ms ease, color 140ms ease; }

.v2-mobile-menu:hover {
    background-color: rgb(255 255 255 / 12%);
    color: var(--on-wallpaper);
}

/* ── The top-left slot: brand, or arrow ──────────────────────────────
   Daniel, 7-Sep: "on mobile the logo only shows on the dashboard and becomes a
   < arrow as it is everywhere else, going up one level? ... the logo can be
   constant on desktop as the back arrows arent present and we have the nav bar
   on the side for it."

   So the two share one slot and the rules below decide which is in it:

     DESKTOP   brand always, arrow never          (the nav is the navigation)
     TOUCH     brand on the dashboard, arrow in a section

   .v2-dash-top is justify-content: flex-end, so whichever one is present takes
   margin-right: auto to sit left while the status bar stays right — rather than
   switching the row to space-between, which would move the bar whenever the
   left-hand control changed. Both carry it, since exactly one is ever shown.

   NO GLASS PILL ON THE BRAND. Every other floating control in this row is one,
   and a wordmark in a bubble is not a control — it is furniture, and it sits on
   the wallpaper the way the greeting does. That is also the distinction the
   breadcrumb failed: it was a NAVIGATION control dressed as bare text in a band
   whose grammar is pills, which is why it read as orphaned.

   THE BOX IS 48px — THE STATUS BAR'S HEIGHT — AND THE ARTWORK OVERHANGS IT.
   ---------------------------------------------------------------------------
   It used to be the artwork's own height, which made row 1 of .v2-shell 72px
   instead of 48 and pushed the greeting 24px down with it. Daniel, 8-Sep: "the
   gap from welcome message to nav buttons is small" — that gap is what the
   greeting ate on its way down.

   Holding the box at the bar's height puts row 1 back to the height the frame
   measured the 64px bar-to-greeting gap against, so the greeting returns to its
   designed position and the space below it comes back. The artwork simply
   overflows the box; nothing clips it, and .v2-dash-top has 120px of padding
   above.

   --v2-brand-h IS THE ARTWORK'S height, separate from the box, so the two can
   differ and the transforms below can be expressed against it. */
.v2-brand {
    --v2-brand-h: 64px;
    display: inline-flex;
    align-items: flex-start;
    height: 48px;
    margin-right: auto;
    padding: 0;
    border: 0;
    background: none;
    /* Drives the WORDMARK, which is inlined as currentColor — see the note below
       for why it does not vary. Not the mark: that is a knockout, so its B is the
       page behind it and no colour here can reach it. */
    color: var(--colour-seafoam);
    cursor: pointer;
}

/* THE MARK ON TOUCH, THE LOCKUP ON DESKTOP. Daniel, 7-Sep: "Full on Desktop
   (BBrease). logo only on Mobile (B)". Both are in the markup and one is hidden,
   which costs nothing now they are inlined rather than fetched.

   block on the wrapper so it cannot add an inline box's descender space under
   the artwork. */
.v2-brand-art {
    display: block;
}

.v2-brand-art--lockup {
    display: none;
}

/* HEIGHT DRIVES, WIDTH FOLLOWS, which is what lets one rule serve two very
   different artworks: the mark is 993x1031 and the lockup 2800x1031, so at a
   shared height the lockup simply takes more room across. width: auto beats the
   root width attribute the export carries, and the viewBox supplies the ratio. */
.v2-brand-art svg {
    display: block;
    height: var(--v2-brand-h);
    width: auto;
    /* NO SHADOW ON THE LOGO, for now. It carried --glass-shadow-filter to mirror
       the status bar; Daniel, 8-Sep: "remove the drop shadow on the icon logo. i
       want to see hwo it looks."

       The token stays declared beside --glass-shadow, because putting it back is
       this one line and it documents how that shadow is expressed for artwork
       rather than for a surface: on the SVG so the filter sees the artwork's own
       alpha and traces the disc, not the button's rectangle. */
}

/* ── What lines up with the status bar ──────────────────────────────
   Daniel, 8-Sep: "the logo is currently sitting slightly lower than inline with
   the status bar as it should be the Brease word that sits in line - currently
   its using the B."

   Right, and the two artworks need different answers, so each gets its own.
   Both are DERIVED from Justin's frame rather than nudged:

   THE LOCKUP centres its WORDMARK on the row. In Logos/Primary (2110:19166) the
   frame is 1030.8 tall and the wordmark group sits at y=252.12 with height
   474.29, so its centre is at 489.265 — 47.465% of the height, not 50%. The mark
   spans the full height, which is exactly why aligning the artwork's own box put
   the B on the bar's line and the word below it.

   THE MARK centres its own box, because on a phone there is no wordmark to align
   and the ⋯ button is what it sits beside.

   transform, not margin: it must not change the 48px box that row 1 is sized
   from, or the greeting moves again. */
.v2-brand-art--lockup svg {
    transform: translateY(calc(24px - var(--v2-brand-h) * 489.265 / 1030.8));
}

.v2-brand-art--mark svg {
    transform: translateY(calc((48px - var(--v2-brand-h)) / 2));
}

/* 72px ON DESKTOP, WHERE THE LOCKUP IS. Daniel, 7-Sep: "still i feel the welcome
   message is bigger than the lgo not sure its right?" — the greeting is 64px
   (--type-greeting-size), so at 72 the brand is taller than the type beside it,
   and at 3:1 it is 215px across where the mark was 64. The width is what actually
   answers him; the extra 8px of height stops it reading as the smaller thing.

   The greeting itself is left alone deliberately: 64px is the frame's own number
   for it, and shrinking the page's headline to flatter the chrome is the wrong
   way round.

   Touch keeps 64: the mark is square, so 72 there would cost 8px of a phone's
   header for no width gained. */
@media (min-width: 1025px) {
    /* The ARTWORK grows; the box stays 48px so row 1 does not. */
    .v2-brand {
        --v2-brand-h: 72px;
    }

    .v2-brand-art--mark {
        display: none;
    }

    .v2-brand-art--lockup {
        display: block;
    }

}

/* NO HOVER EFFECT. There was an opacity lift; Daniel, 7-Sep: "shouldnt be a hover
   effect on the logo." A brand mark is not a button even when it is clickable, and
   cursor: pointer above still says it can be. Nothing is lost on the keyboard
   side: base.css carries a global :focus-visible ring, which is what that rule was
   doubling up on. */

/* ── The B and the wordmark follow the theme ─────────────────────────
   Justin, via Daniel 8-Sep: light mode is "the seapink with the White
   throughout"; dark mode is "the darker Brease word with B filling". Both values
   are already tokens here — Seafoam and Harbour 700 — and both the B and the
   letters are #EFF7F5 in his artwork, so ONE declaration drives both. The coral
   disc and the dot are #D96865 and stay coral in both.

   I flagged that this puts a dark wordmark on a wallpaper that is itself being
   darkened for dark mode, and Daniel confirmed it is what Justin wants. Measured
   rather than argued: the ground behind the brand is #647377 after the scrim,
   and at the 10% dark-mode darkening below it becomes luminance 0.129, where
   Harbour 700 holds 3.6:1. The wordmark is large text at every breakpoint, so
   that clears AA's 3:1 — see the greeting note for the one case that does not.

   [data-theme] rather than prefers-color-scheme: the attribute is on <html> and
   is always exactly light or dark (layout.php resolves it), so there is no third
   state to fall back for.

   NOT the wallpaper, which is the axis Daniel and I landed on yesterday and is
   still the right one for a SECOND wallpaper — see ui_wallpapers(). This is the
   theme because Justin has drawn two theme states for one wallpaper; the two
   axes multiply rather than compete. */
[data-theme="dark"] .v2-brand {
    color: var(--colour-harbour-700);
}

/* ── Superseded: the wordmark used not to vary ──────────────────────
   Seafoam throughout, set once on .v2-brand above. It went deep harbour on the
   light theme for one revision; Daniel, 7-Sep: "I dont like the dark brease in
   light mode", and then, having worked out the actual rule: "i think the logo
   should match ther wallpaper in fact not the theme... for now lets just have
   knockout logo with the white brease throughout for desktop (from Dashboard
   onwards)."

   SO NO CONDITIONAL AT ALL, AND THAT IS THE POINT. Following the theme was
   wrong: the theme changes the panels, not the photograph this sits on, so a
   dark wordmark landed over a dark photo the moment the two disagreed. Following
   the WALLPAPER would be right — it is what Justin's Dark/Light naming means,
   since those name the ground a logo is placed on — but there is one wallpaper
   today, and building the switch before there is a second thing to switch to
   would be machinery with nothing to do.

   THE DURABLE FIX IS DANIEL'S, and it is bigger than a light/dark flip: a
   mapping in the admin portal of which of Justin's seven accents may go with
   which wallpaper, because the constraint is CLASHING and not luminance. He is
   picking those pairs. Logged in the redesign log; nothing here should try to
   anticipate it.

   THE SEAM STAYS, though, at a cost of one declaration: the artwork is inlined
   with its Seafoam fill rewritten to currentColor, so the colour lives in CSS
   where it can be found rather than as a hex inside an SVG. The mark is
   unaffected either way — it is a knockout, so its B is the photograph showing
   through and carries no Seafoam at all. See $brandSvg in dashboard.php. */

/* THE BRAND NO LONGER STEPS ASIDE. Retired 10-Sep with the arrow it made room
   for.
   -----------------------------------------------------------------------------
   This used to swap the two below 1025px in section view — "the logo only shows
   on the dashboard and becomes a < arrow as it is everywhere else" — which is
   what left one control in one corner meaning two different things, and left
   desktop with no up-control at all.

   Daniel, 10-Sep: "if we are dropping the back circle on mobile we should put
   the logo there for the consistency of it then and have an always available
   back to dashboard button."

   So the brand's own display now holds in every view at every width, and there
   is no rule here to write: up-one-level lives in the panel head instead
   (.v2-panel-back in overlays.css). The .v2-back rules further up went with the
   markup; .v2-mobile-menu kept the shape they shared. */


/* ── Greeting ────────────────────────────────────────────────────────────
   808:4119. Two blocks: the salutation, then a line of real data ("Since you
   were last here 12 orders were delivered to your patients"). The number is
   emphasised in the design, so it's marked up as <strong> and inherits that
   weight rather than being styled positionally. */
.v2-greeting {
    color: var(--on-wallpaper);
}

/* ── Dark mode writes the greeting DARK ─────────────────────────────
   Justin, via Daniel 8-Sep: "change the welcome text from White to Black."

   HARBOUR 700 — THE WORDMARK'S EXACT COLOUR, AND DANIEL'S CALL AGAINST THE
   MEASUREMENT. Do not "correct" this back without asking him.
   ---------------------------------------------------------------------------
   8-Sep: "what about matching the welcome message colour to the dark brease
   colour?", and when shown the contrast figures: "i have to disagree with the
   math as look at the brease, i think that same colour as the welcome would look
   great no? i just feel the black is too dark a contrast."

   HE IS RIGHT ABOUT THE THING THE NUMBER CANNOT SEE. Flint 900 beside a teal
   wordmark reads as a second, harsher colour; matching them reads as one design.

   AND THE NUMBER IS WHAT IT IS. Sampled from bloom-1440.jpg over the region the
   greeting actually occupies, after the 38% scrim and the 10% dark-mode dim, the
   ground is luminance 0.125 and Harbour 700 holds 1.67:1 — mean, falling to 1.47
   over the lightest part of the flower. Flint 900 held 3.21. AA wants 3:1 for
   large text, so this is under half the floor rather than a near miss. I checked
   whether I had measured the wrong patch when he pushed back; behind the greeting
   is if anything darker than behind the logo, so the figure stands.

   WHY IT IS DEFENSIBLE ANYWAY, which is the part worth recording: this is a 64px
   Bold salutation and a 28px stat line on a photograph — chrome, not content. No
   clinician acts on "Good morning, Dan"; everything they do act on lives in the
   panels, on solid surfaces, at full contrast. The cost of a soft greeting is
   aesthetic, and the person who owns the aesthetic has chosen.

   THE ONE CASE I WOULD STILL PUT TO JUSTIN: below 640px the stat line drops to
   15px, which is normal text wanting 4.5:1 and now sits near 1.6. A 64px
   salutation at low contrast is a look; 15px at that contrast is hard to read.
   Worth him deciding whether the phone keeps this colour, drops the dim, or holds
   that line larger. */
[data-theme="dark"] .v2-greeting,
[data-theme="dark"] .v2-greeting-sub,
[data-theme="dark"] .v2-greeting-sub strong {
    color: var(--colour-harbour-700);
}

.v2-greeting-title {
    /* color: inherit is load-bearing. base.css colours headings with
       `:where(h1…h6) { color: var(--heading-colour, var(--text-strong)) }`, and
       although :where() contributes no specificity, a colour set ON the element
       still beats one inherited from its parent. So this h1 was rendering in
       --text-strong — near-black Flint in light mode — instead of the white it
       inherits from .v2-greeting. Dark mode only looked right by coincidence,
       because --text-strong happens to be Seafoam there.
       Anything sitting on the wallpaper must state its colour explicitly and
       must not rely on inheriting past an element-level rule. */
    color: inherit;
    margin: 0;
    font-size: var(--type-greeting-size);
    font-weight: var(--type-greeting-weight);
    line-height: var(--type-greeting-leading);
    letter-spacing: -0.015em;   /* Roboto Bold needs a touch of negative tracking at 64px */
    text-wrap: balance;
}

.v2-greeting-sub {
    /* 36rem ≈ the design's 582px greeting block. NOT `ch`: that resolves
       against this element's own font size, and on the parent it computed to
       ~315px and forced the 64px title to wrap onto three lines. */
    max-width: 36rem;
    margin: var(--space-5) 0 0;
    font-size: var(--type-greeting-sub-size);
    font-weight: 400;
    line-height: 1.3;
    color: var(--on-wallpaper-muted);
    text-wrap: pretty;
}

.v2-greeting-sub strong { font-weight: 700; color: var(--on-wallpaper); }


/* ── The typed salutation ────────────────────────────────────────────────
   ONCE PER SESSION, and the server decides — see $typeGreeting in
   views/v2/dashboard.php. Typing it on every load would mean the first thing
   a nurse looks at spends half a second looking unfinished, twenty times a
   day; charming once, friction thereafter. The decision is server-side rather
   than in sessionStorage precisely so this stylesheet can hold the sub-line
   back with no risk of a flash: there is no moment where JS has yet to decide.

   THE SALUTATION TYPES, THE NAME LANDS WHOLE. Their own name stuttering out a
   character at a time is where this tips from warm to gimmicky, and it is the
   half they are actually waiting to read. The name is on its own line and is
   not split at all.

   ZERO REFLOW, which is the reason it is done this way rather than by
   appending text. Every character is present from the first paint and holds
   its own width; only its opacity changes. Nothing grows, so nothing below it
   moves — the same rule the panels are held to.

   The characters are aria-hidden and the real string is repeated once in a
   visually-hidden span, so the accessible name of the <h1> is the sentence and
   not thirteen separate letters. Without JS in the picture, a client with
   scripting off gets the same markup and the animation simply resolves.

   1ms duration, staggered delay: a character APPEARS rather than fades, which
   is what reading a typed line looks like. --i is per character, from PHP. */
.v2-greeting-type > span {
    animation: v2-enter 1ms linear
               calc(var(--type-lead) + var(--i) * var(--type-step)) backwards;
}

.v2-greeting[data-v2-typed] {
    /* THE WHOLE SEQUENCE IS THESE THREE NUMBERS. Everything below is derived, so
       the pace is one value to change and nothing drifts out of step.

       48ms per character: 38 read as too fast (Daniel, 19-Aug). At 13 characters
       ("Good Morning,") the line now completes at ~640ms rather than ~520ms,
       which is still well short of where a page starts to feel slow.

       --type-lead is the wallpaper's head start, shared with the shell entrance.
       --type-done is the moment the salutation finishes, and it is what the name
       and the stat line hang off — neither carries a hardcoded delay, so a longer
       salutation moves all three together. */
    --type-step: 48ms;
    --type-lead: 60ms;
    --type-done: calc(var(--type-lead) + var(--type-chars) * var(--type-step));
}

/* THE NAME IS PART OF THE RUN, not its own beat.
   -----------------------------------------------------------------------------
   It first landed whole after the salutation, on my recommendation, and before
   that it arrived with the shell fade — sitting there fully formed while the
   line above it was still typing, which is the bug Daniel caught ("the name
   printed immediately?"). He then asked for the whole thing typed: "I thought it
   would land as a typed entry with the message?"

   So the name needs no rule of its own at all. dashboard.php numbers its
   characters CONTINUING the salutation's count, so the rule above already types
   it, in sequence, across the <br>. Fewer rules, and the two lines cannot fall
   out of step because there is only one clock. */

/* THE MESSAGE FADES IN AFTER THE TYPING, AS ONE THING — Daniel, 19-Aug: "i think
   a fade in should apply AFTER the typing has finished on first load. then acts as
   normal after which in session".
   -----------------------------------------------------------------------------
   It typed too, briefly, and that was wrong twice over. At 66 characters it pushed
   the whole entry past two seconds at any readable pace; and a fade is the truer
   treatment anyway — the heading is a greeting being written to you, while this is
   a fact arriving. Two different kinds of thing should not move the same way.

   Its start is --type-done, the moment the heading's last character lands, plus a
   beat to separate them. Nothing here is a hardcoded delay: change the pace or
   lengthen the salutation and this follows on its own.

   480ms and ease-out — well slower than the 140ms shell fade, because this one is
   meant to be noticed arriving where the shell is meant to be already there. It
   was 320ms and Daniel wanted it gentler still (19-Aug: "so it feels more
   seperated"), which is the right lever: a longer fade separates the beat by
   making the message unmistakably a different gesture from the typing, whereas a
   longer delay would only have added a gap of nothing happening.

   Scoped to [data-v2-typed], which is what "acts as normal after" means — on
   every later load in the session there is no attribute, no rule, and the message
   is simply present with everything else. */
.v2-greeting[data-v2-typed] .v2-greeting-sub {
    animation: v2-enter 480ms ease-out calc(var(--type-done) + 160ms) backwards;
}


/* ── Dock ────────────────────────────────────────────────────────────────
   813:146. The primary navigation: five tiles, 205px wide, 34px apart, in a
   20px-padded glass panel with a 44px radius. The tiles don't navigate to
   pages — they open overlay panels (see the "ℹ️ Overlays" canvas), so they're
   buttons with aria-expanded, not links. */
.v2-dock {
    display: flex;
    /* Designed at a 34px gap and 205px tiles, which needs ~1320px of viewport
       to render at full size. Rather than overflowing below that, both scale
       fluidly and reach the designed values at ≥1440px. */
    gap: clamp(16px, 2.4vw, 34px);     /* 808:4813 → 34px at design width */
    align-items: flex-start;
    justify-content: center;

    /* FULL WIDTH OF THE SHELL COLUMN, and this is load-bearing for alignment.
       -------------------------------------------------------------------------
       The status bar right-aligns and the greeting left-aligns to the shell
       column (--shell-max, capped by the gutter). The dock used to size itself
       from its CONTENT instead — five tiles at clamp(132px, 14.2vw, 205px) plus
       four gaps at clamp(16px, 2.4vw, 34px) — and those two only agree at the
       1440 design width, where the tiles happen to land on 205 and fill 1201
       exactly.

       Below that they diverge, which is the drift Daniel reported. At a 1200
       viewport the column is 1128 but the content-sized dock is ~1007, so it
       centres with ~60px of slack either side and the status bar's right edge
       sits 60px outside the dock's. Same 60px on the greeting's left edge.

       Filling the column and letting the tiles flex fixes both edges at every
       width — and at 1440 it still resolves to exactly the designed numbers:
       1201 − 40 padding − 136 gaps = 1025, over five tiles = 205. */
    width: 100%;
    padding: var(--space-5);           /* 20px */
    /* No `border` — the rim is the gradient stroke on ::before. A border here
       as well would double the edge. */
    border-radius: 44px;               /* 813:146 — off the radius scale, and
                                          intentionally: this is the one
                                          "fully rounded panel" in the design */
}

.v2-dock-tile {
    display: flex;
    flex-direction: column;
    gap: var(--space-5);               /* 20px between plate and label */
    align-items: center;

    /* Share the dock's width evenly rather than each carrying its own vw clamp.
       The clamp was what let the dock end up narrower than the shell column at
       mid widths — see the note on .v2-dock. `flex: 1` divides whatever the
       column leaves after the padding and gaps, which IS 205px at 1440, and
       min-width: 0 stops the label's text from setting a floor and pushing the
       row wide again. */
    flex: 1 1 0;
    min-width: 0;
    padding: 0;
    border: 0;
    background: transparent;
    cursor: pointer;
    /* The tile is the click target, so the transition belongs here, not on the
       plate — otherwise the label and plate animate out of step. */
    transition: transform 160ms ease, filter 160ms ease;
}

.v2-dock-tile:hover { transform: translateY(-3px); filter: brightness(1.06); }
.v2-dock-tile:active { transform: translateY(0); }

/* The gradient plate. The icon is an exported SVG laid over it — it carries
   both the glyph and the glass highlight, and on two tiles the ⓘ badge is
   baked in, so it must be rendered from the asset rather than redrawn. */
.v2-dock-plate {
    position: relative;
    width: 100%;
    aspect-ratio: 205 / 124;           /* 808:4816 */
    border-radius: var(--radius-panel); /* 24px */
    overflow: hidden;
}

/* ONE KIND OF TILE ART, as of Justin's redraw (26-Aug).
   -----------------------------------------------------------------------------
   There used to be two, and mixing them up was what squashed the icons: two
   tiles were full 205×124 plates while Resources, My Patients and My Orders were
   bare glyphs that had to be centred at a share of the plate width, each with a
   measured vertical offset to counter the transparent drop-shadow padding in
   its export.

   All five are now full plates (Figma "Glass_Dark_Flat" / "Glass_Light_Flat",
   1909:10199 / 1909:10265), so every one of those special cases has gone —
   along with --dock-glyph-scale, which existed only to size them.

   THE ART IS NOW IDENTICAL IN BOTH THEMES, and nothing here should change that.
   An earlier pass at this comment argued the CSS gradients below were holding up
   a light-mode fade — that the --dock-glyph-opacity wash revealed the matching
   gradient underneath and so only the glyph appeared translucent. Wrong in
   practice: it washed the tiles against the wallpaper, which is what Daniel saw.
   The wash is gone (see the token's grave in the :root block above) and the
   glyph translucency lives in the SVG at fill-opacity 0.9, where Justin drew it.

   THE CSS GRADIENTS STAY ANYWAY, on a smaller argument than the one I withdrew.
   They are covered by an opaque plate, so they paint nothing — but they are
   exactly the right colours (checked stop by stop: Sea Verge, Open Water, Last
   Light, Dune Crest, and Resources' untokenised pair), so a missing or failed
   asset degrades to a correctly coloured tile rather than a hole in the dock.

   object-fit: contain still matters on mobile, where the plate turns square and
   a stretched fill would distort the art. */
.v2-dock-plate img {
    position: absolute;
    inset: 0;
    width: 100%;
    height: 100%;
    object-fit: contain;
}


.v2-dock-label {
    font-size: var(--type-dock-label-size);
    font-weight: var(--type-dock-label-weight);
    line-height: 1.2;
    color: var(--on-glass-label);
    text-align: center;
    /* The design has these at Seafoam 60%, which over a blurred photograph is
       below AA. Raised to 85% via --on-glass-label: still visibly secondary,
       but readable. Flagged for Justin. */
}

/* Per-tile gradients, off the Dock node. Four of the five are the palette's
   named gradients with a per-tile angle and stop positions — so they reference
   the gradient tokens rather than repeating hexes.

   Resources is the exception: its layer is *named* IconGradientShoreline but
   its stops (#0A7DB5 → #5BBF6A) are neither Shoreline's (#0C75A8 → #72BA7C)
   nor any other token's. Left faithful to the design and flagged — it's
   either eyedropper drift or a sixth gradient that never got tokenised. */
.v2-dock-plate--resources {
    background-image: linear-gradient(59.66deg, #0A7DB5 0%, #5BBF6A 100%);
}
.v2-dock-plate--add-patient {          /* Sea Verge, reversed */
    background-image: linear-gradient(226.4deg,
        var(--gradient-sea-verge-to) 9.81%, var(--gradient-sea-verge-from) 81.87%);
}
.v2-dock-plate--request-order {        /* Open Water, reversed */
    background-image: linear-gradient(222.46deg,
        var(--gradient-open-water-to) 20.17%, var(--gradient-open-water-from) 76.9%);
}
.v2-dock-plate--my-patients {          /* Last Light */
    background-image: linear-gradient(58.29deg,
        var(--gradient-last-light-from) 9.38%, var(--gradient-last-light-to) 91.43%);
}
.v2-dock-plate--my-orders {            /* Dune Crest */
    background-image: linear-gradient(58.06deg,
        var(--gradient-dune-crest-from) 6.32%, var(--gradient-dune-crest-to) 80.71%);
}


/* ── Secondary pills ─────────────────────────────────────────────────────
   811:76. Lead Times and Latest Updates, below the dock. */
.v2-pill-row {
    display: flex;
    gap: var(--space-2);               /* 8px */
    align-items: center;
    justify-content: center;
    flex-wrap: wrap;
}

/* One label is always hidden. Long on wider screens, short on phones. */
.v2-pill-label-short { display: none; }

/* THE BADGE OR ICON SITS SNUG IN THE RIGHT-HAND CURVE; THE TEXT FILLS THE
   REST (Daniel, 13-Oct, with the frame: "the numbers or icons on the right
   side are snug with the curve of the pill. with the text filling the rest").
   SecondaryButtons 808:4205: a 185×45 pill, its 33px badge 6px in from the
   top, bottom and right, so the circle is concentric with the pill's end,
   and the text from 14px in.

   This used to centre the label and badge together as one group. In a 185px
   pill that happens to land on the frame, but wherever a pill is wider (the
   tablet and phone rows share the dock's width) the badge drifted in off the
   curve. Now the right padding is the badge's 6px at every width, and the
   label takes the remaining width with its text centred in it. A pill with no
   badge (a count of 0) centres its label across the whole pill. */
.v2-pill {
    display: flex;
    align-items: center;
    justify-content: center;
    gap: var(--space-2);
    min-width: 185px;
    min-height: 45px;
    padding: 6px 6px 6px var(--v2-pill-pad-l, 14px);
    border: 0;
    border-radius: var(--radius-pill);
    background-color: var(--glass-fill);   /* same fill as the dock, per theme */
    -webkit-backdrop-filter: blur(var(--glass-blur));
    backdrop-filter: blur(var(--glass-blur));
    box-shadow: var(--glass-shadow);
    font-size: var(--type-nav2-size);
    font-weight: var(--type-nav2-weight);
    color: rgb(255 255 255 / 78%);     /* design says 50%; lifted for AA */
    cursor: pointer;
    white-space: nowrap;
}

/* Hover lifts the same fill rather than introducing a colour, so it works in
   both themes without a second rule. */
.v2-pill:hover { background-color: var(--glass-fill-hover); }

/* The label: every text span of a pill but the badge and the screen-reader
   count (which is out of the flow anyway). */
.v2-pill > span:not(.v2-pill-badge):not(.v2-visually-hidden) {
    flex: 1 1 auto;
    min-width: 0;
    text-align: center;
}
/* With nothing on the right, the 14px / 6px padding would sit the label off
   centre: even it up. */
.v2-pill:not(:has(> .v2-pill-icon, > .v2-pill-badge:not(:empty):not([data-count="0"]))) {
    padding-right: var(--v2-pill-pad-l, 14px);
}

.v2-pill-icon { flex: none; width: 33px; height: 33px; display: block; }

/* Notification badge — a count in a filled circle.
   -----------------------------------------------------------------------------
   The fill comes from --badge-attention / --badge-info; see semantic.css for which
   meaning gets which colour and why neither --intent- role could be used directly.

   RED IS THE DEFAULT, deliberately. A badge with no modifier is the one that wants
   attention, so a caller who forgets the class over-signals rather than under-signals.
   The alternative — no fill on the base — makes a forgotten class render an invisible
   badge, which is the worse failure for a notification.

   (Supersedes an earlier note here that put the design's red at #E85E58, a gradient
   stop. Pixel-sampling the frame gives #C04644 = sea-pink-600, and #E85E58 is only
   3.4:1 under a white digit.) */
.v2-pill-badge {
    display: grid;
    place-items: center;
    flex: none;
    min-width: 33px;
    min-height: 33px;
    padding-inline: 6px;
    border-radius: var(--radius-pill);
    background-color: var(--badge-attention);
    color: #fff;
    font-size: 1.0625rem;              /* 17px · 808:4189 */
    font-weight: 600;
    font-variant-numeric: tabular-nums;
    line-height: 1;
}

/* "New to read" rather than "needs your action" — Latest Updates, not Order
   queries. One modifier, because red is the base. */
.v2-pill-badge--info { background-color: var(--badge-info); }

/* THE COUNT ROLLS (Daniel, 13-Oct: "make sure that the queries counter has a
   ticker and a small animation when going up or down in count ... should be
   rolling"). shell-figures.js lays the old and new figures out digit by
   digit; a digit that changes is a two-row strip that slides one row, UP
   when the count rises (the new figure comes from below) and DOWN when it
   falls, while unchanged digits stay put, like an odometer. Then the plain
   number is put back. Skipped under reduced motion. */
.v2-roll { display: inline-flex; height: 1em; line-height: 1; overflow: hidden; }
.v2-roll-col { display: inline-flex; flex-direction: column; height: 1em; overflow: hidden; }
.v2-roll-strip { display: flex; flex-direction: column; }
.v2-roll-strip > span { display: block; height: 1em; line-height: 1; text-align: center; }
.v2-roll-col--up .v2-roll-strip   { animation: v2-roll-up .42s cubic-bezier(.2, .8, .2, 1) both; }
.v2-roll-col--down .v2-roll-strip { animation: v2-roll-down .42s cubic-bezier(.2, .8, .2, 1) both; }
@keyframes v2-roll-up   { from { transform: translateY(0); }     to { transform: translateY(-1em); } }
@keyframes v2-roll-down { from { transform: translateY(-1em); }  to { transform: translateY(0); } }

/* THE DASHBOARD PILLS' TWO BADGES IN THE FRAME'S COLOURS, BUT NOT ITS ORDER
   (Daniel, 13-Oct). SecondaryButtons 808:4205 has Order queries as a Seafoam
   circle with a Harbour count and Latest Updates in Sea Pink (rendered
   #DD645D, the base Sea Pink, not the 600). He: "Queries = red, Updates =
   white. Spec is wrong in that aspect": red for what is waiting on you, the
   light one for what is new to read. So Queries is --pink, Updates --light.
   Pills only: the sidebar's nav badges keep the classes above.
   Measured: Harbour on Seafoam 4.7:1; white on Sea Pink 3.4:1, under the 4.5
   asked of 17px text (Sea Pink 600 was 5.0). The frame's colour, his call. */
.v2-pill-badge--light { background-color: var(--colour-seafoam); color: var(--colour-harbour); }
.v2-pill-badge--pink  { background-color: var(--colour-sea-pink); }

/* A count of zero is not "nothing to see" rendered small — it's nothing to
   see. Hidden outright so the badge never draws attention to an empty state. */
.v2-pill-badge:empty,
.v2-pill-badge[data-count="0"],
.v2-pill-badge[hidden] { display: none; }   /* display: grid above would beat [hidden] */

/* THE ICON AT ZERO, THE COUNT OTHERWISE (Daniel, 13-Oct). Queries and Latest
   Updates carry their components-page circle (799:1582: speech bubbles,
   megaphone) beside the badge; it shows while there is no count, the same
   33px circle in the same place as the count, so nothing moves when one
   becomes the other. Both are always in the markup, which is also what lets
   a count arrive from zero without a reload (shell-figures.js). */
.v2-pill:has(> .v2-pill-badge:not([hidden]):not([data-count="0"])) > .v2-pill-icon--idle { display: none; }


/* ── Powered by ──────────────────────────────────────────────────────────
   The wordmark in pc-logo-colour.svg is Seafoam, i.e. drawn for a dark ground.
   That's correct over these photographs, but if a pale wallpaper is ever added
   it will need a dark variant — noting it here rather than discovering it
   then. */
.v2-poweredby {
    display: flex;
    /* BASELINE, not centre. The wordmark read as sitting low beside "Powered by"
       (Daniel, 2026-08-12), and the cause is measurable rather than a matter of
       taste: the logo's x-height glyphs bottom out at y=23.27 in a 27.1975-tall
       export while the `p` descender runs to 27.20, so its baseline is 3.93px
       above its box bottom. Centring the BOX therefore parks the wordmark's
       baseline about 5px below the text's.

       Aligning on baselines is also the robust choice rather than a nudge: there
       is a per-nurse --ui-font-scale preference, and with centring any change to
       the text size would shift the relative alignment again. Baselines hold. */
    align-items: baseline;
    justify-content: center;
    gap: var(--space-2);
    /* Owns the whole pills→footer gap, and matches the dock→pills gap so the
       bottom of the page reads as one even rhythm. The frame leaves a similar
       margin below the wordmark, so the same value works underneath. */
    padding-block: var(--nav-stack-gap);
    font-size: var(--type-caption-size);
    color: rgb(255 255 255 / 70%);
}

/* A TRANSFORM, NOT A MARGIN — the margin was inert, which is why three attempts
   at this changed nothing.
   -----------------------------------------------------------------------------
   Under align-items: baseline a flex item's baseline is SYNTHESISED FROM ITS
   BORDER BOX when the item has no text of its own. An <img> has none, so
   margin-bottom moved the box and the baseline alignment compensated straight
   back: -3.9px, -1.6px and +2px all rendered identically. The only thing that
   ever moved the logo was switching centre → baseline in the first place, which
   overshot upward.

   transform: translateY() can't be compensated away — it is applied after layout
   and baseline alignment are settled — so the nudge actually lands. It also costs
   no reflow, which a margin on a footer item would.

   THE VALUE IS SET BY EYE AND SHOULD STAY THAT WAY. Don't re-derive it from the
   artwork: the mark above the `i` in "choice" is part of the logo and inflates the
   export's box upward, so nothing measured from that box describes where the
   wordmark's visual weight sits. Two correctly-measured attempts (baselines, then
   x-height centres) both looked wrong for exactly that reason — Daniel's "you have
   to almost ignore it" is the accurate read.

   3.5px is his 3–4px, judged from the baseline-aligned position. Positive drops
   the logo. TO NUDGE IT: --poweredby-nudge updates live in devtools. */
.v2-poweredby img {
    height: 27px;
    width: auto;
    transform: translateY(var(--poweredby-nudge, 3.5px));
}

/* The .v2-dev-replay rules that were here are gone with the link they styled —
   Daniel, 19-Aug: "can now make it as it would be without the reply message
   button". The ?greet=1 URL still replays the welcome; it just has no chrome. */


/* ── Scroll regions ──────────────────────────────────────────────────────
   Scrollbar_Vertical_Light 841:4418 / _Dark 841:4849, the masters in the
   library's own "Scroll bars" section (841:4852). Instances of them appear
   inside the Resources and Register-a-patient panels; the masters and the
   instances agree exactly.

       track   12px wide, radius 11px, Pebble 100 light / Flint 600 dark
       thumb    6px wide, radius 1000px, Rockpool — inset 3px on every side

   TWO DELIBERATE DEVIATIONS FROM THAT, both recorded where they happen.

   1. THE THUMB STEPS PER THEME. The file draws it as Rockpool 500 in BOTH
      modes, which measured 3.9:1 against the light track and only 2.6:1 against
      the dark one — under WCAG 1.4.11's 3:1 for a control you have to see in
      order to use it. --brand-secondary is Rockpool 500 light / Rockpool 300
      dark, so the role that fits the design is also the one that fixes the dark
      case. Same stepping mechanism as everywhere else, and no new token.

   2. THERE IS NO TRACK ANY MORE (26-Aug). The 12px width and the thumb's 3px
      inset are kept, but nothing is painted behind it — the thumb floats on
      whatever it is scrolling. It came out of the dropdown, where a
      --surface-card channel down a pinned-white list read as a recess cut into
      it, and Daniel took it as the global treatment: "definately the scroll bar
      we want." The measurements that made it safe are on
      ::-webkit-scrollbar-track below. --surface-card is therefore no longer
      part of this component at all.

   THE LIBRARY HAS NO HORIZONTAL VARIANT. Where horizontal scrolling is
   unavoidable (a wide table, a code block) use the same treatment and flag it —
   don't invent a second scrollbar.

   .v2-gp-results IS IN THE LIST for the same reason, added the same day: the
   lookup dropdown caps at a max-height and scrolls, so it too is ALWAYS a scroll
   container. Daniel, seeing the OS bar in it: "also fix the styling to our styles
   of scroll bar and drop down?" — both lookups render into this one class, so
   Add Patient's picker is fixed by the same line as the edit modal's.

   .v2-modal-body IS IN THE LIST TOO, since 18-Sep. Daniel, on the edit modal:
   "scroll bar isnt ours, we =keep making that mistake." Keep making it is the
   operative part — every modal that outgrows its dialog drew the OS bar, and
   every time the fix was applied to that one modal. .v2-modal-body is
   overflow-y: auto unconditionally, exactly like a panel body, so it is a scroll
   container ALWAYS and belongs here rather than being opted in per modal. Fixed
   for every dialog in the portal at once, which is the only way it stops
   recurring.

   .v2-panel-body is in the selector list rather than composing .v2-scroll in
   markup: a panel body is ALWAYS the scroll container on desktop, so making it
   opt-in would only be a thing to forget. Any other scroll region — the
   status-bar panel's results, a long list — takes .v2-scroll explicitly.

   Not inside a media query. On desktop the panel body is what scrolls; on mobile
   the window does and this simply has no scrollbar to paint. Styling it either
   way costs nothing and means any future scroll region matches wherever it
   appears. */
/* THE STANDARD PROPERTIES AND THE WEBKIT PSEUDO-ELEMENTS ARE MUTUALLY EXCLUSIVE.
   -----------------------------------------------------------------------------
   This is why the arrows survived the first attempt at removing them. In Chromium,
   specifying `scrollbar-width` or `scrollbar-color` on an element makes it IGNORE
   every ::-webkit-scrollbar-* rule for that element — so declaring both, as this
   did, silently threw away the whole design: the 12px width, the 11px radius, the
   inset 6px thumb AND the button-hiding, leaving Chrome's own generic bar with its
   steppers.

   So the standard properties are gated to engines that have no pseudo-element to
   ignore them in favour of — i.e. Firefox, where `thin` and a two-colour
   scrollbar-color is the whole of what's available. `@supports not selector(…)` is
   exact about that and needs no browser sniff. */
@supports not selector(::-webkit-scrollbar) {
    .v2-scroll,
    .v2-panel-body,
    .v2-modal-body,
    .v2-gp-results,
    :root:has(> body.ui-next) {
        scrollbar-width: thin;      /* no px control here; `thin` is nearest 12px */
        /* transparent is how Firefox is told not to draw a track — see the note
           on ::-webkit-scrollbar-track below. */
        scrollbar-color: var(--brand-secondary) transparent;
    }
}

.v2-scroll,
.v2-panel-body {
    /* Reaching the end of a panel shouldn't start dragging the page behind it.
       Matters on touch, where the window IS scrollable. */
    overscroll-behavior: contain;
}

/* ── A BAR FILLS ITS SPACE, IT DOES NOT TAKE IT ────────────────────────────
   Daniel, 17-Sep: "all scroll bars should fill space, not push intot it".

   Stated as a rule, so it belongs on the shared class rather than being fixed
   per scroller — it had already been written out three times in overlays.css by
   the time he said it.

   A scrollbar takes its width from the CONTENT box, which shifts everything
   when it appears and, where the content re-flows, can oscillate: bar appears,
   content narrows, a row re-flows, content shortens, bar goes, content widens,
   round again. Reserving the width means the content never changes size, so
   there is no shift and nothing to feed back.

   NOT on .v2-panel-body, deliberately: the request-order panel sets
   scrollbar-gutter: auto there on purpose, because its body does not scroll and
   the reserved 12px put the head and the basket column on right edges 12px
   apart. Adding it here would re-break an alignment that took three attempts.

   Safari gained scrollbar-gutter in 18.2; older versions simply behave as they
   do today rather than worse. */
.v2-scroll { scrollbar-gutter: stable; }

/* The WINDOW's scrollbar gets the same treatment.
   On desktop section view there shouldn't be one at all — the shell is pinned and
   the panel body scrolls — but the home view is still allowed to grow on a short
   window, and mobile scrolls the page by design. When it does appear it should be
   the design's bar, not the OS default.

   Scoped through the v2 body class so this can't reach a classic page once
   ui_next_stylesheets() is wired into header.php. */
.v2-scroll::-webkit-scrollbar,
.v2-panel-body::-webkit-scrollbar,
.v2-modal-body::-webkit-scrollbar,
.v2-gp-results::-webkit-scrollbar,
:root:has(> body.ui-next)::-webkit-scrollbar { width: 12px; }

/* No stepper arrows. The component is a track and a thumb, full stop — there are
   no buttons at either end of 841:4418. WebKit draws them by default and they also
   eat 12px off each end of the track, so the thumb's travel stops matching the
   design's.

   The `:single-button` / `:start:decrement` / `:end:increment` variants are listed
   as well as the bare selector: platforms differ in which of them they actually
   render, and a bare ::-webkit-scrollbar-button doesn't always win against a
   platform default that targets one of the specific states. */
.v2-scroll::-webkit-scrollbar-button,
.v2-panel-body::-webkit-scrollbar-button,
.v2-modal-body::-webkit-scrollbar-button,
.v2-gp-results::-webkit-scrollbar-button,
:root:has(> body.ui-next)::-webkit-scrollbar-button,
.v2-scroll::-webkit-scrollbar-button:single-button,
.v2-panel-body::-webkit-scrollbar-button:single-button,
.v2-modal-body::-webkit-scrollbar-button:single-button,
.v2-gp-results::-webkit-scrollbar-button:single-button,
:root:has(> body.ui-next)::-webkit-scrollbar-button:single-button,
.v2-scroll::-webkit-scrollbar-button:start:decrement,
.v2-panel-body::-webkit-scrollbar-button:start:decrement,
.v2-modal-body::-webkit-scrollbar-button:start:decrement,
.v2-gp-results::-webkit-scrollbar-button:start:decrement,
:root:has(> body.ui-next)::-webkit-scrollbar-button:start:decrement,
.v2-scroll::-webkit-scrollbar-button:end:increment,
.v2-panel-body::-webkit-scrollbar-button:end:increment,
.v2-modal-body::-webkit-scrollbar-button:end:increment,
.v2-gp-results::-webkit-scrollbar-button:end:increment,
:root:has(> body.ui-next)::-webkit-scrollbar-button:end:increment {
    display: none;
    width: 0;
    height: 0;
}

/* Where a vertical and a horizontal bar would meet. Nothing is drawn for it in
   the design, and the default is a grey square that doesn't match either track. */
.v2-scroll::-webkit-scrollbar-corner,
.v2-panel-body::-webkit-scrollbar-corner,
.v2-modal-body::-webkit-scrollbar-corner,
.v2-gp-results::-webkit-scrollbar-corner,
:root:has(> body.ui-next)::-webkit-scrollbar-corner { background: transparent; }

/* NO TRACK, ANYWHERE. The thumb floats on whatever it is scrolling.
   -----------------------------------------------------------------------------
   This started as a fix for the dropdown, whose list is pinned white and where
   a --surface-card channel looked like a recess cut into it. Daniel, 26-Aug:
   "thats fantastic and definately the global scroll bar we want."

   MEASURED ON EVERY SURFACE IT CAN LAND ON before removing it, because the
   track was what the thumb used to read against — 1.4.11 wants 3:1 for a UI
   component:

       panel body  light   Rockpool 500 on --surface-page Seafoam 200   4.14:1
       panel body  dark    Rockpool 300 on --surface-page Flint 500     3.63:1
       on a card   light   Rockpool 500 on --surface-card Pebble 100    3.88:1
       on a card   dark    Rockpool 300 on --surface-card Flint 600     4.55:1

   The worst case is 3.63:1, which is the dark panel body — better than the
   3.88:1 the light track used to give, so nothing is lost by this.

   THE WINDOW BAR IS SAFE TOO, which was the one worth checking: it sits over
   the BODY, and the body is a solid --surface-page. The wallpaper is inside
   .v2-shell, which the window scrollbar is outside of — so the thumb never ends
   up floating on a photograph, where contrast would be anyone's guess.

   Kept as an explicit `transparent` rather than deleted: WebKit draws its own
   track otherwise, and a deleted rule reads as an oversight where this reads as
   a decision. */
.v2-scroll::-webkit-scrollbar-track,
.v2-panel-body::-webkit-scrollbar-track,
.v2-modal-body::-webkit-scrollbar-track,
.v2-gp-results::-webkit-scrollbar-track,
:root:has(> body.ui-next)::-webkit-scrollbar-track {
    background-color: transparent;
}

/* 3px of transparent border with background-clip: content-box turns the 12px
   track into the file's 6px thumb AND reproduces its 3px inset on every side —
   one declaration instead of a separate track-piece rule. */
.v2-scroll::-webkit-scrollbar-thumb,
.v2-panel-body::-webkit-scrollbar-thumb,
.v2-modal-body::-webkit-scrollbar-thumb,
.v2-gp-results::-webkit-scrollbar-thumb,
:root:has(> body.ui-next)::-webkit-scrollbar-thumb {
    background-color: var(--brand-secondary);
    border: 3px solid transparent;
    background-clip: content-box;
    border-radius: 1000px;
}


/* ── Cards inside a panel ────────────────────────────────────────────────
   The screens' "ProductTypeCard" / "Section/FormField" container. One shape used
   for both: a neutral card on the panel's own surface.

     background  --surface-card   Pebble 100 light / Flint 600 dark
     radius      20px             --radius-card
     padding     20px

   Measured on LeadTimes/Desktop/Light 1300:3362 and /Dark 1300:3310; the
   Resources and Register-a-patient frames use the same values. */
.v2-card {
    display: grid;
    /* 30px, which is what every Section/FormField in the file uses between its
       heading and its content — Register's three sections (1300:4887, 1300:4915,
       1300:4923) and Lead Times' lookup card all agree. Off §6's stated ramp, like
       the 10px in a choice card; the frames win. */
    gap: 30px;
    padding: var(--space-5);                /* 20px */
    border-radius: var(--radius-card);      /* 20px */
    background-color: var(--surface-card);
}


/* ── Forms ───────────────────────────────────────────────────────────────
   The <form> carries the rhythm between sections, not .v2-panel-body — the body's
   only child is the form, so without this the three cards would sit flush against
   each other. 20px, matching Frame 464's gap between blocks. */
.v2-form {
    display: grid;
    gap: var(--space-5);
}


/* ── Form fields ─────────────────────────────────────────────────────────
   From RegisterANewPatient (1300:4882 light / 1300:4811 dark), which is the first
   screen to exercise them. These are library components, not Register's: Request
   Patient Order and the Resources search need the same pieces.

   A section is a .v2-card with a heading and a body. The heading is 24px Bold in
   Harbour (1300:4917) — the frames say colour/harbour-light, and --brand-primary is
   that stepped for contrast, 4.4:1 on a Pebble 100 card against Harbour Light's
   2.6:1. At 24px Bold it only needs 3:1, so this is comfortable rather than
   marginal. */
.v2-section-title {
    margin: 0;
    font-size: var(--type-h2-size);         /* 24px */
    font-weight: 700;
    line-height: 1.2;
    color: var(--brand-primary);
}

/* TWO COLUMNS, which is both the frame's layout and Daniel's preference.
   -----------------------------------------------------------------------------
   1300:4890 draws two columns of 395px — 810 across, the card's 850 less its
   padding. Row pitch in the frame is 77px against a 57px field, so the row gap is
   the same 20px as the gutter.

   I briefly took this to three columns (a037fbfc) so short fields like Salutation
   and Date of Birth could share a row with a third. Daniel reverted it: "I think the
   dual column looks better." He's right that two reads calmer — three 256px boxes
   in a row start to look like a spreadsheet, and the form is long enough that
   density isn't the problem worth solving.

   minmax(0, 1fr) rather than a fixed 395px so a narrower panel reflows instead of
   overflowing; at the design width it resolves to exactly 395. */
.v2-form-grid {
    display: grid;
    grid-template-columns: repeat(2, minmax(0, 1fr));
    gap: var(--space-5);                    /* 20px both axes */
}

/* Spans, so placement stays DATA in the PHP rather than a class per field — kept
   from the three-column attempt because it is the better mechanism either way.

   On two columns --span-2 and --wide coincide, which is fine: the section list says
   what a field means ("this one needs the full width") and the grid decides what
   that costs. If a future panel wants three columns, the intent is already
   expressed. */
.v2-form-field--wide   { grid-column: 1 / -1; }
.v2-form-field--span-2 { grid-column: span 2; }

/* One column on a phone, which is what the mobile frame draws: Form Fields
   1300:4961 is 346 wide with its five fields stacked at y=0/75/150/225/300, no
   pairing. Both span modifiers become no-ops there, correctly — spanning two of one
   column is still that column. */
@media (max-width: 640px) {
    .v2-form-grid { grid-template-columns: minmax(0, 1fr); }
    .v2-form-field--span-2 { grid-column: auto; }
}

/* Label, control, then optional help text. 7px between them (1300:4900), which is
   off every scale in the system — it is what the frame says and it is doing real
   work: any more and the label stops reading as attached to its field. */
.v2-form-field {
    display: grid;
    gap: 7px;
    align-content: start;
}

/* An action sitting ON the field grid — the alternate-contact toggle beside the
   Email field.
   -----------------------------------------------------------------------------
   This was align-content: end, on the reasoning that bottom-aligning puts the
   action on the CONTROL's line rather than level with the 12px label. In
   practice it read as hanging off the bottom of the input rather than sitting
   with it (Daniel, 25-Aug: "see how its alligned to the bottom of the fields
   rather than central").

   Centred instead. The cell's height is set by the tallest field in the row —
   label + 7px gap + control — so centring puts the action just above the
   control's own midline, which is close enough to read as aligned and is stable
   whatever the label wraps to. Aligning to the control's exact centre would mean
   knowing the label's height here, which this cell cannot see. */
.v2-form-action {
    display: grid;
    align-content: center;
    justify-items: start;
}

/* A text action rather than a filled button — it adds a field, it isn't the
   section's primary act. Harbour so it reads as interactive, and --brand-primary is
   already the contrast-stepped pair. */
.v2-button--text {
    min-height: 36px;
    padding: var(--button-pad-y) 0;
    background: none;
    border: 0;
    color: var(--brand-primary);
    font-family: inherit;
    font-size: var(--type-body-size);
    font-weight: 500;
    text-align: left;
    cursor: pointer;
}

.v2-button--text:hover:not([disabled]) { text-decoration: underline; }
.v2-button--text[disabled] { cursor: not-allowed; }


/* 12px Medium, and --text-label rather than --brand-primary. The frames draw the
   label the same Harbour Light as the section heading, but 12px needs 4.5:1 where
   24px Bold needs 3:1 — Harbour 500 measures 4.4:1 and misses. See semantic.css. */
.v2-label {
    font-size: var(--type-caption-size);    /* 12px */
    font-weight: 500;
    line-height: 1.3;
    color: var(--text-label);
}

/* The required marker. Colour alone must never carry meaning (WCAG 1.4.1), and it
   doesn't here — the input carries `required`, so assistive tech is told properly
   and this asterisk is the visual echo of that rather than the fact itself. */
.v2-label-required {
    margin-left: 2px;
    color: var(--intent-danger-text);
}

/* Help and error text under a field. 12px, and the frame's colour/flint/200 is
   2.0:1 on a card — --text-muted is the stepped role at 5.5:1 (1300:4905). */
.v2-field-help {
    margin: 0;
    font-size: var(--type-caption-size);
    font-weight: 500;
    line-height: 1.4;
    color: var(--text-muted);
}

.v2-field-help--error { color: var(--intent-danger-text); }


/* ── Collapsed field group ────────────────────────────────────────────────
   display:contents so the fields inside stay on the PARENT grid — opening the
   group must not re-flow them into a nested layout of their own.

   The [hidden] rule is load-bearing, not defensive: `display: contents` here is
   an author declaration and would beat the browser's own `[hidden]{display:none}`,
   so without it the group would never actually hide. */
.v2-form-collapse { display: contents; }
.v2-form-collapse[hidden] { display: none; }

/* The toggle sits on its own grid line rather than beside a field, so it reads
   as an action on the section and not a label for the input above it. */
.v2-form-action--span { grid-column: 1 / -1; }


/* ── GP surgery picker, and the self-healing email's note ─────────────────
   Both live inside .v2-form-field, so they inherit its column and need no
   positioning maths. Every value below is an existing token — no new ones and
   no !important, per the contract frozen 25-Aug. */

/* The note the GP email speaks through when our confirmed address replaces the
   directory's. Deliberately NOT styled as an error: nothing went wrong, and a
   red flash would teach nurses to dread a routine correction. Sits apart from
   --text-muted help text because this one reports an ACTION taken on their
   behalf and has to be read, not skimmed. */
.v2-field-note {
    margin: var(--space-2) 0 0;
    font-size: var(--type-caption-size);
    font-weight: 500;
    line-height: 1.4;
    color: var(--text-body);
}

.v2-gp-results {
    margin-top: var(--space-2);
    display: flex;
    flex-direction: column;
    /* Capped and scrollable: the proxy returns up to 20, and a list that long
       would push the rest of the GP block off screen. */
    max-height: 280px;
    overflow-y: auto;
    background: var(--surface-raised);
    border: 1px solid var(--border-subtle);
    border-radius: var(--radius-button);
}

.v2-gp-result {
    display: flex;
    flex-direction: column;
    gap: 2px;
    padding: var(--space-3);
    background: none;
    border: 0;
    border-bottom: 1px solid var(--border-subtle);
    text-align: left;
    font: inherit;
    color: var(--text-body);
    cursor: pointer;
}

.v2-gp-result:last-child { border-bottom: 0; }

.v2-gp-result:hover,
.v2-gp-result:focus-visible {
    background: var(--surface-hover);
}

/* Focus has to be visible on a keyboard path through a list of choices —
   :hover alone leaves keyboard users with no idea where they are. */
.v2-gp-result:focus-visible {
    outline: 2px solid var(--border-focus);
    outline-offset: -2px;
}

.v2-gp-result-name { font-weight: 600; }

.v2-gp-result-address {
    font-size: var(--type-caption-size);
    color: var(--text-muted);
}


/* ── Segmented choice (Yes / No) ──────────────────────────────────────────
   1300:4918. A question on the left, the options right-aligned on the same row.
   Selected is a Meadow fill; unselected is Flint 50 with a Pebble 200 border
   (1300:4921 / 1300:4922). Both 36px tall, 12px radius, min-width 80.

   RADIOS, NOT BUTTONS, in the markup — this is a single choice with a value, so it
   has to be keyboard-arrow navigable and announce itself as a group. The visual is
   applied to the labels; the inputs are visually hidden but still focusable, which
   is what keeps :focus-visible working. */
.v2-question-row {
    display: flex;
    align-items: center;
    justify-content: space-between;
    /* So the error bed drops onto its own line under the row rather than
       becoming a third item beside the Yes/No pair. */
    flex-wrap: wrap;
    gap: var(--space-4);
    margin: 0;
    padding: 0;
    border: 0;              /* it is a <fieldset> */
}

/* The bed under a QUESTION ROW rather than under an input. Two corrections to
   the field version: it spans the row, and it does not inherit the -7px that
   cancels .v2-form-field's 7px grid gap — this fieldset's gap is --space-4, and
   borrowing the field's arithmetic here would pull the message up into the
   options. Fully rounded too: it hangs below a row, not off the bottom edge of
   a control, so there is no edge for it to continue. */
.v2-question-row > .v2-field-error {
    flex: 0 0 100%;
    margin: calc(var(--space-4) * -1 + 2px) 0 0;
    border-radius: var(--radius-button);
}

.v2-choice-question {
    margin: 0;
    font-size: var(--type-body-size);       /* 15px Medium */
    font-weight: 500;
    color: var(--text-body);
}

.v2-choice-options {
    display: flex;
    gap: var(--space-2);                    /* 8px */
    flex: 0 0 auto;
}

.v2-choice-option { position: relative; display: flex; }

/* Visually hidden but NOT display:none — it has to stay focusable and reachable. */
.v2-choice-option input {
    position: absolute;
    inset: 0;
    opacity: 0;
    margin: 0;
    cursor: pointer;
}

/* THE UNSELECTED PILL STAYS LIGHT IN BOTH THEMES — its TEXT is what had to be pinned.
   -----------------------------------------------------------------------------
   In dark mode the resting Yes/No buttons came out white on white (Daniel, 14-Aug).
   The fill was right and the copy was wrong: I had the frame's colour/flint/50 fill
   but left the label on --text-body, which correctly becomes Seafoam in dark. Light
   pill, near-white text.

   My first fix made it worse by swapping the FILL to --surface-raised so the pair
   would move together. Daniel showed the target: the pill is a light pill on a dark
   card, with dark text. The dark frame agrees — 1300:4922 gives it colour/flint/50
   with colour/flint copy, neither swapped, which is §2's "most colours stay the same
   across both modes" applied literally.

   SO THIS IS THE .v2-field SITUATION EXACTLY: an element whose surface does not swap
   cannot let its copy follow --text-body. Both are pinned to primitives here, and
   that is correct rather than a rule break — the rule exists so a themed surface
   can't drift from its themed copy, and this surface isn't themed.

   The border is pinned for the same reason. --border-subtle is Flint 400 in dark,
   which would draw a dark ring round a near-white pill; the dark frame says Pebble
   200, the same as light. */
.v2-choice-option span {
    display: grid;
    place-items: center;
    min-width: 80px;
    min-height: 36px;
    padding: var(--button-pad-y) var(--button-pad-x);
    border: 1px solid var(--colour-pebble-200);
    border-radius: var(--radius-button);
    background-color: var(--colour-flint-50);
    color: var(--colour-flint-500);
    font-size: var(--type-body-size);
    font-weight: 500;
    line-height: 1;
    cursor: pointer;
    transition: background-color 120ms ease, color 120ms ease, border-color 120ms ease;
}

/* Selected. Meadow fill, and --text-on-accent for the label: Seafoam on Meadow is
   2.1:1, which is the same trap as the Search button. */
.v2-choice-option input:checked + span {
    border-color: transparent;
    background-color: var(--intent-success);
    color: var(--text-on-accent);
}

.v2-choice-option input:focus-visible + span {
    outline: 2px solid var(--border-focus);
    outline-offset: 2px;
}

/* Hover is pinned for the same reason the resting state is. --surface-hover was here,
   and in dark it resolves to a dark translucent fill under copy that is deliberately
   Flint 500 — dark on dark, which is what Daniel saw. An unswapped surface has to pin
   every one of its states, not just at rest.
   One step down the same ramp: Flint 50 -> 100 is clearly perceptible (L .853 -> .659)
   and keeps the copy at 6.2:1. The border steps with it, or Pebble 200 would vanish
   into the darker fill and the pill would lose its edge on hover. */
/* ── AND ONLY WHERE THERE IS A POINTER TO HOVER WITH ─────────────────────────
   Daniel, 28-Sep, on the clinic type pills: "i notice the NHS type selection has
   a weird activation step before going blue? its not a clean transition?"

   That step IS this rule. A touch fires :hover before the checkbox flips, so the
   pill goes to the grey hover fill first and only then to its selected colour —
   two visible states for one tap, with the 120ms transition making both of them
   legible. Worse, :hover then STICKS on the last thing tapped, because nothing
   moves the pointer away on a touchscreen.

   (hover: hover) is the whole fix. A device with a real pointer keeps exactly
   the behaviour it has now; a touchscreen never enters a state it has no way to
   leave. Nothing about the selected or resting appearance changes anywhere.

   SHARED, deliberately. This is not a registration bug — the same two-stage tap
   and the same stuck fill are on every choice row in v2, so the guard belongs on
   the component rather than in one page's overrides. */
@media (hover: hover) {
    .v2-choice-option input:not(:checked):hover + span {
        border-color: var(--colour-pebble-300);
        background-color: var(--colour-flint-100);
    }
}


/* ── Choice card ─────────────────────────────────────────────────────────
   ProductTypeCard proper (1300:3362): a card that IS a control — glyph, title,
   one line of description, and a trailing arrow. Used to pick a branch before
   any data is shown.

   A <button> rather than a card containing a link: the whole 415×140 rectangle
   is the target in the design, and wrapping a card in an <a> whose text is the
   title would make the accessible name the title alone while the description
   sat outside it. */
.v2-choice-card {
    display: grid;
    /* icon row, then the text row — the frame's own 10px gap between them, with
       the icon row sized to the tallest glyph (45px) so both cards' text blocks
       start on the same line. Without that the Lymphoedema glyph (45px) and the
       Dressings glyph (41.6px) would offset their titles by 3.4px. */
    grid-template-rows: 45px auto;
    gap: 10px 0;                            /* the frame's own gap. Even, but not
                                               on §6's 4/8/12/16/20/24 ramp — as
                                               is the 30px inside the lookup card.
                                               The Guidelines say dev mode is the
                                               source of truth for exact spacing,
                                               so the frame wins over the ramp.
                                               Worth confirming which §6 means */
    align-content: start;
    width: 100%;
    min-height: 140px;
    padding: var(--space-5);
    border: 0;
    border-radius: var(--radius-card);
    background-color: var(--surface-card);
    color: var(--text-body);
    text-align: left;
    font-family: inherit;
    cursor: pointer;
    transition: background-color 120ms ease, box-shadow 120ms ease;
}

.v2-choice-card:hover:not([disabled]) {
    background-color: var(--surface-hover);
}

/* Inert until the screen it opens exists. Marked rather than hidden, and kept
   visually intact — the same treatment the status-bar controls get. */
.v2-choice-card[disabled] { cursor: not-allowed; }

/* Top-aligned in its row, which is where the frame puts both glyphs (each sits
   at the card's 20px padding). The row being fixed at the taller of the two is
   what aligns the two text blocks; the glyphs themselves stay exactly as drawn
   rather than being nudged to a shared baseline. */
/* The glyph's own mask-image and box are set per card by the panel that owns it
   — see the Lead Times block in overlays.css. They must be declared in a
   STYLESHEET rather than in an INLINE STYLE ATTRIBUTE, and that part still
   holds: a relative url() in a custom property set inline has no single agreed
   base, resolves against the using stylesheet, 404s, and masks the element out
   entirely. That is what made every icon vanish on the first pass.

   What has changed is that the URLs are now data URIs, which have no base to
   resolve — so a custom property declared IN THIS FILE is safe, and is used to
   avoid carrying each SVG twice for the -webkit- prefix. */
.v2-choice-glyph {
    display: block;                  /* a <span> is not replaced, so width and
                                        height only apply once it is blockified —
                                        never leave that to the parent */
    align-self: start;
    justify-self: start;
    background-color: var(--brand-primary);
    -webkit-mask-repeat: no-repeat;
            mask-repeat: no-repeat;
    -webkit-mask-position: left top;
            mask-position: left top;
    -webkit-mask-size: contain;
            mask-size: contain;
}

.v2-choice-body {
    display: flex;
    align-items: flex-end;           /* the arrow sits on the description's line */
    justify-content: space-between;
    gap: var(--space-4);
}

.v2-choice-text { display: grid; gap: var(--space-4); }   /* 16px, per the frame */

/* 24px Bold in Harbour. The frames use colour/harbour-light in BOTH modes, which
   measures 2.6:1 on a Pebble 100 card — under even the 3:1 large-text threshold.
   --brand-primary is already the stepped pair (Harbour 500 light / 400 dark), so
   the role that exists is also the correct one: 4.4:1 light, 3.9:1 dark, and
   at 24px Bold both clear large-text AA. */
.v2-choice-title {
    margin: 0;
    font-size: var(--type-h2-size);         /* 24px */
    font-weight: 700;
    line-height: 1.2;
    color: var(--brand-primary);
}

.v2-choice-desc {
    margin: 0;
    font-size: var(--type-body-size);       /* 15px Medium */
    font-weight: 500;
    color: var(--text-body);
}

/* Icon/ArrowRight (1300:3372) is a single 32px path: a filled disc with the
   arrow knocked OUT of it. Masking reproduces that exactly — the hole stays
   transparent and the card shows through — where an <img> could not be themed.

   Ring colour is flint-100 light / flint-400 dark; --border-subtle is
   flint-400 in dark (exact) and pebble-200 in light (#D8DDE0 vs #D0D5D8, two
   units per channel). That measures 1.2:1 against the card, which would fail
   WCAG 1.4.11 if the arrow carried meaning — it doesn't: the card itself is the
   control and the title and description carry it. Decorative, so aria-hidden,
   and left as drawn rather than darkened against Justin's intent. */
.v2-choice-arrow {
    display: block;
    flex: 0 0 auto;
    width: 32px;
    height: 32px;
    background-color: var(--border-subtle);
    /* Named here rather than passed in: every choice card uses the same arrow, so
       there is nothing per-instance about it. Relative to this file. */
    --v2-mask: url("data:image/svg+xml,%3Csvg preserveAspectRatio='none' overflow='visible' style='display: block;' width='32' height='32' viewBox='0 0 32 32' fill='none' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath id='Vector' d='M16 0C12.8355 0 9.74207 0.938383 7.11088 2.69649C4.4797 4.45459 2.42894 6.95345 1.21793 9.87706C0.00693255 12.8007 -0.309921 16.0177 0.307443 19.1214C0.924806 22.2251 2.44866 25.0761 4.6863 27.3137C6.92394 29.5513 9.77486 31.0752 12.8786 31.6926C15.9823 32.3099 19.1993 31.9931 22.1229 30.7821C25.0466 29.5711 27.5454 27.5203 29.3035 24.8891C31.0616 22.2579 32 19.1645 32 16C31.9955 11.7579 30.3084 7.69085 27.3088 4.69124C24.3091 1.69163 20.2421 0.00447972 16 0ZM23.0246 16.8708L18.1015 21.7938C17.8706 22.0248 17.5574 22.1545 17.2308 22.1545C16.9042 22.1545 16.5909 22.0248 16.36 21.7938C16.1291 21.5629 15.9993 21.2497 15.9993 20.9231C15.9993 20.5965 16.1291 20.2832 16.36 20.0523L19.1831 17.2308H9.84616C9.51974 17.2308 9.20669 17.1011 8.97587 16.8703C8.74506 16.6395 8.61539 16.3264 8.61539 16C8.61539 15.6736 8.74506 15.3605 8.97587 15.1297C9.20669 14.8989 9.51974 14.7692 9.84616 14.7692H19.1831L16.36 11.9477C16.1291 11.7167 15.9993 11.4035 15.9993 11.0769C15.9993 10.7503 16.1291 10.4371 16.36 10.2062C16.5909 9.97521 16.9042 9.84547 17.2308 9.84547C17.5574 9.84547 17.8706 9.97521 18.1015 10.2062L23.0246 15.1292C23.139 15.2435 23.2298 15.3793 23.2918 15.5287C23.3537 15.6781 23.3856 15.8383 23.3856 16C23.3856 16.1617 23.3537 16.3219 23.2918 16.4713C23.2298 16.6207 23.139 16.7565 23.0246 16.8708Z' fill='%23D0D5D8'/%3E%3C/svg%3E");
            -webkit-mask-image: var(--v2-mask);
            mask-image: var(--v2-mask);
    -webkit-mask-repeat: no-repeat;
            mask-repeat: no-repeat;
    -webkit-mask-position: center;
            mask-position: center;
    -webkit-mask-size: contain;
            mask-size: contain;
}

.v2-choice-card:hover:not([disabled]) .v2-choice-arrow {
    background-color: var(--brand-primary);
}


/* ── Buttons ─────────────────────────────────────────────────────────────
   Guidelines §6: 8px/16px padding, 8px between buttons. The screens' medium
   button is 36px tall with a 12px radius (Lead Times "Search", 1300:3396).

   The label colour comes from --text-on-brand or --text-on-accent depending on
   how dark the fill is — see the note on those roles. It is NOT one value:
   light copy on Meadow measures 2.1:1. */
.v2-button {
    display: inline-flex;
    align-items: center;
    justify-content: center;
    gap: var(--button-gap);
    min-height: 36px;
    padding: var(--button-pad-y) var(--button-pad-x);   /* 8px 16px */
    border: 0;
    border-radius: var(--radius-button);               /* 12px */
    font-family: inherit;
    font-size: var(--type-body-size);                  /* 15px Medium */
    font-weight: 500;
    line-height: 1;
    text-decoration: none;
    cursor: pointer;
    transition: background-color 120ms ease;
}

/* NO OPACITY WASH. Daniel asked why the Download button's label looked faded
   (14-Aug) — it was this, at 0.65, dimming fill and label together. Two reasons it
   had to go.

   It made a bad contrast worse: white on Meadow is already 2.1:1, and 65% opacity
   takes it to roughly 1.5:1, which is not legible by any measure.

   And it was distorting the design under review. Every disabled button on this
   surface is disabled because the FEATURE isn't built, not because the design says
   the control is unavailable — a placeholder state was making Justin's button look
   like something he never drew. The `disabled` attribute still does the real work:
   not clickable, out of the tab order, announced as unavailable, and title="Not
   built yet" says why.

   A genuine disabled treatment — a form that can't submit yet, an action the nurse
   lacks permission for — isn't in the frames, and shouldn't get invented as a side
   effect of placeholders. Worth asking Justin for when a screen actually needs one. */
.v2-button[disabled] { cursor: not-allowed; }

/* ── A DISABLED FILL KEEPS ITS COLOUR ──────────────────────────────────────
   I gave the filled variants the --pending treatment when disabled, on the
   argument that Add opens unavailable and a lit meadow button would look ready
   to press. Daniel, 20-Sep, overruled it: "i dont think i like the removal of
   the button colour until active, id rather it there but disabled then missing
   colour".

   His call, and the note above stands behind it — no unavailable treatment
   exists in the frames, so the one I reached for was the closest thing rather
   than the right thing. cursor: not-allowed is the signal, as it was before.
   Recorded because the reasoning will look like an omission otherwise, and
   because it is worth Justin's eye if a disabled fill ever does need a state. */

/* Meadow. §1's success/positive fill, and what the screens use for the primary
   action in a panel. Dark label — see --text-on-accent. */
.v2-button--accent {
    background-color: var(--intent-success);
    color: var(--text-on-accent);
}

.v2-button--accent:hover:not([disabled]) {
    background-color: var(--colour-meadow-600);   /* a scale step, per §1: never
                                                     a filter or an opacity */
}

/* SECONDARY — an outline, for the second of two actions.
   -----------------------------------------------------------------------------
   A bare .v2-button has NO FILL AND NO BORDER: the base rule sets geometry and
   the variants supply the paint. So `class="v2-button"` on its own renders as
   floating text, which is what Daniel saw on the details viewer (26-Aug: "just
   the something not right button isnt right") — and it was three buttons, not
   one: "Start from scratch" and "Back to dashboard" in the two modals had the
   same problem, unnoticed because nobody had looked at them yet.

   THE LIBRARY HAS NO SECONDARY BUTTON. It has accent (Meadow), primary
   (Harbour) and text. Every screen so far needed only one filled action, and
   the modals are the first place with two — where making both a fill asks which
   one you meant, and making the second a text link makes it look optional when
   it is half the decision.

   So: an outline. Same treatment the submit's not-ready state already uses, for
   the same reason — fill versus outline is a difference of FORM, so it survives
   greyscale where two hues would not (WCAG 1.4.1).

   inset box-shadow rather than a border, so adding the outline does not change
   the button's size and a secondary sitting beside an accent lines up.

   Measured on every surface these land on — 1.4.11 wants 3:1 for the edge:

       light  on --surface-card Pebble 100    border 3.51  label 7.96
       light  on --surface-page Seafoam 200   border 3.74  label 8.48
       dark   on --surface-card Flint 600     border 4.39  label 10.63
       dark   on --surface-page Flint 500     border 3.51  label 8.48

   ONE FOR JUSTIN: this is not in the frames. It is invented because two-action
   modals need it, and it should be confirmed rather than assumed. */
.v2-button--secondary {
    background-color: transparent;
    color: var(--text-body);
    box-shadow: inset 0 0 0 1px var(--border-strong);
}

.v2-button--secondary:hover:not([disabled]) {
    background-color: var(--surface-hover);
}

/* Harbour. The dark-fill case, so the label is the light one. */
.v2-button--primary {
    background-color: var(--brand-primary);
    color: var(--text-on-brand);
}

.v2-button--primary:hover:not([disabled]) {
    background-color: var(--brand-primary-hover);
}


/* ── Text fields ─────────────────────────────────────────────────────────
   36px tall, 12px radius, 1px Pebble 200 border, 19px of left padding
   (1300:3394).

   The fill is WHITE IN BOTH THEMES — the dark frame (1300:3342) says bg-white
   with the same Pebble 200 border, not a Flint step. Faithful to the file, and
   it is defensible: an input that stays white reads unambiguously as editable
   and keeps its own contrast independent of the surface it sits on. Called out
   because it is the one place dark mode does NOT follow §2's swap. */
.v2-field {
    width: 100%;
    min-height: 36px;
    padding: var(--space-2) 19px;
    /* Pinned, not --border-subtle. That role is Flint 400 in dark, which would draw a
       dark ring round a white field; the dark frame (1300:4903) says Pebble 200, the
       same as light. Third of the same class Daniel caught on 14-Aug — an unswapped
       surface needs its border and its copy pinned as well as its fill. */
    border: 1px solid var(--colour-pebble-200);
    border-radius: var(--radius-button);
    background-color: #fff;
    color: var(--colour-flint-500);   /* the field is white in both themes, so
                                         its copy cannot follow --text-body */
    font-family: inherit;
    font-size: var(--type-body-size);
    font-weight: 500;
    /* base.css sets color-scheme: dark on [data-theme="dark"], which tells the OS
       to paint native control chrome dark. On a field that stays WHITE that is
       backwards — the caret and any UA affordance would be drawn light on white.
       Pinned to light for the same reason the fill is. */
    color-scheme: light;
    appearance: none;                 /* iOS rounds text inputs by default */
}

/* Inert but still drawn as designed: the white fill and border are author rules,
   so they already beat the UA's disabled styling. Only the cursor and the copy
   soften — the disabled Search button is what says "not available". */
.v2-field[disabled] {
    cursor: not-allowed;
    /* Pinned, same class as the rest. A disabled field keeps the white fill above, and
       --text-muted is Pebble 200 in dark — near-white copy on white, about 1.5:1 and
       effectively invisible. Flint 300 reads as disabled in both themes at 4.07:1, and
       WCAG exempts inactive controls from 1.4.3 anyway, so this is the safe end. */
    color: var(--colour-flint-300);
}

/* A LOCKED VALUE IS NOT AN EMPTY ONE, and it must not go pale to say so.
   -----------------------------------------------------------------------------
   Two goes at this. The first set `color: var(--text-body)` — which is Seafoam 200
   in dark, near-white, on a field whose fill is PINNED #fff in both themes. Text at
   about 1.1:1 (Daniel, 26-Aug: "the text is hard to read when locked and greyed out
   in dark mode"). The file's own note on .v2-field says the fill is pinned; a
   theme-varying colour on top of a pinned fill can only be wrong in one theme.

   So the state changes the FILL, not the copy. Seafoam 200 is the page tint, which
   reads as "not a live input" while the value stays at the same Flint 500 every
   other field uses — 8.48:1, versus 9.23:1 on white. Nothing about a locked field
   is harder to read than an editable one.

   -webkit-text-fill-color is not belt-and-braces: WebKit greys disabled control
   text through that property, and it BEATS `color`. Without it Safari renders this
   the pale grey the whole rule exists to avoid.

   And the chevron goes. A select that will not open should not draw the affordance
   that says it will. */
.v2-field.is-locked[disabled] {
    background-color: var(--colour-seafoam-200);
    color: var(--colour-flint-500);
    -webkit-text-fill-color: var(--colour-flint-500);
    opacity: 1;
    background-image: none;
    padding-right: var(--space-4);
}

/* THE NOTE SITS BESIDE THE CONTROL, not under it (Daniel, 26-Aug: "should be to the
   right so not to extend the container more"). Under it, an explanation that only
   appears sometimes grew the whole medical card by a line whenever a date of birth
   happened to be an exempting one.

   The cell claims its neighbour's column to do it, which costs nothing HERE and
   only here: the note appears when the exemption is locked to A, and A carries no
   certificate, so the expiry field that would have occupied that column is hidden
   anyway. A class from script rather than :has(), because the same --info note is
   used elsewhere on fields that have no free neighbour.

   Row 2 is the control: label-row, control, error bed, note. */
.v2-form-field--note-beside {
    grid-column: span 2;
    grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
}

/* .v2-combo IS IN THIS LIST, and leaving it out is what broke the layout.
   Daniel, 20-Sep, on the sign-up form: "when the automation runs for this and
   locks the field. it flexs across to the wrong location instead of under the
   title. its to the right?"

   The exemption control is a <select>, and select.js REPLACES it: the native
   element takes .is-enhanced and goes display:none, and the thing on screen is a
   .v2-combo div inserted beside it. That div carries .v2-field on its BUTTON,
   not on itself — so `> .v2-field` matched only the hidden select, the visible
   control was auto-placed, and the first free cell was row 1 column 2. Which is
   beside the label, with the note pushed under it.

   Every enhanced select in a note-beside cell had this; the exemption is simply
   the only one that locks, so it is the only one anybody saw. */
.v2-form-field--note-beside > .v2-label-row,
.v2-form-field--note-beside > .v2-field,
.v2-form-field--note-beside > .v2-combo,
.v2-form-field--note-beside > .v2-field-error {
    grid-column: 1;
}

.v2-form-field--note-beside > .v2-field-note {
    grid-column: 2;
    grid-row: 2;
    align-self: center;
    margin: 0;
    padding-left: var(--space-4);
}

/* PLACEHOLDERS ARE PINNED, both themes, at the frame's Flint 200.
   -----------------------------------------------------------------------------
   These were on --text-muted, which steps to Pebble 600 in light — and Daniel called
   it (14-Aug): too dark, and the spec doesn't change it between themes. He's right on
   both counts, and the first one is the more interesting: a placeholder darker than it
   should be stops reading as a placeholder and starts reading as an entered value,
   which on a form full of empty fields is actively misleading.

   The frames pin it — colour/flint/200 in the light field (1300:4904) and the same in
   the dark one (1300:4834). Same reasoning as the pill above and .v2-field itself: the
   field is white in both themes, so its copy cannot follow a role that swaps.

   MEASURED AT 2.3:1 ON WHITE, so noted rather than hidden. What makes it defensible
   here and not elsewhere: every field in this form has a real <label>, so the
   placeholder is never the only cue. It does still carry format hints — "DD/MM/YYYY",
   "123 456 7890" — which is the part that isn't purely decorative.

   IF A MIDDLE GROUND IS EVER WANTED: Flint 300 measures 4.07:1 on white, still
   obviously a placeholder rather than a value, and one token away. */
.v2-field::placeholder {
    color: var(--colour-flint-200);
    opacity: 1;                       /* Firefox dims placeholders by default */
}

/* FOCUS LIVES IN THE BORDER, NOT IN A RING AROUND IT. Daniel, 7-Sep: "the blue
   active border around the text box, can that be removed".

   It is NOT removed, because it cannot be: base.css says so at ~240 and it is
   right — an invisible focus indicator is a WCAG 2.4.7 failure. And this is not
   a stray mouse ring either. :focus-visible normally fires for keyboard focus
   only, but the spec makes TEXT FIELDS match it on any focus, since they always
   take input — so clicking into one has always shown it and always would.

   What can go is the halo. The border takes the focus colour and an inset
   shadow doubles its weight, so the field reads as focused without anything
   floating outside it. The border stays 1px, so nothing shifts.

   PINNED to harbour/500 rather than --border-focus, for the same reason this
   rule's own colour and colour-scheme are pinned: the field is WHITE in both
   themes, so its focus colour cannot follow a token that swaps. MEASURED
   against that white — harbour/500 is 5.09:1, where dark's --border-focus
   (harbour/300) is 2.34:1 and would fail the 3:1 a focus indicator needs
   (WCAG 1.4.11). The old outline got away with harbour/300 because it sat
   OUTSIDE the field, against the dark card.

   THIS APPLIES TO EVERY v2 FIELD, not just the cancellation reason box — one
   control should not have two focus treatments. */
.v2-field:focus-visible {
    outline: none;
    border-color: var(--colour-harbour-500);
    box-shadow: inset 0 0 0 1px var(--colour-harbour-500);
}


/* ── Selects ─────────────────────────────────────────────────────────────
   The frames draw selects identically to text fields — "Select Title" in the
   Salutation box (1300:4895) is a placeholder in the same 395×36 white field. So
   this inherits .v2-field and only adds the chevron.

   appearance: none removes the OS control, then the arrow is a background SVG
   rather than a pseudo-element, because <select> can't have children in every
   engine. Data URI so it needs no request and no file to keep in step with the
   token; it is stroked with the label colour by hand — currentColor doesn't reach
   a background image, which is the one real cost of this approach. */
.v2-select {
    appearance: none;
    -webkit-appearance: none;
    padding-right: 44px;                    /* room for the chevron */

    /* Ellipsise a selected value too long for the box rather than hard-clipping it.
       This matters now that Status shares a row with Expiry (Daniel, 14-Aug): the
       exemption options are full sentences, so a chosen one WILL overflow a
       half-width select. The dropdown still shows every option in full, so nothing
       is lost — but an ellipsis reads as "there is more" where a mid-word cut reads
       as broken. */
    text-overflow: ellipsis;
    background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%230A5E86' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M6 9l6 6 6-6'/%3E%3C/svg%3E");
    background-repeat: no-repeat;
    background-position: right 16px center;
    background-size: 16px 16px;
    cursor: pointer;
}

/* An unchosen select shows its placeholder option, which should read as a placeholder
   rather than as an answer — so it takes the same pinned Flint 200 as a text field's
   placeholder, for the same reason. */
/* Muted ONLY while the empty option is the one selected.
   `:invalid` covers a REQUIRED select with no value. It cannot cover an optional
   one — Salutation is optional, so it never matched, and a static
   data-placeholder="true" was standing in for it. Static meant permanently
   faded: the control stayed muted after choosing a value, and because native
   <option>s inherit the select's colour, every row in the open list looked
   disabled too (Daniel, 25-Aug).

   :has(option[value=""]:checked) is the same intent expressed as state rather
   than as a fixed attribute, and it works whether the field is required or not.
   It cannot drift, because there is nothing to keep in step. */
.v2-select:invalid,
.v2-select:has(option[value=""]:checked) { color: var(--colour-flint-200); }

/* THE LIST IS NOT THE CONTROL, and it is not ours to theme.
   Options inherit the select's colour, so the muted state above followed them
   into the popup and made every choice look unavailable. But the popup is
   painted by the PLATFORM, on the platform's own background — white on Windows
   regardless of our theme. Feeding it --text-body fixed light mode and made dark
   mode worse: pale text on a white list.

   CSS system colours are the correct answer because they are resolved by the
   same thing that paints the popup. CanvasText on Canvas is legible in any
   theme, on any OS, without us guessing what the popup looks like.

   This is exactly the flaw the bespoke dropdown removes — a native popup we do
   not draw and therefore cannot theme. Until then, legible beats on-brand. */
.v2-select option {
    color: CanvasText;
    background-color: Canvas;
}
/* Still "not a choice", said in the platform's own idiom. */
.v2-select option[value=""] { color: GrayText; }


/* ── Checkboxes ──────────────────────────────────────────────────────────
   The classic form has two that matter — "NHS number unknown" and the consent
   tick — and the frames don't draw either, so this is the design language applied
   by extension rather than a measured component. Flagged as such.

   The box is 20px to stay on §6's ramp, the tick is drawn in CSS rather than
   shipped as an asset, and the whole control is a real <input type="checkbox">
   with the visual on a sibling so it keeps its semantics and its focus ring. */
.v2-check {
    display: grid;
    grid-template-columns: auto 1fr;
    gap: var(--space-3);
    align-items: start;
    cursor: pointer;
}

.v2-check input {
    appearance: none;
    -webkit-appearance: none;
    width: 20px;
    height: 20px;
    margin: 0;
    /* Pinned. The box is white in both themes, so its edge can't follow
       --border-strong: that role is Pebble 400 in dark, which measures 2.63:1 on white
       and so fails 1.4.11's 3:1 for a control boundary. Flint 300 is the role's own
       light-mode value and holds 4.07:1 both ways. */
    border: 1px solid var(--colour-flint-300);
    border-radius: var(--radius-button-sm);   /* 8px */
    background-color: #fff;
    cursor: pointer;
    transition: background-color 120ms ease, border-color 120ms ease;
}

.v2-check input:checked {
    border-color: transparent;
    background-color: var(--intent-success);
    /* The tick, drawn rather than shipped. Flint 700 to match --text-on-accent,
       since Seafoam on Meadow is the 2.1:1 case. */
    background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23232C30' stroke-width='3' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M20 6L9 17l-5-5'/%3E%3C/svg%3E");
    background-repeat: no-repeat;
    background-position: center;
    background-size: 14px 14px;
}

.v2-check input:focus-visible {
    outline: 2px solid var(--border-focus);
    outline-offset: 2px;
}

.v2-check-label {
    font-size: var(--type-body-size);
    font-weight: 500;
    line-height: 1.4;
    color: var(--text-body);
}


/* ── Layout: desktop ─────────────────────────────────────────────────────
   Dashboard_Light 808:5045 at 1440×1024. The greeting sits upper-left, the
   dock is centred in the lower half, pills under it, powered-by at the foot. */
/* One centred column of --shell-max, shared by all three rows. This is what
   aligns the greeting's left edge and the status bar's right edge with the
   dock, exactly as the Figma frame does — rather than each row carrying its own
   gutter and drifting apart on a wide monitor. */
/* width: min() rather than max-width + padding. With padding the content box
   became 1201 MINUS two gutters, so the status bar right-aligned to ~1105 while
   the dock kept its natural 1201 and overflowed — which is exactly why their
   right edges didn't line up. This way the content box IS --shell-max on a wide
   screen (the dock fits it precisely, by definition, since the dock defines it)
   and shrinks by the gutter only when the viewport is narrower. */
.v2-dash-top,
.v2-greeting,
.v2-dash-nav {
    width: min(100% - (2 * var(--shell-gutter)), var(--shell-max));
    margin-inline: auto;
}

.v2-dash-top {
    display: flex;
    justify-content: flex-end;
    align-items: flex-start;    /* stops the bar stretching to the row height */

    /* 120px above the bar, held proportionally.
       -------------------------------------------------------------------------
       Dashboard_Dark 808:3988 puts StatusBar (808:4592) at y=120 in a 1024-tall
       frame. This was var(--space-6) — 24px — which was never measured; I picked
       it. Corrected 2026-08-12 after the same misreading was found on mobile.

       IT IS NOT JUST WHITESPACE. The frame puts the bar at y=120–165 and the
       section panel at y=265, a 100px gap that .v2-dash-mid already carries
       correctly RELATIVE to the bar. With the bar at 24px the panel was landing
       at ~169 instead of 265, so every desktop panel sat ~96px too high — the
       same propagated error, and fixing this one number fixes both.

       And the reason to trust the spec here rather than trim it: the status bar
       is a floating glass pill. Glass only reads as glass with room around it;
       against the top edge it reads as a fixed toolbar, which is a different
       material. The wallpaper is the point of this design, so spending some of
       it is deliberate.

       WHERE THIS DEVIATES is the unit, not the value. A flat 120px is a fixed
       cost out of a variable budget — on a ~700px laptop viewport it is 17% of
       the height, and with the dock, pills and footer all bottom-anchored that
       is what tips the home view into scrolling. 11.7vh is the frame's own
       120/1024, so the design height gives exactly 120: ~105px at 1080p, ~82px
       on a small laptop, capped at 120 so a tall monitor doesn't keep stretching
       it.

       Knock-on, intended: desktop panels lose height, which they absorb by
       scrolling internally rather than growing the page.

       TUNED DOWN FROM THE FRAME, 2026-08-12. 11.7vh put the bar at the frame's
       exact proportion and Daniel judged it too low on a real monitor — the
       frame's 1024px canvas is shorter than most desktop viewports, so the
       proportion that reads as balanced there reads as sunken at 900px+. 9vh,
       capped at 96px, is 80% of the frame value: still unmistakably floating,
       about 20px higher at 1080p. His call, and the kind the Guidelines leave
       open — dev mode is the source of truth for the value, not for whether the
       value suits a viewport the canvas never depicted. */
    padding-top: clamp(var(--space-6), 9vh, 96px);
}

/* Two rows: the greeting's area, then the nav pinned to the bottom.
   space-between put the greeting hard against the status bar and let the nav
   drift down. In the frame the greeting sits centred in the gap between the
   status bar and the dock (its centre is at ~36% of the height, and that gap's
   centre is ~37%), so centring it in row 1 reproduces that at any height —
   which also lifts the dock slightly, since row 2 is now sized by its content
   rather than by whatever space-between left over. */
.v2-dash-mid {
    display: grid;
    grid-template-rows: 1fr auto;
    row-gap: var(--space-6);
    /* No bottom padding: the gap to the footer is owned entirely by
       .v2-poweredby's padding-top, so it is one number rather than the sum of
       two — which is how it drifted out of step with the dock-to-pills gap. */
    padding-bottom: 0;
}

/* The gap from the status bar to the greeting is now EXPLICIT, not emergent.
   -----------------------------------------------------------------------------
   This was align-self: center, which reproduced the frame — the greeting's centre
   sits at ~36% of 1024 and the bar-to-dock gap's centre at ~37%, so centring in
   row 1 matched it at the design height.

   The flaw only shows on a tall viewport: centring means the bar-to-greeting gap
   GROWS with the window, because row 1 absorbs all the slack. So the taller the
   monitor, the further the greeting drifts from the bar — and Daniel read that as
   too much space (2026-08-12). Lowering the bar's own padding can't fix it;
   centring would just re-split the larger row.

   So the greeting start-aligns to a gap taken from the frame: bar bottom y=165 to
   greeting top y=229 is 64px, which is 6.25vh of 1024. Held proportionally with
   that as the ceiling, so it stops growing instead of tracking the viewport.

   The slack now pools BELOW the greeting rather than around it, which is where
   the design wants it anyway — row 1 is 1fr and the dock in row 2 stays in the
   lower half. Matches how mobile already does it. */
.v2-greeting {
    align-self: start;
    margin-top: clamp(var(--space-6), 6.25vh, 64px);
}

.v2-dash-nav {
    display: grid;
    justify-items: center;
    gap: var(--nav-stack-gap);
}


/* ── Layout: mobile AND tablet ───────────────────────────────────────────
   Mobile_Home_Dock_Light 849:1296 at 402×883. This is a reorganisation, not a
   reflow: the dock carries FOUR tiles with no labels, and Resources moves down
   into the pill row beside Lead Times and Updates. The dock also drops to the
   bottom of the screen, thumb-height.

   THE BREAKPOINT IS 1024px, not a phone width. Tablets take the mobile layout
   deliberately: the desktop dock needs ~1320px to render at its designed size,
   and a tablet is a touch device, so the 44px targets and thumb-reach dock are
   the right call there too. 1024 covers iPad portrait and landscape up to the
   10.9". Above it, the dock scales fluidly rather than switching.

   .v2-dock-tile--secondary marks the tile that leaves the dock; and
   .v2-pill--dock-overflow the pill that replaces it. Two modifier classes keep
   one set of markup for both layouts — no duplicated nav, so no chance of the
   two drifting apart. */
@media (max-width: 1024px) {
    /* THE 70px ABOVE THE BAR IS A TOTAL, NOT AN ADDITION.
       -------------------------------------------------------------------------
       The mobile frames put the button row at y=70 (Frame 2/Frame 3 in
       RegisterPatient/Mobile 1252:1749 and LeadTimes/Mobile 1300:3402).

       This was 20px + env(safe-area-inset-top), on the reading that the frame's
       70px band was the device status bar and a browser viewport already sits
       below it. Daniel called that wrong, and the arithmetic agrees with him:
       20 + a 47px iPhone inset is 67px — almost exactly the frame's 70. So the
       frame is showing status bar PLUS about 20px of padding, which makes 70 the
       total distance from the top of the surface to the bar, and the old rule
       right only in standalone where an inset exists to make up the difference.
       In a browser tab it was 50px short, which is what he could see.

       So env() becomes a FLOOR via max(), not an addend: 70px in a tab, still
       clear of the hardware in standalone (20 + 47 = 67 < 70, so 70 wins), and
       it grows if a device ever has a deeper inset than that.

       Held as 8vh — the frame's own 70/883 — so a short landscape viewport isn't
       spending a fifth of its height on empty space. That means ~62px at a
       typical 770px-tall phone and the full 70px from ~875px up. If the flat 70
       is wanted regardless of height, it's the middle value of the clamp. */
    .v2-dash-top {
        padding-top: max(
            clamp(32px, 8vh, 70px),
            calc(var(--space-5) + env(safe-area-inset-top, 0px))
        );
    }

    /* Greeting to the TOP, dock to the BOTTOM — the mobile frames put the dock
       at thumb height with the wallpaper open in between, which is the opposite
       arrangement to the desktop's centred greeting. Hence `auto 1fr` here
       against the desktop's `1fr auto`. */
    .v2-dash-mid { grid-template-rows: auto 1fr; }
    .v2-dash-nav { align-self: end; }

    /* The greeting sits noticeably below the top row rather than tucked under
       it — in the mobile frame its first line starts at ~20% of the height. A
       viewport-relative offset holds that proportion on a tall phone and a
       short one, and the clamp stops it collapsing or running away. */
    .v2-greeting {
        align-self: start;
        margin-top: clamp(20px, 9vh, 110px);
    }

    /* MOBILE USES ITS OWN TILE ART, so every desktop glyph rule is undone here.
       -------------------------------------------------------------------------
       The library has a separate "Mobile Dock" section (815:894): four 70×70
       tiles, GlassMobileIcons 815:1198, each one a complete tile — gradient,
       glyph and shadow in a single 21px-radius export. Crucially they carry NO
       (i) badge, where the desktop plate art bakes one into two of the tiles.
       That badge isn't tappable on a phone, which is what Daniel reported; the
       fix is to use the right asset rather than mask the wrong one.

       <picture> in the markup swaps the source at this same 1024 breakpoint, so
       only one file is ever downloaded.

       This block used to carry overrides — undoing the centred-glyph widths,
       offsets and scale that desktop needed for three of its tiles, and undoing
       the light-mode opacity wash because the mobile art already drew its own
       glyph translucency. NOTHING IS LEFT TO OVERRIDE: the redraw made every
       desktop tile a full plate, and the wash is gone from both breakpoints
       because the translucency is in the SVG on both. Desktop and mobile now
       differ only in which file <picture> fetches, which is the whole idea. */

    .v2-dash-nav {
        gap: var(--space-3);
        padding-bottom: var(--space-5);
    }

    /* MobileDock 815:1010 is 364×94 with 70px tiles, which pins every number:
           364 = 2×12 padding + 4×70 tiles + 3×20 gaps
            94 = 2×12 padding + 70
       So the gap is 20px, not the 12px this used to carry.

       THE TILES ALWAYS FILL THE DOCK — no slack, at any width. Daniel caught the
       old rule at a zoomed viewport: `justify-content: stretch` is not a valid
       value for a flex container, so it behaved as flex-start, and a 168px cap on
       the tiles meant every leftover pixel piled up as an empty gap on the right
       of the panel. The cap was mine, not the design's.

       Capping the DOCK instead of the tiles fixes both ends. The tiles keep
       flex: 1 1 0 so they divide whatever the panel gives them exactly, and
       max-width stops the group getting silly on a wide-but-zoomed viewport:

           402px phone     dock 362 → tiles (362−24−60)/4 = 69.5 ≈ 70  ✓ the frame
           ~967px zoomed   dock 560 centred → tiles 119, still no slack

       560px is chosen so a phone lands on the designed 70px exactly. */
    .v2-dock {
        gap: var(--space-5);               /* 20px · 815:1010 */
        padding: var(--space-3);           /* 12px */
        border-radius: var(--radius-panel);
        width: 100%;
        max-width: 560px;
        margin-inline: auto;
        justify-content: center;
    }

    .v2-dock-tile {
        width: auto;
        flex: 1 1 0;
        min-width: 0;
        gap: 0;
    }

    .v2-dock-tile--secondary { display: none; }

    /* 70×70 at a 21px radius (815:1178), expressed as a PERCENTAGE because the
       radius has to scale with the tile. The export draws its own rounded rect
       and clips to it, and an SVG's corners scale with the image — so a fixed
       21px here would clip tighter than the art as soon as the tile grew past
       70px, shaving its corners. 30% is 21/70, so container and art agree at
       every size. */
    .v2-dock-plate { aspect-ratio: 1; border-radius: 30%; }

    .v2-dock-label { display: none; }

    /* Labels are hidden here, so the accessible name has to come from
       somewhere — the markup carries aria-label on every tile for this reason.
       Don't "simplify" that away. */
}

@media (min-width: 1025px) {
    .v2-pill--dock-overflow { display: none; }
}


/* ── Pills on a tablet: ONE ROW, the dock's width ────────────────────────
   Daniel, 30-Sep: "on tablet when that 4th pill appears, it should fit in same
   row. not cause a flex/wrap". On a tablet Resources joins the row (the dock
   loses its tile), and four pills at the desktop's 185px minimum need 740px,
   which a tablet's 560px dock row does not have, so the fourth wrapped.

   So below 1025 the row is the dock's own width (max 560, like .v2-dock) and
   never wraps; the pills drop the 185 floor, size to their content and share
   the spare width, so the row lines up with the dock edge to edge. The labels
   are the short ones the phone frames use ("Queries", "Updates"): the long
   four measure ~550 of 560, which is too close to trust at 15px across
   devices, and the short four ~470. 45px tall still, a real touch target.
   The phone block below then trims them further. */
@media (max-width: 1024px) {
    /* EQUAL PILLS (Daniel, 13-Oct: "could the 4 pills not be uniform").
       Equal grid columns, not flex: flex never squeezes a pill below its own
       padding, and a pill with a badge has less than one without, so they
       came out a few pixels apart. A column is the same width whatever is in
       it, and a pill that isn't shown (Resources above 1024, Queries with
       the module off) takes no column.

       THE TEXT IS SIZED FROM THE ROW, so the longest label and its icon
       ("Lead Times" and the clock, 62.4px at 12px, the widest of the four:
       Resources 56.8, Updates 44.5, Queries 41.5) always fit their quarter.
       The row is the size container: 25cqi is a quarter of it. All four
       take the same size. */
    .v2-pill-row {
        display: grid;
        grid-auto-flow: column;
        grid-auto-columns: minmax(0, 1fr);
        width: 100%;
        max-width: 560px;
        margin-inline: auto;
        container-type: inline-size;
    }

    .v2-pill {
        min-width: 0;
        /* A quarter of the row less 3 × 8px gaps, less 14 + 8 + 33 + 6 of
           padding, gap and icon, is what the label has; Lead Times is 5.2
           of its own font size wide (5.3 for a margin). */
        font-size: 13px;   /* a browser without container units (pre-2022) */
        font-size: min(var(--type-nav2-size), calc((25cqi - 67px) / 5.3));
    }

    .v2-pill > span:not(.v2-pill-badge):not(.v2-visually-hidden) {
        overflow: hidden;
        text-overflow: ellipsis;   /* only below ~330px, at the floor */
    }

    .v2-pill-label-long  { display: none; }
    .v2-pill-label-short { display: inline; }
}

/* ── Layout: phone only ──────────────────────────────────────────────────
   Below the tablet tier the shell loses chrome rather than shrinking it, which
   is what the mobile frames actually show:

     - the status bar is replaced by the "…" button; its contents live in the
       panel that button opens
     - "Powered by" is gone
     - the pill row is one compact horizontal line — "Resources · Lead Times ·
       Updates" — not three full-width stacked buttons
     - the Updates badge becomes a plain dot; a count is detail a phone doesn't
       need at a glance

   A tablet keeps the status bar and the full-size pills: there is room for the
   404px bar and three 185px pills at 768px+, and removing them there would be
   losing information for no reason. Only the dock arrangement is shared with
   the phone. */
@media (max-width: 640px) {
    .v2-statusbar { display: none; }
    .v2-mobile-menu { display: grid; }
    .v2-poweredby { display: none; }

    /* Greeting lower again on a phone: the frame puts it at ~20% of 883px,
       which is a bigger share than the tablet needs.
       (The tighter dock→pills gap is already set in the tablet block above —
       there's no footer below the pills here to balance against.) */
    .v2-greeting { margin-top: clamp(24px, 12vh, 120px); }

    /* FOUR PILLS ON A PHONE, THE DOCK'S WIDTH, SCALED TO FIT (Daniel, 13-Oct).
       Resources was hidden here on 30-Sep ("on mobile we can hide the
       resources button"), when it had no phone design; now it has one and
       this pill is the way in. His shape for it: "if they stayed the width of
       the icon panel above it and shrunk in scale in line then should be
       fine". So the row keeps the tablet's geometry (one line, the dock's
       width, the spare shared out: the tablet block above), and what is
       inside each pill scales with the screen instead of the row wrapping.

       Measured at 12px the four need ~364px, more than a phone's dock. So the
       label, icon and padding scale with the viewport: full size from ~414px
       wide, ~0.9 at 375 (the four come to ~310), with floors that still fit
       at 320 (~275). One line, so nothing wraps and nothing clips; the row is
       not a scroller, so the drop shadows are whole (the "blocky drop
       shadow", 30-Sep). */
    .v2-pill-row { gap: 6px; }

    .v2-pill {
        min-width: 0;
        min-height: 32px;
        gap: 3px;
        --v2-pill-pad-l: 6px;
        /* THE ICON FILLS THE CURVE WHERE THERE IS ROOM (Daniel, 13-Oct: "on
           mobile can we make the pill icons a little bigger. tighter to the
           surround curve? (not making the pill bigger)"). The pill stays
           32px; the icon grows into it, 16px on a 390 phone (where the
           label has nothing to spare) to 26px from ~460, and the inset
           round it is whatever keeps it concentric with the end: (32 - icon)
           / 2, 3px at full size. Right never more than the 6px it was. */
        --v2-pill-icon: 16px;   /* without container units */
        --v2-pill-icon: clamp(16px, calc(25cqi - 80px), 26px);
        --v2-pill-inset: calc((32px - var(--v2-pill-icon)) / 2);
        padding: var(--v2-pill-inset) min(6px, var(--v2-pill-inset)) var(--v2-pill-inset) var(--v2-pill-pad-l);
        /* As above with the phone's numbers: a quarter of the row less
           3 × 6px gaps, less 6 + 3 + the icon + its inset. ~10px on a 375
           phone, 12px from ~420; 8.5px is the floor, where Lead Times
           ellipses. */
        font-size: clamp(8.5px, 2.6vw, var(--type-caption-size));   /* the same, without */
        font-size: clamp(8.5px, calc((25cqi - 13.5px - var(--v2-pill-icon) - min(6px, var(--v2-pill-inset))) / 5.3), var(--type-caption-size));
        white-space: nowrap;
    }

    .v2-pill-label-long { display: none; }
    .v2-pill-label-short { display: inline; }

    .v2-pill-icon { width: var(--v2-pill-icon, 16px); height: var(--v2-pill-icon, 16px); }

    /* Badge → dot. The number is hidden rather than removed from the markup, so
       the visually-hidden count next to it still announces "n unread updates". */
    .v2-pill-badge {
        min-width: 10px;
        min-height: 10px;
        padding-inline: 0;
        font-size: 0;                  /* hides the digits, keeps the element */
        line-height: 0;
    }
}

/* THE NARROWEST PHONES (under 360, the first iPhone SE's 320): a quarter of
   the row cannot hold "Lead Times" and its clock at a size worth reading, so
   the two icons go and the labels take the whole pill (~10.5px at 320) rather
   than ending in an ellipsis. The badges stay: they are dots here already. */
@media (max-width: 359px) {
    .v2-pill-icon { display: none; }
    .v2-pill {
        font-size: clamp(8.5px, 3.2vw, var(--type-caption-size));
        font-size: clamp(8.5px, calc((25cqi - 16.5px) / 5.3), var(--type-caption-size));
    }
}


/* ── Motion ──────────────────────────────────────────────────────────────
   base.css already neutralises transitions under prefers-reduced-motion. The
   dock's translateY is the one thing worth also disabling outright, since a
   lifting tile is motion for its own sake rather than feedback. */
@media (prefers-reduced-motion: reduce) {
    .v2-dock-tile:hover { transform: none; }
}


/* ══════════════════════════════════════════════════════════════════════════
   MY PATIENTS — search row, view toggle, patient cards
   Figma: MyPatientCards/Desktop/Light 912:4919, /Dark 915:5214,
          MyPatientsCard 900:228, ViewSlider 918:5439.

   EVERYTHING HERE IS THE FRAME'S OWN VALUE. Nothing is stepped, inferred or
   substituted — Daniel's standing rule (17-Aug): "no more inferring ever. figma is
   rule or i am". Where a frame value fails a contrast check the ratio is recorded
   beside it and the value is still the frame's; that trade is his call, not mine.

   THE HISTORY IS KEPT BECAUSE IT COST THREE ROUNDS, all the same mistake — I
   substituted my judgement for a value that was sitting in the file. Daniel caught
   every one by eye:

     "the blue is too dark compared to spec"   I used --text-label (Harbour) where
                                              the frames say Seafoam 700/600, and
                                              say it in BOTH themes
     "shaded too dark compared to spec"        I inferred the dark cells were Flint
                                              700, a step DARKER than the card, from
                                              light mode's Pebble 100→200. The dark
                                              frame (915:5231) says Flint 500 —
                                              LIGHTER. The direction inverts, and it
                                              was never guessable
     "font appears too big"                   I read the fractional sizes as a scale
                                              artifact (they are: 13.049/10.874/8.699
                                              all divide by 0.8699, so the component
                                              was drawn at 310 and scaled to 270) and
                                              "corrected" them onto the --type-*
                                              scale. But the card really is 270 wide
                                              here, so the frame's rendered size IS
                                              the right size, artifact or not

   The sizes are therefore the frame's literal values, fractions included.

   WHAT THAT COSTS, measured and recorded rather than corrected:
     DOB      seafoam 600 on Pebble 100   1.82:1  ·  on Flint 600   4.55:1
     labels   seafoam 700 on Pebble 200   2.29:1  ·  on Flint 500   2.95:1
     Order    seafoam on Harbour Light    2.68:1
     toggle   Pebble 400 thumb on its own Pebble 100 track   2.27:1
   Four of those are under their threshold. They are the frame's values and the
   trade is Daniel's or Justin's to make — this comment exists so the numbers are
   never lost, not to argue with them.
   ══════════════════════════════════════════════════════════════════════════ */

/* ── Search row (Section/FormField 912:4930) ─────────────────────────────
   A card holding the field and its button. Field flexes, button holds the
   frame's 140px: 662 + 8 + 140 = 810 inside 850 less 20px padding. */
.v2-search {
    display: flex;
    gap: var(--space-2);                  /* 8px */
    align-items: center;
    /* 10px, not the frame's 20. Daniel, 18-Aug: "i just want to maximise the space of
       the panel for the actual reslts."
       This row was 76px of panel to hold a 36px control — a card with 20px on all four
       sides and nothing else in it. Halving the padding gives 20px back to the results
       and the field is still clearly inside its own surface. The cheapest 20px on the
       panel, and it needed no change to how scrolling works. */
    padding: 10px;
    border-radius: var(--radius-card);    /* 20px */
    background-color: var(--surface-card);
}

/* BOTH ARE EXACTLY 36px. [frame] the Field is 36 (912:4932) and the Button is 36
   (912:4934) — they are the same height in the file and were not on screen: Daniel,
   18-Aug, "the search button is smaller than the search box in height".
   min-height alone was not enough. input[type=search] carries its own UA metrics,
   so the field grew past 36 while the button held it. An explicit height on both
   settles it regardless of what any UA thinks a search box should be. */
.v2-search .v2-field {
    flex: 1 1 auto;
    min-width: 0;
    height: 36px;
}

.v2-search .v2-button {
    flex: 0 0 auto;
    min-width: 140px;
    height: 36px;
    justify-content: center;
}

/* ── My Orders' head aside ───────────────────────────────────────────────
   1781:1530's head row. It holds the three stat tiles; the view toggle it also
   renders is pinned to the panel's top-right and so is out of this flow — see
   the note on .v2-panel-head--control .v2-view-toggle for why that is what got
   the two panels' headers to the same height.

   Kept as a wrapper rather than dropped now that it has one child: it is what
   right-aligns the tiles and gives the trailing slot something with a width,
   and the toggle still renders inside it in the markup, where it belongs
   semantically. align-items:end on the head bottom-aligns it with the title
   block. */
.v2-panel-head-aside {
    display: flex;
    flex-direction: column;
    align-items: flex-end;
    min-width: 0;
}

/* ── Stat tiles (Stats/Light 1782:12848, Stats/Dark 1782:13028) ──────────
   THE TWO THEMES WERE READ SEPARATELY, and it mattered: every colour differs,
   the accents are one scale step LIGHTER in dark, and the caption INVERTS
   (Flint on the light tile, Seafoam on the dark). Deriving either from the other
   would have got the caption exactly backwards — which is the mistake the
   standing rule exists to prevent.

   Two of the three values turn out to be roles we already have:
     tile fill  pebble 100 / flint 600  =  --surface-card, exactly
     caption    flint 500  / seafoam 200 =  --text-body, exactly
   so only the three accents need new tokens, defined per theme below. */
.v2-stats {
    display: flex;
    gap: var(--space-2);                  /* 8px */
    align-items: center;
    margin: 0;
    padding: 0;
    text-align: center;
}

/* SHALLOWER THAN THE FRAME'S 49px, because the frame's third line is gone.
   Daniel, 2-Sep: "the status stats can be slightly less deep. remove the all
   clinics text as its redundant itll either be null or all so its irrelevant."
   Right on both counts — see the note in _order-head-aside.php — and the two go
   together: dropping the caption is most of the depth, and the gap comes down
   with it so the figure and its label read as one pair rather than two lines
   that happen to be near each other. */
.v2-stat {
    display: flex;
    flex-direction: column;
    align-items: center;
    justify-content: center;
    gap: 2px;
    /* 88px in the frame. min-width, not width: "Completed today" is the widest
       label and 1,400 the widest figure, and a hard 88 clips whichever of them
       grows when the nurse turns the AA font-size control up. */
    min-width: 88px;
    min-height: 40px;                     /* [frame] 49, less the caption line */
    padding: 5px var(--space-2);
    border-radius: var(--radius-button);  /* 12px */
    background-color: var(--surface-card);
    white-space: nowrap;
}

.v2-stat-figure {
    font-size: 20px;                      /* [frame] 1782:12850 */
    font-weight: var(--type-h1-weight);   /* Bold */
    line-height: 1;
    color: var(--v2-stat-accent);
}

.v2-stat-label {
    font-size: 10px;                      /* [frame] 1782:12851 */
    font-weight: 500;
    line-height: 1;
    /* The label takes the figure's colour, not a neutral — that pairing is what
       makes each tile read as one thing rather than a number with a caption. */
    color: var(--v2-stat-accent);
}

/* THE CAPTION LINE IS GONE, and with it the 6px type question.
   -----------------------------------------------------------------------------
   The frame's third line (1782:12851) held "ALL CLINICS" / "AWAITING REPLY" at
   6px — roughly half the smallest size anywhere else in this system, which I had
   shipped as drawn and flagged for Justin. Daniel settled it instead by
   removing the content: the scope caption cannot tell you anything, because the
   scope is either every clinic or the only clinic you have.

   So there is no .v2-stat-note rule any more. The qualifier that DID carry
   meaning — that the queries figure counts only those awaiting a reply — is kept
   as visually-hidden text on that tile, so a screen reader still gets the whole
   sentence while the tile stays two lines like its neighbours.

   Worth knowing if the caption ever comes back: it was --text-body deliberately,
   which is the one role that resolves to Flint on the light tile and Seafoam on
   the dark, matching the frames' inversion exactly. */

/* The three accents. Light values from 1782:12848, dark from 1782:13028 — one
   scale step apart in every case, which is the pattern §1 uses throughout for
   a mid-tone that has to hold up on both grounds. */
:root {
    --stat-open:      var(--colour-rockpool-400);      /* [frame] #6A90B3 */
    --stat-completed: var(--colour-meadow-400);        /* [frame] #84C88A */
    --stat-queries:   var(--colour-golden-hour-400);   /* [frame] #F7A25E */
}

[data-theme="dark"] {
    --stat-open:      var(--colour-rockpool-300);      /* [frame] #7EA7C9 */
    --stat-completed: var(--colour-meadow-300);        /* [frame] #97D69E */
    --stat-queries:   var(--colour-golden-hour-300);   /* [frame] #F9AE6C */
}

.v2-stat[data-stat="open"]      { --v2-stat-accent: var(--stat-open); }
.v2-stat[data-stat="completed"] { --v2-stat-accent: var(--stat-completed); }
.v2-stat[data-stat="queries"]   { --v2-stat-accent: var(--stat-queries); }

/* ── The head goes to one column, and the stats span it ──────────────────
   I HAD THIS WRONG AND HID THEM. The reasoning was that MyOrders/Mobile
   1362:8050 has no stats, so removing them was "the frame's own answer" — but
   that frame is one of several mobile variants and Daniel's own reference shows
   the tiles present on a phone, three across the full width above the search
   (2-Sep: "mobile/tablet isnt quite right either if you see"). An absence in one
   frame is not a decision; that was an inference dressed up as a reading.

   So they stay, and the head stacks instead of trying to seat them beside a
   title in 370px. Three things have to move together for that:

     - the head's auto-flow goes back to ROW, or its two-column template keeps
       the aside in a column beside the title
     - the aside stops right-aligning and stretches, so the tiles can share the
       full width
     - [data-v2-head-below] gives up its explicit grid-row: 2, which was placing
       the filter card in the row the aside now occupies — they would collide
       and the filters would land on top of the tiles

   The view toggle needs nothing: it is already display:none below 1024px, which
   is the frames' own call and why pinning it did not affect this.

   WHERE THESE RULES LIVE IS LOAD-BEARING, so it is worth stating: the head's own
   grid — .v2-panel-head--control and [data-v2-head-below] — is declared in
   overlays.css, which the layout loads AFTER this file. A media query adds no
   specificity, so overriding those from here would lose to them no matter what
   order they sit in. They are in overlays.css's own mobile block instead. Only
   the things this file owns are below, and they come after the rules they
   override for the same reason. */
@media (max-width: 900px) {
    .v2-panel-head-aside { align-items: stretch; }

    /* Equal thirds. flex-basis 0 rather than auto so "Completed today" doesn't
       claim more width than its neighbours just for being the longest label. */
    .v2-stat {
        flex: 1 1 0;
        min-width: 0;
    }

    /* Long labels ellipse rather than forcing the tile wider than a third. */
    .v2-stat-label {
        overflow: hidden;
        text-overflow: ellipsis;
    }
}


/* ── My Orders' filter card (Section/FormField 1362:7754) ────────────────
   The same card as My Patients' search row, with a second row under it: the
   Status and Date dropdowns at the start, the three view buttons at the end.

   IT IS THE CARD, so the .v2-search inside it must stop being one — otherwise
   the row's own surface and padding nest inside this one and the search field
   sits in a plate within a plate. Reset below rather than by adding a modifier
   to .v2-search, because the thing that changes is this container's business,
   not the search row's. */
.v2-order-filters {
    display: flex;
    flex-direction: column;
    /* 12px, where the frame (1362:7754) says 30.
       Following Daniel's own ruling on the sibling card, 18-Aug — "i just want
       to maximise the space of the panel for the actual reslts" — which is why
       .v2-search already carries 10px of padding against the frame's 20. Two
       36px rows with 30px between them is 132px of head before a single order
       is on screen. Recorded as a deviation rather than taken silently: say the
       word and it goes back to 30. */
    gap: var(--space-3);
    padding: 10px;
    border-radius: var(--radius-card);    /* 20px */
    background-color: var(--surface-card);
}

.v2-order-filters .v2-search {
    padding: 0;
    border-radius: 0;
    background: none;
}

/* 1362:7759 — a space-between row. The dropdowns group at the start, the view
   buttons at the end. Wraps, because at a narrow panel width five controls on
   one line would either overflow or crush the selects below readability. */
/* ── The clinic filter, inside the search row ────────────────────────────
   Daniel's placement (2-Sep): "use the search bar space, add the clinic filter
   between search bar and search button." Only rendered for a nurse with more
   than one clinic.

   200px, not the 221 of the row below: this one shares its line with a field
   that has a long placeholder to show and a 140px button, and it is a shorter
   list of shorter strings. The search field keeps whatever is left, which at the
   frame's width is still around 470px.

   The specificity matters — .v2-search .v2-field is `flex: 1 1 auto`, so without
   a three-class selector the clinic select would grow like the search box it sits
   next to. */
.v2-search .v2-clinic-filter {
    flex: 0 0 auto;
    width: 200px;
}

.v2-search .v2-clinic-filter .v2-field {
    flex: 0 0 auto;
    width: 100%;
    height: 36px;
}

/* ── …and on a narrow screen it becomes an icon ───────────────────────────
   Daniel: "on tablet and mobile same place but as an icon to reveal a drop
   down?" — which the control can already do, so this is styling rather than new
   behaviour. select.js replaces the native <select> with .v2-combo: a BUTTON
   that toggles an absolutely-positioned list. Collapsing the button to a square
   and hiding its value text leaves exactly the icon-reveals-a-dropdown that was
   asked for, with the keyboard handling, roving focus and listbox semantics
   already built.

   It matters more here than the space it saves: below 1024 the second row's
   dropdowns are hidden, so a clinic filter left down there would have gone with
   them and a multi-clinic nurse would have had no way to narrow by clinic on a
   tablet at all.

   The GLYPH IS NOT IN THE FRAME — this control isn't drawn anywhere, so it is
   three horizontal rules, the conventional filter mark, stroked in the same
   Harbour the existing chevron data URI uses so the two agree. Flagged for
   Justin along with the control itself. (Same theme caveat as that chevron: a
   colour inside a data URI can't follow a token. It is the established
   compromise in this file rather than a new one.)

   NO-JS DEGRADATION is deliberate: the native <select> gets the same square and
   the same glyph, so if select.js hasn't run the control is still a working,
   labelled dropdown rather than a 36px sliver of a text field. */
@media (max-width: 1024px) {
    /* SQUARE, AND THE SAME HEIGHT AS THE ROW IT SITS IN. Daniel, 2-Sep: "the
       clinic filter button is too tall, should be matching height."

       It was 44px against the field and button's 36 — .v2-combo-button carries
       a `min-height: 44px` below 1024 as a touch-target floor, which is right
       for a form full of dropdowns and wrong for a control sharing a line with
       two 36px siblings. Overridden HERE ONLY, so every other v2 select keeps
       its 44px target: this one is square, so it still offers 36x36 of tappable
       area rather than a 36px-tall sliver. */
    /* IT MATCHES THE FIELD BY DERIVING FROM IT, not by asserting 36px.
       -----------------------------------------------------------------------
       Daniel, 2-Sep: "the search bar on mobile seems to be slightly wider than
       it should be … making the icon for clinic select seem small (when infact
       the search bar is bigger)."

       And that is the shape of the bug rather than a number to nudge: I had
       hardcoded 36px twice — once here and once on the button — so the icon was
       36px whatever the field beside it turned out to be. Any rule that changes
       the field's box at a given width silently breaks the pairing, and the
       symptom reads as "the icon is small" when nothing about the icon changed.

       A SQUARE, ASSERTED ON BOTH AXES — which is where this ends up after three
       goes at being clever, and Daniel's own framing is the reason: "the icon
       being nice and squared … i want it to be the same so the search bar isnt
       taller."

       The two failed attempts are worth keeping, because both looked right:

         1. `width: auto` + `aspect-ratio: 1`, deriving a square off the
            stretched height. Collapsed to a sliver — a flex item's MAIN-axis
            size resolves from width/flex-basis BEFORE `stretch` resolves the
            CROSS axis, so there is no height to derive from yet, and `auto`
            fell back to a content width of ~0 (the real <select> is
            display:none once select.js has enhanced it).
         2. `align-self: stretch`, deriving the height off the flex line. That
            worked exactly as intended and was still wrong: it faithfully
            transmitted the search field being a couple of pixels taller on a
            phone straight into the icon, which then rendered 36 wide by ~38
            tall. Deriving from a neighbour means inheriting its mistakes.

       So both axes are stated, and the field is pinned to the same token below
       so the row cannot drift away from it either. --control-h-md, not 36, so
       there is one number for a control height in this file. */
    .v2-search .v2-clinic-filter {
        /* Overridden to --control-h-lg below 640, where the mobile frames put
           every control at 44px. One variable, so the square cannot come apart
           into a width and a height that disagree. */
        --clinic-btn: var(--control-h-md);
        width: var(--clinic-btn);
        height: var(--clinic-btn);
        align-self: center;
    }

    /* .v2-combo IS THE MISSING LINK, and leaving it out is what produced the
       sliver twice. select.js inserts this wrapper between the cell and the
       button, it has no height of its own, so the button's `height: 100%`
       resolved against `auto` — which means auto — and the button collapsed to
       its own border box. About 3px, because at this width .v2-combo-value is
       display:none so there is no text inside it to give it height. A 36px-wide,
       3px-tall white line is exactly what Daniel saw, twice.

       Percentage heights need a definite parent at every step of the chain. */
    .v2-search .v2-clinic-filter .v2-combo { height: 100%; }

    .v2-search .v2-clinic-filter .v2-combo-button,
    .v2-search .v2-clinic-filter .v2-select {
        /* A FLOOR AS WELL AS A PERCENTAGE, deliberately belt-and-braces after
           three goes at this control. height: 100% fills the cell's definite
           size through .v2-combo above; min-height guarantees the control can
           never collapse to a sliver again whatever happens to that chain.
           --clinic-btn, so the floor moves with the breakpoint rather than
           pinning the button to 36 under a 44px cell. */
        min-height: var(--clinic-btn);
        height: 100%;
    }

    /* Hides the chosen clinic's name, which is what frees the square. The value
       is still in the button's accessible name via the list's selected option,
       so nothing is lost to assistive tech. */
    .v2-search .v2-clinic-filter .v2-combo-value { display: none; }

    /* A CLINIC, NOT THREE LINES. Daniel: "the icon should be a clinic or
       hospital ono? the 3 lines is vague?" — and it was: a filter mark says
       "there is a filter here" without saying what it filters, which is exactly
       the question a nurse has when the label is hidden.

       A building with a cross reads as clinic at 18px where anything more
       literal turns to mush. Drawn rather than exported: this control is not in
       the frames at all, so the glyph is mine — stroked in the same Harbour the
       chevron data URI uses so the two agree, and FLAGGED FOR JUSTIN along with
       the control. Same theme caveat as that chevron: a colour inside a data URI
       cannot follow a token. */
    .v2-search .v2-clinic-filter .v2-combo-button,
    .v2-search .v2-clinic-filter .v2-select {
        padding: 0;
        background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%230A5E86' stroke-width='1.8' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M3 21h18M5 21V9l7-4 7 4v12'/%3E%3Cpath d='M12 12.5v4M10 14.5h4'/%3E%3C/svg%3E");
        background-position: center;
        background-size: 18px 18px;
    }

    /* ── OPENING IT WIDENS IT, and the search field gives up the room ─────
       Daniel, 2-Sep: "When opening the filter on tablet it pushes into the
       search bar (it should flex, when opened, the search bar shrinks to allow
       the search for clinics." And on a phone it was worse — the list opened off
       the left edge of the screen entirely.

       Both were the same mistake: I sized the open list and its search box
       ABSOLUTELY, at a fixed 240px anchored to the right of a 36px button. That
       is 200px of dropdown hanging outside its own control, over a search field
       on tablet and off the viewport on a phone.

       The fix is to stop overlaying and let flex do it. The cell widens IN FLOW
       when the control opens, so the search field — which is `flex: 1 1 auto` —
       shrinks by exactly that much, and the absolutely-positioned list and
       search box then inherit the widened cell's own box. Nothing hangs outside
       anything, and the list can no longer leave the screen because its control
       never does.

       aria-expanded is the hook, which means no new JavaScript: select.js
       already sets it on open, and sets it BEFORE it measures the list's height,
       so the widening is in the layout by the time the open animation is
       calculated. */
    .v2-search .v2-clinic-filter:has(.v2-combo-button[aria-expanded="true"]) {
        width: 240px;
        max-width: 100%;
    }

    /* No right-anchoring any more: left/right 0 from the base rule makes the
       list exactly as wide as the control it belongs to, which is now 240px and
       always on screen. */
}

.v2-order-filters-row {
    display: flex;
    flex-wrap: wrap;
    gap: var(--space-2);
    align-items: center;
    justify-content: space-between;
}

/* NO WRAP HERE — the dropdowns SHRINK, they do not stack.
   -----------------------------------------------------------------------------
   This wrapped, and that is what put Status above Date on a desktop panel with
   room for both (Daniel, 2-Sep: "that has caused the status and date bar to flex
   for some reason"). It was my doing one change earlier: giving the cells
   `flex: 0 1 221px` so three of them could fit made them shrinkABLE, but
   flex-wrap decides where an item goes using its flex BASE size, not its shrunk
   size. So with the group squeezed below 2x221 the second select was moved to a
   new line and never got the chance to shrink — wrap wins over shrink, always.

   nowrap removes the choice: the line always holds both selects at the definite
   width set below. .v2-order-filters-row still wraps, so on a narrow panel the
   whole view-button group drops to its own line — which is the break that should
   happen, rather than one dropdown orphaning itself from another. */
.v2-order-filters-selects {
    display: flex;
    flex-wrap: nowrap;
    gap: var(--space-2);
    /* DO NOT SHRINK. Daniel, 2-Sep: "on desktop the filters still are too small
       compared to spec?" — they were, and shrinking is why.

       The frame's row (1362:7759) fits its two 221px dropdowns and the three
       view buttons across a 930px panel, with about 20px to spare. Any narrower
       window than the frame's 1440 and the panel comes in under 930, at which
       point a shrinkable group is the first thing to give — so the dropdowns
       quietly compressed towards their minimum and stopped looking like the
       design at every width except the exact one it was drawn at.

       Two dropdowns is all this row ever holds: the clinic filter a multi-clinic
       nurse gets lives in the search row above, where there is width going
       spare.

       Held at their basis instead, the row's own flex-wrap does the adapting:
       the view buttons drop to a second line when there isn't room for both
       groups, and the dropdowns stay the size the frame draws them. A wrapped
       row is a truer rendering of this design than a squashed one. */
    flex: 0 0 auto;
}

/* 221px in the frame (1362:7761), but allowed to shrink: the panel is narrower
   than the frame's 850 at every breakpoint below desktop, and a fixed width here
   is what would push the view buttons onto their own line prematurely.

   ON THE CELL, not on the <select>. select.js replaces the native control with a
   .v2-combo widget inserted as its next sibling and hides the original, and
   .v2-combo is width:100% — so a width set on .v2-select styles the one element
   that is display:none by then. The cell is the thing both of them size to. */
.v2-order-filter-cell {
    /* A DEFINITE 221px, the frame's own (1362:7761) — not a flex-basis.
       -----------------------------------------------------------------------
       This is the third go at it and the first correct one, so the reason is
       worth stating: `flex: 0 1 221px` did NOT hold them at 221. A flex item's
       basis does not feed its container's intrinsic width when the item can
       shrink — the group's max-content came from the CELLS' OWN CONTENT
       instead, which after select.js swaps in the .v2-combo widget is just the
       placeholder word plus the chevron, around 130px. So the group measured
       ~270 rather than 450, the row saw it fit alongside the view buttons, and
       the dropdowns rendered at the min-width floor: "on desktop the filters
       still are too small compared to spec?" (Daniel, 2-Sep).

       An explicit width is definite, so it contributes 221 to the group's
       intrinsic size, the row correctly concludes it cannot seat both groups on
       one line, and the view buttons wrap instead of the dropdowns collapsing.
       max-width keeps it honest if it ever lands somewhere narrower than
       itself. */
    flex: 0 0 auto;
    width: 221px;
    max-width: 100%;
}

/* AFTER the two rules above, not in the earlier mobile block, because a media
   query carries no extra specificity — put this first and `nowrap` and the
   221px basis would simply win. This is the same order-beats-media-query trap
   that forced a 56px nav column onto a phone on the staff dashboard. */
/* ── On a phone the dropdowns GO, and the view buttons span ──────────────
   Daniel, 2-Sep: "drop the filters on mobile per spec, and align the status live
   / closed and scheduled so they span the panel as currently fall short."

   Which is what the mobile frame shows: search, its button, and the three view
   buttons — no Status, no Date. A 221px dropdown has nowhere to go in a 370px
   panel, and two of them stacked cost more depth than they were worth above a
   list you came to read.

   Nothing is lost that the nurse can't still get at: the three views are the
   filter that matters on a phone, and they stay. The selects keep submitting
   their defaults — empty for Status and Date, which means "no filter" — so
   hiding them changes the query not at all.

   1024px, NOT the 900 this started at: it is already where .v2-view-toggle
   hides, so it is this codebase's own line for "this surface is too narrow for
   optional controls" rather than a new one.

   (It also used to be carrying an overflow fix — three definite 221px dropdowns
   in a non-shrinking group came to 679px, which a tablet panel could not
   contain. That is no longer the reason: the clinic filter moved up into the
   search row, so this row is only ever the two the frame draws. The breakpoint
   stays where it is on the merits above.)

   The view buttons that replace them are widened separately: .v2-segment and
   .v2-segment-option are declared further down this file, and a media query
   carries no extra specificity, so from here those overrides would lose. They
   sit in a second mobile block immediately after their base rules. */
@media (max-width: 1024px) {
    .v2-order-filters-selects { display: none; }
}

/* ── The segmented view control (1362:7763) ──────────────────────────────
   Live orders / Closed orders / Scheduled. The selected one takes the frame's
   Harbour Light fill with a Seafoam label; the others have no fill at all and a
   Flint 400 label.

   RADIOS, styled. They are one choice out of three, so the platform's own
   control is the right one — arrow keys move between them, there is a single tab
   stop, and each is announced as "1 of 3". A row of <button>s would need all of
   that reimplementing in JS and would still not say they were alternatives.

   The input is positioned off-screen rather than display:none, which would take
   it out of the tab order and remove it from the accessibility tree — the exact
   trap that makes styled radios inaccessible. */
/* The unselected label's colour, per theme — read from both frames rather than
   derived from either. See the note on .v2-segment-option's `color`. */
:root      { --segment-idle: var(--colour-flint-400); }    /* [frame] 1362:7765 */
[data-theme="dark"] { --segment-idle: var(--colour-seafoam-600); } /* [frame] 1362:7048 */

.v2-segment {
    display: flex;
    gap: var(--space-2);
    margin: 0;
    padding: 0;
    border: 0;
}

.v2-segment-option {
    position: relative;
    display: inline-flex;
    align-items: center;
    justify-content: center;
    min-width: 80px;                      /* [frame] 1362:7764 */
    height: 36px;
    padding: 0 var(--space-4);
    border-radius: var(--radius-button);  /* 12px */
    font-size: var(--type-body-size);
    font-weight: 500;
    line-height: 1;
    /* THE UNSELECTED LABEL, AND IT IS NOT A GREY IN DARK.
       -----------------------------------------------------------------------
       Daniel, 2-Sep: "in dark mode its white text on the inactive tabs instead
       of the colured text on spec." Correct, and my fault for a familiar reason:
       I read the LIGHT frame, saw Flint 400, reasoned that a role token would
       theme it properly, and used --text-muted. That resolves to Pebble 200 in
       dark — near-white.

       The dark frame says something no amount of reasoning would have produced:

         light  1362:7765  colour/flint/400    #566069   a dark slate
         dark   1362:7048  colour/seafoam/600  #7BBFB5   a muted TEAL

       Not a lighter grey, a different hue. Which is precisely what the standing
       rule is for — check both frames, never derive one theme from the other.

       No existing role pairs those two, so this is its own token. Measured on
       --surface-card, the plate these actually sit on: 5.5:1 in light
       (#566069 on #EEEEEE) and 5.5:1 in dark (#7BBFB5 on #2E3B40). Both clear
       AA for body text, so there is nothing to trade off here — the frame's
       values are simply right. */
    color: var(--segment-idle);
    white-space: nowrap;
    cursor: pointer;
    transition: background-color 120ms ease;
}

.v2-segment-option input {
    position: absolute;
    width: 1px;
    height: 1px;
    margin: 0;
    opacity: 0;
    pointer-events: none;
}

/* The unselected hover, on the same fill the rest of v2 uses for a hover on an
   unfilled control — so it can't disagree with .v2-collection-more or a
   secondary button. */
.v2-segment-option:hover:not(:has(input:checked)) {
    background-color: var(--surface-hover);
}

.v2-segment-option:has(input:checked) {
    /* [frame] Harbour Light — the same fill the record card's Order button takes,
       and --action-generic is the role that already names it. */
    background-color: var(--action-generic);
    color: var(--colour-seafoam-200);
}

/* The focus ring goes on the LABEL, because the input it belongs to is 1px and
   off-screen. :focus-visible so it appears for keyboard use and not on click. */
.v2-segment-option:has(input:focus-visible) {
    /* base.css's own ring, values and all — a second focus treatment on one
       surface is how keyboard users learn to distrust it. */
    outline: 2px solid var(--border-focus);
    outline-offset: 2px;
}

/* ── Below 1024 the three views divide the panel ─────────────────────────
   Daniel, 2-Sep: "align the status live / closed and scheduled so they span the
   panel as currently fall short." They were content-width and left-aligned, so
   the row ended in dead space — and with the dropdowns gone at this width
   (same 1024 breakpoint, see the block above .v2-order-filter-cell) they are
   the only filter left, so they should own the line.

   flex-basis 0 with grow makes the three equal whatever their labels weigh, so
   "Scheduled" gets the same target as "Closed orders" rather than a third less.

   IMMEDIATELY AFTER THE BASE RULES ON PURPOSE. A media query adds no
   specificity, so written any earlier in this file these two would simply lose
   to the rules above — the same order-beats-media-query mistake that had the
   mobile stats block doing nothing an hour ago, and that once put a 56px nav
   column on a phone. */
@media (max-width: 1024px) {
    .v2-segment { flex: 1 1 100%; }

    .v2-segment-option {
        flex: 1 1 0;
        /* Releases the 80px desktop minimum so three can share a 320px phone
           without overflowing, and trims the side padding that was sized for a
           button hugging its own label. */
        min-width: 0;
        padding: 0 var(--space-2);
    }
}

/* ── View toggle (ViewSlider 918:5439) ───────────────────────────────────
   72x36 track, a 34x34 thumb on the selected half. Built from CSS plus the two
   glyph masks rather than the flat exports, because the exports only exist for
   the grid-active state — a flattened track cannot render list-active at all.

   COLOURS COME FROM EXISTING ROLES AND THEY HOLD BOTH WAYS, which the frame's
   own values do not: its Pebble 400 thumb is 2.27:1 against the Pebble 100
   track, under 1.4.11's 3:1 for a control boundary. The set here:

     track          --surface-card   sits on the panel, so it is a step off it
     thumb          --text-muted     Pebble 600 light / Pebble 200 dark — dark
                                     thumb on a light track, light thumb on a
                                     dark one. 5.5:1 and 8.4:1
     selected glyph --surface-card   the track colour punched out of the thumb,
                                     which inverts with it for free
     idle glyph     --text-muted     same role as the thumb, on the track

   Inert for now: it needs the list layout, which the frame does not contain. */
.v2-view-toggle {
    display: flex;
    flex: 0 0 auto;
    gap: 0;
    padding: 1px;
    border-radius: var(--radius-pill);
    background-color: var(--surface-card);
}

/* PINNED TO THE PANEL'S TOP-RIGHT, IDENTICALLY ON BOTH LIST PANELS.
   -----------------------------------------------------------------------------
   Daniel, 2-Sep: "lets bring my patients and my orders grid toggle in line and
   stick it top right of panel. slightly higher than it is now on my orders.
   then we can bring the header of panel up on my orders in line with my
   patients."

   Those are one change, not two, and taking it out of flow is what does both.
   In the head's grid the toggle was a column-2 item bottom-aligned with the
   title — which meant My Orders' head row was as tall as the toggle PLUS the
   stat tiles stacked under it (~93px against My Patients' ~60px), so the filter
   card and the first row of cards sat lower on one panel than the other. Pinned,
   the head row is only as tall as its own text again and the two panels' headers
   line up, with the stats left as the only thing in the trailing slot.

   It also matches the file more closely than the in-flow version did: the frame
   places the slider against the CARD's edge (1782:12832 at y=18 of the 930-wide
   card), outside the 850 head row — which is why its x overflowed that row's
   right edge and read as a positioning oddity rather than a layout item.

   The containing block is .v2-panel-head, which already carries
   position: relative for its fade bands — so the offset is expressed as the
   difference between the frame's 18px from the card top and the head's own
   inset, and stays correct if that padding is ever retuned. right: 0 lands it on
   --panel-pad-x, which is the 40px the frame has.

   KEYED ON THE HEAD, NOT ON A MODIFIER, and that is a bug I shipped rather than
   a refinement. This said .v2-panel-head--control, which was every head that
   had a trailing slot — until 10-Sep, when a second shape appeared and Order
   queries took it. The panel stopped matching, the toggle lost its pinning and
   fell back into flow above the search row (Daniel: "in My Queries you seem to
   have broken the grid selector?").

   The toggle only ever lives in a head, so the head is what it should have been
   scoped to all along. Now a third head shape cannot break it. */
.v2-panel-head .v2-view-toggle {
    position: absolute;
    top: calc(18px - var(--panel-pad-top));
    right: 0;
    /* Above the head's scrim, which is what the cards scroll under. */
    z-index: 1;
}

.v2-view-toggle button {
    display: grid;
    place-items: center;
    width: 34px;
    height: 34px;
    padding: 0;
    border: 0;
    border-radius: var(--radius-pill);
    background-color: transparent;
    cursor: pointer;
    transition: background-color 120ms ease;
}

.v2-view-toggle button[aria-pressed="true"] {
    background-color: var(--v2-vt-thumb, var(--colour-pebble-400));   /* [frame] GridSliderOn 918:5442 */
}

.v2-view-toggle-icon {
    display: block;                       /* a span is not a replaced element,
                                             so a mask needs this to have a box */
    /* 18, not 16: the glyphs are 16px drawings with a 2px stroke, so their ink
       runs 1px past each side. A 16px mask cut it off — square corners on the
       grid and half-dots on the list (Daniel, 5-Oct: "not the right design"). */
    width: 18px;
    height: 18px;
    background-color: var(--v2-vt-idle, var(--colour-pebble-400));   /* [frame] 918:5440 */
    -webkit-mask-repeat: no-repeat;
            mask-repeat: no-repeat;
    -webkit-mask-position: center;
            mask-position: center;
    -webkit-mask-size: 18px 18px;
            mask-size: 18px 18px;
}

.v2-view-toggle button[aria-pressed="true"] .v2-view-toggle-icon {
    background-color: var(--colour-seafoam-50);   /* [frame] the thumb's glyph */
}

/* INLINED, and now safely on a custom property — see the note at the head of the
   mask work in overlays.css. The old warning here was about a RELATIVE url() in a
   custom property, which has no agreed base; a data URI has no base to resolve,
   so the hazard that rule guarded against no longer exists. The property lets the
   prefixed and unprefixed declarations share one copy of the SVG instead of two. */
.v2-view-toggle button[value="grid"] .v2-view-toggle-icon {
    --v2-mask: url("data:image/svg+xml,%3Csvg width='18' height='18' viewBox='0 0 18 18' fill='none' xmlns='http://www.w3.org/2000/svg'%3E%3Cg transform='translate(-8 -8)'%3E%3Cpath d='M14.3333 9H9.88889C9.39797 9 9 9.39797 9 9.88889V14.3333C9 14.8243 9.39797 15.2222 9.88889 15.2222H14.3333C14.8243 15.2222 15.2222 14.8243 15.2222 14.3333V9.88889C15.2222 9.39797 14.8243 9 14.3333 9Z' stroke='%23000' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'/%3E%3Cpath d='M24.1111 9H19.6667C19.1757 9 18.7778 9.39797 18.7778 9.88889V14.3333C18.7778 14.8243 19.1757 15.2222 19.6667 15.2222H24.1111C24.602 15.2222 25 14.8243 25 14.3333V9.88889C25 9.39797 24.602 9 24.1111 9Z' stroke='%23000' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'/%3E%3Cpath d='M24.1111 18.7778H19.6667C19.1757 18.7778 18.7778 19.1757 18.7778 19.6667V24.1111C18.7778 24.602 19.1757 25 19.6667 25H24.1111C24.602 25 25 24.602 25 24.1111V19.6667C25 19.1757 24.602 18.7778 24.1111 18.7778Z' stroke='%23000' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'/%3E%3Cpath d='M14.3333 18.7778H9.88889C9.39797 18.7778 9 19.1757 9 19.6667V24.1111C9 24.602 9.39797 25 9.88889 25H14.3333C14.8243 25 15.2222 24.602 15.2222 24.1111V19.6667C15.2222 19.1757 14.8243 18.7778 14.3333 18.7778Z' stroke='%23000' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/g%3E%3C/svg%3E");
            -webkit-mask-image: var(--v2-mask);
            mask-image: var(--v2-mask);
}

.v2-view-toggle button[value="list"] .v2-view-toggle-icon {
    --v2-mask: url("data:image/svg+xml,%3Csvg width='18' height='18' viewBox='0 0 18 18' fill='none' xmlns='http://www.w3.org/2000/svg'%3E%3Cg transform='translate(-45 -9)'%3E%3Cpath id='Vector' d='M46 12H46.0089M46 18H46.0089M46 24H46.0089M50.4444 12H62M50.4444 18H56.2222H62M50.4444 24H62' stroke='%23000' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/g%3E%3C/svg%3E");
            -webkit-mask-image: var(--v2-mask);
            mask-image: var(--v2-mask);
}

/* ── The card grid (MyPatients_Cards 915:5181) ───────────────────────────
   The frame is three fixed 270px cards with a 20px gap in an 850 row. auto-fill
   reproduces that at 850 and then keeps working: 250px is the narrowest a card
   reads at, so the grid drops to two and then one on its own. */
.v2-record-grid {
    display: grid;
    grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
    gap: var(--space-5);                  /* 20px */
    align-items: start;
}

/* NO text-box TRIM. It caused the squashing, twice.
   -----------------------------------------------------------------------------
   Every text layer in MyPatientsCard carries [text-box-trim:trim-both] with
   [text-box-edge:cap alphabetic] — Figma measures those boxes cap-height to baseline
   rather than across the font's ascender and descender. Reproducing that with the
   `text-box` property did make the card match the frame's height, and it also clipped
   the boxes so tightly that the label sat on the value: Daniel reported it as squashed
   and overlapping twice, first at gap: 6px and again after I widened the gap to 8px.
   Widening the gap was treating the symptom — the boxes themselves were the problem.

   So the trim is gone and line-height carries the spacing at 1.3, which is a normal
   readable line box. The cards are a few px taller than the frame's 208 as a result,
   and that is the right trade: the frame's height was never the requirement, its
   LEGIBILITY was, and one of those was costing the other.

   Left as a note rather than deleted because the pull to re-add it is obvious — the
   frame really does specify the trim. It is specified on text that Figma renders and
   we don't, and matching the metric broke the reading. */
/* ── THE RECORD CARD — shared by My Patients and My Orders ───────────────
   MyPatientsCard 900:228 and MyOrdersCard 927:293.

   NAMED FOR THE PATTERN, NOT THE FIRST PANEL TO USE IT. These were
   .v2-patient-* until My Orders arrived and turned out to need every one of them
   unchanged: the two components are the same card. Same 16px/8px padding, same
   20px radius, same 12px gap, same inset detail cells, and the same three-step
   type ramp (13.049 / 10.874 / 8.699px) — verified against 1362:7768, not
   assumed from the resemblance.

   Renaming rather than adding a second selector to every rule is the point
   (Daniel, 2-Sep: "worth also matching much of the adjustments made in
   my-patients … no need to rehash the same issues twice"). Every fix argued out
   on My Patients — the text-box trim that squashed the cells twice, the cell
   padding vs gap correction, min-width:0 so long names ellipse instead of
   shoving the trailing control out — now applies to Orders because it is
   literally the same rule, not a copy that has to be kept in step.

   WHAT STAYS PANEL-SPECIFIC is only what genuinely differs: .v2-patient-delete
   (the bin, which Orders has no equivalent of — it carries a status tag there
   instead), and the list-view block further down.

   16px vertical padding against 8px horizontal is the frame's, not a slip —
   the detail cells carry their own 8px, so the card's sides stay tight. Radius
   is 20px: the component default says 12 but every instance in the row
   overrides it. */
.v2-record-card {
    display: flex;
    flex-direction: column;
    gap: var(--space-3);                  /* 12px */
    padding: var(--space-4) var(--space-2);
    border-radius: var(--radius-card);
    background-color: var(--surface-card);
}

.v2-record-head {
    display: flex;
    gap: var(--space-4);
    align-items: center;
}

/* min-width:0 so a long name ellipses instead of pushing the delete button out
   of the card — the default min-width:auto on a flex item will not shrink. */
.v2-record-ident {
    flex: 1 1 0;
    min-width: 0;
    display: flex;
    flex-direction: column;
    gap: 11.599px;                      /* [frame] 861:2781 */
}

.v2-record-name {
    margin: 0;
    font-size: 13.049px;                  /* [frame] 861:2782 */
    font-weight: 700;
    line-height: 1.15;
    color: var(--text-body);
    overflow: hidden;
    text-overflow: ellipsis;
    white-space: nowrap;
}

.v2-record-meta {
    margin: 0;
    font-size: 8.699px;                   /* [frame] 861:2783 */
    font-weight: var(--type-caption-weight);
    line-height: 1.15;
    color: var(--text-card-meta);         /* [frame] seafoam 600, both themes */
}

.v2-patient-delete {
    flex: 0 0 auto;
    display: grid;
    place-items: center;
    width: 36px;
    height: 36px;
    padding: 0;
    border: 0;
    border-radius: var(--radius-button);
    background-color: var(--intent-danger);
    cursor: pointer;
    transition: background-color 120ms ease;
}

.v2-patient-delete:hover:not([disabled]) {
    background-color: var(--colour-sea-pink-600);   /* a scale step, per §1 */
}

.v2-patient-delete[disabled] { cursor: not-allowed; }

.v2-patient-delete span {
    display: block;
    /* ICON/TRASH, 921:261 (Daniel, 13-Oct: "is the bin icon correct. looks
       squashed" - it was another glyph, drawn 18px wide in a 16px box).
       16.28 x 19 artwork at an even 16 x 18, so it centres on whole pixels
       in the 36px button. */
    width: 16px;
    height: 18px;
    background-color: var(--text-on-accent);        /* 3.43:1 on Sea Pink 500,
                                                       clears 1.4.11's 3:1 */
    --v2-mask: url("data:image/svg+xml,%3Csvg width='16.2843' height='19' viewBox='0 0 16.2843 19' fill='%23000' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M14.2906 2.71698C14.6652 2.7404 14.9499 3.06308 14.9265 3.43767L14.0787 17.0062C14.0112 18.1054 13.1933 18.9997 12.0438 19H4.24078C3.10299 18.9997 2.26627 18.1147 2.2059 17.0062L1.35804 3.43767C1.33462 3.06308 1.61935 2.7404 1.99394 2.71698C2.36852 2.69357 2.69121 2.97829 2.71462 3.35288L3.56249 16.9214V16.9293C3.58594 17.3851 3.88893 17.6405 4.24078 17.6407H12.0438C12.3922 17.6405 12.6935 17.3908 12.7221 16.9227V16.9214L13.57 3.35288C13.5934 2.9783 13.9161 2.69358 14.2906 2.71698Z'/%3E%3Cpath d='M15.6047 2.716C15.98 2.716 16.2843 3.0203 16.2843 3.39562C16.2843 3.77094 15.98 4.07524 15.6047 4.07524H0.679617C0.304298 4.07524 0 3.77094 0 3.39562C0 3.0203 0.304298 2.716 0.679617 2.716H15.6047Z'/%3E%3Cpath d='M10.1778 3.39411V1.69573C10.1779 1.65167 10.168 1.60798 10.1513 1.56723C10.1344 1.52637 10.1097 1.48852 10.0784 1.45727C10.0472 1.42618 10.0105 1.4012 9.96978 1.38441C9.92905 1.3676 9.88533 1.35916 9.84127 1.35923H6.44451C6.40053 1.35911 6.3567 1.36771 6.31601 1.38441C6.27516 1.40126 6.2373 1.42602 6.20605 1.45727C6.1748 1.48852 6.15004 1.52637 6.13319 1.56723C6.11649 1.60792 6.10789 1.65175 6.10802 1.69573V3.39411C6.10783 3.76927 5.8036 4.07373 5.4284 4.07373C5.0532 4.07373 4.74897 3.76927 4.74878 3.39411V1.69706C4.74844 1.47451 4.79242 1.25365 4.87729 1.04791C4.96245 0.841637 5.08778 0.654599 5.24558 0.496796C5.40338 0.338994 5.59042 0.21367 5.79669 0.128505C6.00244 0.0436351 6.22329 0.000982704 6.44584 0.00132479L9.83862 0V0.00132479C10.0614 0.000898563 10.2818 0.0435643 10.4878 0.128505C10.6942 0.213673 10.8823 0.338863 11.0402 0.496796C11.1979 0.65459 11.3234 0.841693 11.4085 1.04791C11.4934 1.25365 11.536 1.47451 11.5357 1.69706V3.39411C11.5355 3.76927 11.2313 4.07373 10.8561 4.07373C10.4812 4.07339 10.178 3.76906 10.1778 3.39411Z'/%3E%3Cpath d='M7.46265 15.6057V6.10692C7.46265 5.73161 7.76694 5.42731 8.14226 5.42731C8.51758 5.42731 8.82188 5.73161 8.82188 6.10692V15.6057C8.82157 15.9807 8.51739 16.2853 8.14226 16.2853C7.76714 16.2853 7.46296 15.9807 7.46265 15.6057Z'/%3E%3Cpath d='M5.06513 5.42724C5.44002 5.41408 5.7552 5.70808 5.7686 6.08302L6.10774 15.5804C6.12114 15.9555 5.82705 16.2705 5.45197 16.2839C5.07715 16.297 4.76198 16.0043 4.74851 15.6295L4.40936 6.13071C4.3962 5.75581 4.69019 5.44063 5.06513 5.42724Z'/%3E%3Cpath d='M11.2188 5.42724C11.5938 5.44063 11.8878 5.75581 11.8746 6.1307L11.5355 15.6295C11.522 16.0043 11.2068 16.2971 10.832 16.2839C10.4569 16.2705 10.1628 15.9555 10.1762 15.5804L10.5154 6.08301C10.5288 5.7081 10.844 5.41412 11.2188 5.42724Z'/%3E%3C/svg%3E");
            -webkit-mask-image: var(--v2-mask);
            mask-image: var(--v2-mask);
    -webkit-mask-repeat: no-repeat;
            mask-repeat: no-repeat;
    -webkit-mask-position: center;
            mask-position: center;
    -webkit-mask-size: 16px 18px;
            mask-size: 16px 18px;
}

/* ── Detail cells (Detail/Patient 861:2789 and siblings) ─────────────────
   A <dl> because that is what these are: four label/value pairs. The rows are
   flex rather than a 2-column grid so a cell can take the whole width if a card
   ever carries an odd number of them. */
.v2-record-details {
    display: flex;
    flex-direction: column;
    gap: var(--space-2);
    margin: 0;
}

.v2-record-row {
    display: flex;
    gap: var(--space-2);
}

.v2-record-cell {
    flex: 1 1 0;
    min-width: 0;                         /* so the value can ellipse */
    display: flex;
    flex-direction: column;
    /* THE FRAME'S 6px GAP, WITH LESS PADDING AROUND IT.
       Daniel, 18-Aug: "the position of the text was perfect, widening it was the wrong
       fix, should simply reduce the padding margin."

       Right — once the text-box trim came off, the label and value sat correctly and
       what remained was a cell whose padding made the pair look cramped inside it. I
       had widened the GAP, which moved text that was already in the right place. The
       gap goes back to the frame's 6px and the padding comes down instead: same text
       position, more room around it, and a tighter cell. */
    gap: 4px;                             /* text closer, per Daniel */
    padding: 4px var(--space-2);          /* 36px cell, matching the frame */
    border-radius: var(--radius-button);
    background-color: var(--surface-inset);
}

.v2-record-cell dt {
    font-size: 8.699px;                   /* [frame] 861:2790 */
    font-weight: var(--type-caption-weight);
    line-height: 1.15;
    color: var(--text-card-label);        /* [frame] seafoam 700, both themes */
}

.v2-record-cell dd {
    margin: 0;
    font-size: 10.874px;                  /* [frame] 861:2791 */
    font-weight: 500;
    line-height: 1.15;
    color: var(--text-body);
    overflow: hidden;
    text-overflow: ellipsis;
    white-space: nowrap;                  /* "Oxford Lymphoed..." truncates in
                                             the frame too, so this is designed */
}

/* ── The ORDER card's cells keep the frame's own padding ─────────────────
   Daniel, 2-Sep: "Order cards are too thin (not deep enough compared to spec)?"

   They were, and the cause is the shared card doing its job. The cell above
   carries HIS 18-Aug tuning of the PATIENT card — "the position of the text was
   perfect, widening it was the wrong fix, should simply reduce the padding
   margin" — which took the vertical padding from the frame's 8px down to 4. An
   order card is two cell rows, so it inherited 16px less depth than
   MyOrdersCard 927:293 draws, on top of having no second identity line where the
   patient card has DOB and age. Hence noticeably shallower than the frame.

   THE TWO INSTRUCTIONS DO NOT ACTUALLY CONFLICT, so this doesn't pick between
   them: the tighter cell was asked for on My Patients and is kept there, and the
   order card goes back to the frame's 8px/6px. Both cards then match their own
   frame, which is the thing that matters more than the two being identical to
   each other.

   Say the word if you want them unified — one selector to delete either way, and
   the frame's values are the ones written here. */
.v2-record-card:has(.v2-tag) .v2-record-cell {
    gap: 6px;                             /* [frame] 920:6822 */
    padding: var(--space-2);              /* [frame] 8px all round */
}

/* THE NAME IS BIGGER ON AN ORDER CARD, AND IT COSTS NOTHING.
   Daniel, 2-Sep: "can probably make the names a little bigger on the order cards
   too now without making the cards any bigger."

   Right, and the reason it is free is the tag. An order card's head is a 24px
   status pill beside a single line of name, so the row's height is the PILL's,
   not the name's — the name at the frame's 13.049px only occupies about 15px of
   it and the remaining 9 are already being paid for. --type-body-size (15px)
   takes it to roughly 17px and the head does not grow by a pixel, so neither
   does the card.

   15px is the ceiling for that, which is why it is the value: the next step up
   in the ramp would clear 24px and start pushing the head open. Deliberately not
   applied to the patient card, whose head has a second line (DOB and age) under
   the name and no such slack — there the same change would make every card
   taller. */
.v2-record-card:has(.v2-tag) .v2-record-name {
    font-size: var(--type-body-size);
}

.v2-record-actions {
    display: flex;
    gap: var(--space-2);
}

.v2-record-actions .v2-button {
    flex: 1 1 0;
    min-width: 0;
    justify-content: center;
}

/* Order takes the frame's own fill, Harbour LIGHT (861:2805, and the same in the
   dark card 915:5231) — not --brand-primary, which is Harbour 500 in light.
   I had stepped it for contrast; reverted under the standing rule that the frame
   decides. MEASURED AT 2.68:1 under the frame's Seafoam label, recorded rather
   than corrected. The same trade Daniel already took on Meadow. */
.v2-record-actions .v2-button--primary {
    background-color: var(--colour-harbour-light);
}

.v2-record-actions .v2-button--primary:hover {
    background-color: var(--colour-harbour-500);   /* a scale step, per §1 */
}


/* ── Status tag (Tag 923:6967) ───────────────────────────────────────────
   The pill in an order card's head, where a patient card puts the bin.

   GEOMETRY IS THE FRAME'S, EXACTLY: 24px tall (and --tag-height already existed
   in scales.css for it), fully rounded, 12px of side padding, 10px Medium, with
   a 1px border a step darker than the fill. Not a fixed 65px width though —
   the frame's instance is sized to "Pending" and the real labels run to
   "Part Prescription Received", which would either clip or force a second line.

   THE COLOUR PER STATUS IS NOT IN THE FILE, and this is the honest version of
   that. Every one of the nine MyOrdersCard instances across both themes points
   at the SAME variant — "Pending", in Sea Pink 300 on a Sea Pink border — so
   Justin has drawn one state, not a set. Inventing eight more hexes is exactly
   what the standing rule forbids, so the other statuses take the --intent-*
   role tokens that §1 already defines for precisely this job. That is reuse of
   the system rather than a new palette, and every one of them is one line to
   repoint when the variants arrive.

   ONE FOR JUSTIN, and it needs saying plainly: Sea Pink is this system's DANGER
   colour, and it is what the only drawn variant uses for Pending. That may well
   be deliberate — a pending order is the one a nurse can still edit, and
   --badge-attention is Sea Pink 600 too, so Sea Pink reads as "attention" here
   rather than "error". Implemented as drawn either way. */
.v2-tag {
    display: inline-flex;
    align-items: center;
    justify-content: center;
    flex: 0 0 auto;
    min-height: var(--tag-height);           /* 24px, per 923:6967 */
    padding: 0 var(--space-3);               /* 12px sides */
    border: 1px solid var(--v2-tag-border, transparent);
    border-radius: var(--radius-pill);
    background-color: var(--v2-tag-fill, var(--surface-inset));
    font-size: 10px;                         /* [frame] 923:6962 */
    font-weight: 500;
    line-height: 1.2;
    /* Seafoam in the frame, and it is the label on every fill — the tag is
       always a mid-tone plate, never a subtle tint, so one label colour works
       for all of them. */
    color: var(--colour-seafoam-200);
    text-align: center;
    /* The long labels wrap rather than clip. A two-line tag is taller than the
       frame's 24 and that is the right way round: "Part Prescription Received"
       is the status, and a truncated status is worse than a tall card. */
    white-space: normal;
}

/* [frame] 923:6967 — the one variant Justin drew. */
.v2-tag--attention {
    --v2-tag-fill:   var(--colour-sea-pink-300);
    --v2-tag-border: var(--colour-sea-pink-500);
}

/* The rest, on role tokens rather than invented hexes. Fill is the 500 the role
   already names; the border is the step down that the drawn variant's
   300-fill/500-border relationship implies, applied consistently. */
.v2-tag--progress {                          /* in checks, with BrightPearl */
    --v2-tag-fill:   var(--intent-info);
    --v2-tag-border: var(--intent-info-text);
}

.v2-tag--waiting {                           /* waiting on someone else */
    --v2-tag-fill:   var(--intent-warning);
    --v2-tag-border: var(--intent-warning-text);
}

.v2-tag--done {
    --v2-tag-fill:   var(--intent-success);
    --v2-tag-border: var(--intent-success-text);
}

/* Cancelled is deliberately the quietest of the set, not the loudest. It is a
   closed, settled state — the nurse has nothing to do about it — so it reads as
   inert rather than as an alarm. */
.v2-tag--inert {
    --v2-tag-fill:   var(--colour-flint-300);
    --v2-tag-border: var(--colour-flint-400);
}


/* ── Record section, footer, and the list view ───────────────────────────
   MyPatientSection (912:4929) is a column with a 20px gap. That gap has to be
   stated: the panel body is a plain block, so without it the search card and the
   results sat flush — which is exactly what Daniel caught on the first render. */
/* A PLAIN BLOCK, deliberately — it was a flex column.
   -----------------------------------------------------------------------------
   At the version Daniel tested (overlays.css efb93525ce / commit 45bb8e49) the
   panel CSS was identical for both panels, and Register scrolled while My Patients
   did not. Identical container, different result, so the difference is what sits
   INSIDE the scroller — and the only structural difference was this: Register's
   body is a plain sequence of blocks, mine was a flex column wrapping everything.

   Blocks and a margin, which is what Register effectively has. Same 20px rhythm
   from MyPatientSection (912:4929), one fewer formatting context between the
   scroll container and the cards. */
.v2-record-section > * + * {
    margin-top: var(--space-5);           /* 20px, per 912:4929 */
}

/* NO COUNT LINE. Daniel, 18-Aug: "The showing X patients - showing X - isnt on
   spec. get rid." It isn't in the frame and I added it, so it goes.

   The element stays for the states a fetching surface has and a mockup doesn't —
   loading, an error, "no clinics assigned" — but it is silent about counts, and it
   is .v2-visually-hidden in the markup until there is something worth showing, so
   nothing occupies the layout in the normal case. */
/* ── The collection footer ───────────────────────────────────────────────
   SHELL-LEVEL, not My Patients': every fetched list has a footer, so these
   classes are named for the capability (collection.js) rather than for the first
   panel to use them. My Orders and Queries inherit them instead of copying them.

   ONE SLOT, ONE SET OF METRICS, TWO APPEARANCES. Exactly one of these is ever on
   screen — collection.js decides which in a single branch — so they share the
   height, the font and the centring from --collection-foot-*, and differ only in
   the one thing that should differ: the button looks pressable and the status
   line does not.

   That is the reconciliation of two instructions that pull against each other.
   Daniel, 18-Aug: "use the bottom style" — the footer must not change weight
   depending on which state you land in. Daniel, 2-Sep: "better style the load
   more button" — as a bare text link it read as an afterthought at the bottom of
   a wall of cards. Shared metrics answer the first, the outline answers the
   second, and neither needs the other's rule.

   PROVISIONAL, and flagged as such: Load more is NOT in the frames. Justin asked
   for append-paging over numbered pages and the control itself was never drawn,
   so this is built from the design system's own parts (--button geometry,
   --border-strong, --radius-pill) rather than invented from nothing. Worth a
   frame when he next has the file open. */
.v2-collection-status,
.v2-collection-more {
    /* 32px clear of the last card, not the section's own 20. Daniel, 18-Aug: "space it a
       little more from the actual results." It closes the list rather than belonging to
       it, so it should sit further off than one card sits from the next. */
    margin: var(--space-8) auto 0;
    min-height: 40px;
    font-family: inherit;
    font-size: var(--type-body-size);
    font-weight: 500;
    /* Centred on its own box rather than by text-align on the parent, so nothing
       inherits into the cards above. */
    text-align: center;
}

.v2-collection-status {
    color: var(--text-label);
    /* Optically level with the button's label: the button gets its height from
       padding, this has none, so without this the text sits higher in the slot
       than the label it alternates with. */
    padding: 10px 0;
}

.v2-collection-status--error { color: var(--intent-danger-text); }

/* ── STALE: SOMEONE ELSE'S CONTENT, HELD IN PLACE ────────────────────────
   Set while a panel changes SUBJECT — another patient, another order — and
   lifted when the new one lands (record.js for the record, collection.js for a
   list; patient-overview.js decides when its orders list is stale).

   visibility, not display: the boxes keep their size, so the frame does not
   collapse and grow back while the request is out, and nothing below it moves.
   Hidden rather than dimmed because the old content is the wrong patient's —
   readable for 200ms is still readable. visibility:hidden also takes it out of
   focus and pointer reach, so the old head buttons cannot act on the old record. */
[data-v2-record-stale],
[data-v2-panel]:has([data-v2-record-stale]) :is(.v2-panel-head-aside, .v2-panel-sub),
[data-v2-panel]:has([data-v2-collection-stale]) :is(
    [data-v2-collection], [data-v2-collection-status],
    [data-v2-collection-more], [data-v2-collection-count]) {
    visibility: hidden;
}

/* display:block is what lets the auto margins centre it — .v2-button is
   inline-flex. The :not([hidden]) scoping this carried is gone — base.css now
   makes [hidden] win globally with !important, which is where that belonged. */
.v2-collection-more {
    display: block;
    padding: 10px var(--space-6);
    line-height: 1.2;
    border: 0;
    border-radius: var(--radius-pill);
    /* The outline treatment, not a fill: appending the next page is a secondary
       act on a surface whose primary actions are the cards' own View and Order
       buttons. A filled pill here would out-shout every card on the panel. */
    background-color: transparent;
    color: var(--text-body);
    box-shadow: inset 0 0 0 1px var(--border-strong);
    cursor: pointer;
    transition: background-color 120ms ease;
}

.v2-collection-more:hover:not([disabled]) { background-color: var(--surface-hover); }
.v2-collection-more[disabled] { cursor: progress; }

/* ── List view ───────────────────────────────────────────────────────────
   NOT IN THE FILE — CONFIRMED, not assumed. The My Patients page (1362:5312) was
   searched end to end: ViewSlider appears twice, and every card container on it is
   MyPatients_Cards_Light, the grid. There is no list frame in either theme.

   So this layout is mine, and under the standing rule that means it is provisional
   and needs either a frame or Daniel's word. What is fixed here is only the thing he
   reported (18-Aug): "the Bin spans and those action icons are smaller than the row
   containers? soo much wasted space?"

   THE BUG WAS A COLUMN COUNT. .v2-record-head is display:contents, which promotes
   its two children into the card's own grid — so a row has FOUR items (identity,
   delete, details, actions) and I gave it three columns. The fourth wrapped onto a
   second line, which is where the stranded bin and the empty band came from.

   Four columns now, and `order` puts the bin last where it belongs without moving it
   in the DOM (it stays beside the name it labels, so the accessible order is
   unchanged). Everything reuses .v2-record-* — no second card. */
.v2-record-grid[data-view="list"] {
    grid-template-columns: minmax(0, 1fr);
    gap: var(--space-2);
}

.v2-record-grid[data-view="list"] .v2-record-card {
    display: grid;
    grid-template-columns: minmax(150px, 1.1fr) minmax(0, 2.6fr) auto auto;
    gap: var(--space-3);
    align-items: center;
    /* Tighter than a tile: a row's height should be its content, not a card's
       padding. 8px/16px against the grid card's 16px/8px. */
    padding: var(--space-2) var(--space-4);
}

.v2-record-grid[data-view="list"] .v2-record-head { display: contents; }

.v2-record-grid[data-view="list"] .v2-record-ident { min-width: 0; }

/* The cells run across instead of stacking, and stop being full-height blocks —
   in a row they only need to be as tall as the two lines they hold. */
.v2-record-grid[data-view="list"] .v2-record-details {
    flex-direction: row;
    min-width: 0;
    gap: var(--space-2);
}

.v2-record-grid[data-view="list"] .v2-record-row {
    flex: 1 1 0;
    min-width: 0;
}

.v2-record-grid[data-view="list"] .v2-record-actions { flex: 0 0 auto; }

/* order:1 sends the bin to the last column. It stays next to the name in the
   markup, so a screen reader still hears "Delete Janet Turner" with her name. */
/* The head's TRAILING item goes last, whichever card this is — the bin on a
   patient, the status tag on an order. Both are the fourth grid item and both
   must land in the last `auto` column; without this the tag inherits the 2.6fr
   details column and the details themselves shrink-wrap into `auto`, which
   reads as a broken row rather than a dense one.

   `order` rather than moving it in the DOM: the tag stays beside the name it
   describes, so the reading order is unchanged for a screen reader. */
.v2-record-grid[data-view="list"] .v2-patient-delete { order: 1; }

/* ── An ORDER row: the status travels with the name ──────────────────────
   Daniel, 2-Sep: "i dont like how in the row version the columns misallign …
   id prefer the view to be the last column and the status to be attched
   somewhere and not cause misallignment."

   THE MISALIGNMENT WAS THE TAG BEING A COLUMN. Promoted into the card's grid by
   display:contents, it took the trailing `auto` track — and `auto` sizes to
   content, so a row ending in "Awaiting Stock" gave every cell to its left less
   width than a row ending in "Pending". Nothing lined up down the list, and the
   wider the status the worse it got: "Part Prescription Received" would have
   thrown the row out completely.

   So the tag stops being a grid item. The head keeps its own flex layout and
   holds the name and the status together in the FIRST column — which is also
   where the grid card puts them (they share .v2-record-head there too), so the
   two views now describe an order the same way. Three columns, all of them
   fr-or-auto by their own content rather than by the status text, and View is
   last as asked.

   :has() rather than an .v2-order-card class: the difference really is "this
   card carries a status tag", and the codebase already relies on :has() for
   .v2-select's placeholder state. */
.v2-record-grid[data-view="list"] .v2-record-card:has(.v2-tag) {
    grid-template-columns: minmax(220px, 1.3fr) minmax(0, 2.6fr) auto;
}

.v2-record-grid[data-view="list"] .v2-record-head:has(.v2-tag) {
    display: flex;
    align-items: center;
    gap: var(--space-2);
    min-width: 0;
}

/* One line in a row, whatever the label. The tag wraps in the grid card, where
   the card is 270px wide and has the height to spare; in a row a second line
   would make one row taller than its neighbours, which is the same class of
   problem as the misalignment. The name ellipses instead — it has
   min-width: 0 from .v2-record-ident. */
.v2-record-grid[data-view="list"] .v2-tag { white-space: nowrap; }

/* ── Mobile is GRID ONLY ──────────────────────────────────────────────────
   Daniel, 18-Aug: "should hide the grid selector from mobile view by the way - grid
   only." The toggle goes, and the grid ignores a remembered `list` choice — otherwise
   someone who picked list on a desktop would arrive on their phone in a layout with no
   control to leave it by.

   1024px, the same breakpoint the panel already uses to drop the side nav, rather than
   the 900px this block used to carry. One boundary: below it there is no toggle AND no
   list layout, so the two can't disagree about which side of 900–1024 they're on. */
@media (max-width: 1024px) {

    .v2-view-toggle { display: none; }
}

/* ── Phone: the search stacks ─────────────────────────────────────────────
   Daniel, 18-Aug: "the search botton for the search on mobile should stack not be to
   the left." Right — the 140px button was taking its width off a field that had none
   to give, so the placeholder truncated to "Search by name, NHS nu".

   640px, the project's existing phone breakpoint, not the 1024 above: at tablet width
   the panel is still wide enough for the pair to sit side by side, and stacking there
   would waste a row. The two rules want different boundaries because they answer
   different questions — is there room for a toggle, and is there room for two controls
   on one line. */
@media (max-width: 640px) {

    /* NO CONTAINER ON A PHONE. Daniel, 18-Aug: "on mobile the search bar and button
       arent in a container on spec?" — the mobile frame puts the field and the button
       straight onto the panel, where the desktop frame wraps them in a Pebble card.
       Which makes sense of the width it has: at 340px of usable panel, a card's 10px
       of padding and its own surface are competing with the control inside it, and the
       panel is already a surface. */
    .v2-search {
        flex-direction: column;
        align-items: stretch;
        padding: 0;
        background-color: transparent;
        border-radius: 0;
    }

    /* Both full width once stacked. The button drops its 140px floor, which only
       existed to hold the frame's fixed width beside the field. */
    .v2-search .v2-field,
    .v2-search .v2-button {
        flex: 0 0 auto;
        width: 100%;
        min-width: 0;
    }

    /* ── A phone search row that has a clinic icon in it ─────────────────
       THE ICON BELONGS BESIDE THE FIELD IT FILTERS. Daniel, 2-Sep: "the icon
       flexes to the below line when it should be to the right of the search box,
       not to the left of the search button."

       My first pass gave the field the whole first line, which pushed the icon
       down to share the second with Search — so it sat at the far LEFT of the
       row, reading as a partner to the button rather than to the field, and
       opening its list off the left edge of the screen. Both wrong for the same
       reason: it had been separated from the thing it modifies.

       So the field and the icon share line one, and Search takes line two on its
       own. `width: 100%` on the button is what forces that break — it cannot
       share a line, so it wraps without needing a media query of its own.

       DIRECT-CHILD SELECTORS throughout, and they are not cosmetic: the clinic
       filter contains a .v2-field of its own (the <select>), so a descendant
       selector here would size that too and fight the rules above it. `>` hits
       only the panel's own search input.

       Scoped with :has() so My Patients, which has no clinic filter, keeps
       exactly the stacked layout Daniel signed off on 18-Aug. */
    .v2-search:has(.v2-clinic-filter) {
        flex-direction: row;
        flex-wrap: wrap;
        align-items: center;
    }

    /* THE SAME HEIGHT AS EVERY OTHER CONTROL. Daniel, 2-Sep: "on mobile the
       search bar is taller, causing the icon to stretch. i want it to be the
       same so the search bar isnt taller? ?? whats the cost?"

       The cost is nothing, which is the short answer. --control-h-md (36px) is
       already what every v2 field and button is at every width: `.v2-field` is
       min-height 36 throughout, and --control-h-lg (44px, "touch target — mobile
       only") is declared in scales.css and used by NOTHING. So the search field
       running a couple of pixels taller on a phone was the anomaly, not the
       standard, and pinning it changes no policy — it just stops one control
       drifting from the rest.

       The honest caveat, since it was asked: 36px is under the ~44px touch
       target the WCAG 2.2 guidance suggests. But that is already true of every
       field in v2, so this makes nothing worse — it is one item for the
       accessibility pass, not a decision being taken here. See the note on
       .v2-combo-button's 44px, which is the only control that DOES grow on
       touch and is therefore the odd one out. */
    /* Takes the room the icon does not, and gives it back when the icon widens
       on open.

       flex-basis 0, NOT auto, and that is the whole of the last mobile bug
       (Daniel, 2-Sep: "when clicked. it opens the search box onto the next row
       instead of taking up the standard search box space on the same row").
       This is the wrap-beats-shrink trap for the third time in this panel, and
       it is worth naming for good: with flex-wrap on, an item is placed using
       its flex BASE size, never its shrunk size. At `auto` the basis is an
       input's intrinsic width — around 180px — so once the clinic cell opened to
       240 the line needed 428px, exceeded the panel, and the cell wrapped to a
       row of its own rather than the field simply narrowing.

       At 0 the field asks for nothing and takes what is left, so the line is
       always 240 + a gap wide at most and the two stay together: closed, the
       field fills the row beside a 36px icon; open, it shrinks to about 120px
       and the clinic search sits where the search box was. Which is what the
       tablet already does, since that row does not wrap at all. */
    /* NO HEIGHT HERE. I pinned this field to 36px to stop it being taller than
       the clinic icon, told Daniel "the cost is nothing", and was wrong on both
       counts — he found the rule I had missed:

           overlays.css, @media (max-width: 640px)
           .v2-field, .v2-lookup-form .v2-button { min-height: 44px; }
           "Touch target: §6's 36px control height is a desktop value, and the
            mobile frames use 44px throughout (1300:4964, 1300:4992)."

       So 44px on a phone is THE FRAME'S OWN VALUE, not an anomaly, and my claim
       that every v2 control is 36px everywhere was false. min-height beats
       height, so the pin never took effect anyway — which is why the field never
       changed.

       WHY I MISSED IT, since the method is the fixable part: I grepped
       components.css for "min-height: 44px" (it is in overlays.css), and then
       grepped for lines containing both "v2-field" and "height" — which cannot
       match a rule whose selector and declaration are on separate lines. A
       multi-line rule defeats a single-line grep. Search for the SELECTOR alone
       and read the block.

       DANIEL'S CALL IS 36, and I put the alternative to him: match at 44 (the
       frame's mobile value) or bring the field down to 36. "no the field should
       go down to 36px" — so the field comes down, and the standing rule covers
       it exactly: Figma is the rule, or Daniel is.

       min-height, not just height, and that is the actual bug behind three
       rounds of this. The overriding rule is a MIN-height, which always wins
       over a height whatever the specificity — so my `height: 36px` was never
       going to do anything, and the field stayed 44 while I looked for a rule
       that was setting a bigger height. There wasn't one.

       SCOPED TO THIS ROW, not to .v2-field. Overriding the global would take
       every field in every v2 mobile form off the frame's 44px touch target on
       the strength of one control's alignment, which is not what was asked.

       KNOWN CONSEQUENCE, flagged rather than hidden: My Patients' search field
       is still 44px on a phone, so the two list panels' search bars now differ
       there. If they should agree, that is the global change — and a deliberate
       deviation from 1300:4964 / 1300:4992 for every form, so Daniel's call
       again rather than a tidy-up. */
    .v2-search:has(.v2-clinic-filter) > .v2-field {
        flex: 1 1 0;
        min-width: 0;
        min-height: var(--control-h-md);
        height: var(--control-h-md);
    }

    /* Its own line, under both. */
    .v2-search:has(.v2-clinic-filter) > .v2-button {
        flex: 0 0 auto;
        width: 100%;
    }
}

@media (max-width: 1024px) {
    .v2-record-grid[data-view="list"] .v2-record-card {
        grid-template-columns: minmax(0, 1fr);
        align-items: stretch;
        padding: var(--space-4) var(--space-2);
    }

    .v2-record-grid[data-view="list"] .v2-record-head { display: flex; }
    .v2-record-grid[data-view="list"] .v2-record-details { flex-direction: column; }
    .v2-record-grid[data-view="list"] .v2-patient-delete { order: 0; }
    /* The row collapses to a single column here, so the order card's three-column
       template has nothing to do — and the tag is already in the head. */
    .v2-record-grid[data-view="list"] .v2-record-card:has(.v2-tag) {
        grid-template-columns: minmax(0, 1fr);
    }
}


/* Tone variants for a runtime field note. Semantic colour only — the note's
   layout is set where .v2-field-note is defined. `warn` is a caution the nurse
   should resolve; `info` reports a fact that is not a problem (a patient at
   another clinic is importable BY DESIGN, not an error to be scolded for). */
.v2-field-note--warn { color: var(--intent-danger-text); }
.v2-field-note--info { color: var(--text-link); }


/* ── Validation, themed ───────────────────────────────────────────────────
   v2 owns its validation (validate.js), so a failure is a CLASS and nothing
   more — no forced styles to outrank, and every value below comes from the
   same tokens as the rest of the form.

   Daniel, 25-Aug: keep the red, but make it "an extending bed above or below
   the field itself" rather than a bubble floating beside it. So it sits IN
   FLOW under the field's container — which also means it scrolls with the
   panel, where an absolutely-positioned bubble would stay put while its field
   moved away from it. */
/* THE FADE BANDS. .v2-panel-head::before/::after are pinned 64px below the head,
   over the top of the scroll area — so anything scrolled hard against the top of
   .v2-panel-body arrives underneath a gradient. block:'center' usually keeps
   clear of it, but a failing field in the last screenful of a long form cannot be
   centred: the scroller runs out first and it lands wherever it lands. This is
   what scroll-margin is for, and it costs nothing when the target is centred. */
.v2-form-field,
.v2-question-row,
.v2-consent {
    scroll-margin-top: 72px;      /* the 64px band, plus a little air */
    scroll-margin-bottom: var(--space-6);
}

.v2-field-error {
    /* -7px closes .v2-form-field's own grid gap so the bed BUTTS the input
       rather than floating below it. The two then read as one control that has
       grown, which is the whole idea — a gap here and it is just a red box
       nearby. Tied to that gap: if .v2-form-field's changes, this follows. */
    /* CONSTANT — and the bed is never created or destroyed. One element per
       field is rendered by the PHP and simply collapses; see validate.js.
       -------------------------------------------------------------------
       Animating this margin was tried and made things worse (Daniel, 25-Aug):
       the bed slid upward as it grew, which is more movement, not less. And
       creating/removing the element caused a 7px jump at the end, because a
       zero-height bed with a live -7px still pulls the content below it up,
       until removal takes the margin away too.

       Present-and-collapsed costs nothing, and the arithmetic is why. The cell
       is a grid with a 7px gap, so with the bed in place but closed:

           input --gap 7--> bed(margin -7, height 0) --gap 7--> help  =  7px

       exactly what `input --gap 7--> help` would be on its own. So the layout
       is identical whether a field has ever errored or not, and opening only
       grows the row — the cell expands to fit and contracts back, with nothing
       entering or leaving the layout to shift anything. */
    margin: -7px 0 0;
    /* No padding here — it lives on the inner span so it is CLIPPED by the
       collapsing track rather than animated alongside it. Padding on the
       container would keep the box 16px tall at 0fr and stop it closing. */
    /* Tucked under the field and revealed downward, so it reads as the field
       growing rather than as a separate thing arriving. */
    border-radius: 0 0 var(--radius-button) var(--radius-button);
    background: var(--intent-danger);
    color: var(--text-on-accent);
    font-size: var(--type-caption-size);
    font-weight: 600;
    line-height: 1.4;
    /* HEIGHT IS ANIMATED FOR REAL, via grid-template-rows 0fr -> 1fr.
       -------------------------------------------------------------------
       This was max-height, and that is why it moved in two stages (Daniel,
       25-Aug: "seems to go in 2 stages and looks glitchy"). A max-height
       transition runs over the DECLARED value, not the real one: with
       max-height 6em on ~2.5em of text, the first ~60% of the animation
       changes nothing visible and the rest collapses at speed. The curve is
       correct and the motion is not.

       0fr -> 1fr interpolates the track against the CONTENT's own height, so
       one property carries the whole movement at a constant rate. Padding
       moves to the inner span, which means it is clipped rather than
       animated — one timing instead of three, which is the other half of why
       this felt disjointed. */
    display: grid;
    grid-template-rows: 0fr;
    opacity: 0;
    /* ONE MOVEMENT. Both properties, same duration, same easing, no delay, and
       identical in both directions.

       This previously staggered them on purpose — opacity delayed 60ms opening
       and finishing early closing — on the reasoning that the space should open
       before the text arrives. Daniel read it as glitchy twice (25-Aug), and he
       is right: two timings on one element is legible as two events, however
       well-intentioned the offset. The tidy asymmetry mattered less than the
       thing feeling like a single gesture. */
    transition: grid-template-rows 180ms ease, opacity 180ms ease;
}

.v2-field-error.is-shown {
    grid-template-rows: 1fr;
    opacity: 1;
}

/* The clipped row. min-height:0 is required — a grid item's default
   min-content floor would refuse to shrink below the text and the track would
   never reach 0fr. */
.v2-field-error > span {
    overflow: hidden;
    min-height: 0;
    padding: var(--space-2) var(--space-3);
}

/* Classic draws a little arrow on the bubble. In flow it has nothing to point
   at, so it goes. */

/* The field itself keeps the red edge, from tokens rather than Bootstrap's
   #dc3545, and loses its bottom rounding so the bed reads as continuous. */
.v2-form .v2-field.is-invalid,
.v2-form .v2-select.is-invalid {
    border-color: var(--intent-danger);
}

/* ── THE SQUARE BOTTOM BELONGS TO THE BED, SO IT ONLY APPEARS WITH ONE ───────
   Daniel, 28-Sep, on lead capture: "the fields in lead capture also break and
   look weird (because of the expanding bed used in new patient i imagine? but
   because those beds dont show here it deforms the fields."

   Exactly that. The field drops its bottom rounding so the red bed underneath
   reads as one continuous shape with it — which is right on new-patient, where
   validate.js renders a bed in every cell. Lead capture and registration show
   their message as a plain line of text with NO bed, so the rule left a field
   with square bottom corners hanging over nothing.

   Keyed on a SHOWN bed in the same cell, not on the field being invalid. That
   also fixes a quieter case on new-patient itself: the bed auto-dismisses after
   eight seconds while the field stays invalid, and until now left the same
   square-bottomed field behind with nothing under it.

   A browser without :has() gets full rounding throughout, which is the right
   failure — a slightly less continuous bed, never a deformed field. */
.v2-form .v2-form-field:has(.v2-field-error.is-shown) .v2-field.is-invalid,
.v2-form .v2-form-field:has(.v2-field-error.is-shown) .v2-select.is-invalid {
    border-radius: var(--radius-button) var(--radius-button) 0 0;
}


/* ── Field help, behind an (i) ─────────────────────────────────────────────
   Daniel, 25-Aug: put the helpers behind an (i) on desktop and drop them on
   mobile. A sentence under every input is a paragraph of instructions down a
   thirty-field form — it competes with the labels and ends up read by nobody.

   NO JAVASCRIPT. :hover and :focus-visible on the button reveal the sibling
   text, so there is no state to get out of step and nothing to initialise. */
.v2-label-row {
    display: flex;
    align-items: center;
    gap: 6px;
    min-width: 0;
}

/* The label keeps its own truncation behaviour; the icon must not be squeezed. */
.v2-label-row .v2-label { min-width: 0; }
.v2-field-info-wrap { position: relative; flex: none; display: inline-flex; }

.v2-field-info {
    width: 14px;
    height: 14px;
    padding: 0;
    border: 1px solid var(--border-medium);
    border-radius: 50%;
    background: none;
    color: var(--text-muted);
    font-size: 10px;
    font-weight: 700;
    font-family: inherit;
    line-height: 1;
    cursor: help;
    display: inline-flex;
    align-items: center;
    justify-content: center;
}

.v2-field-info:hover,
.v2-field-info:focus-visible {
    border-color: var(--text-link);
    color: var(--text-link);
}

/* The popover. Hidden by visibility rather than display so it can transition,
   and so the text stays in the accessibility tree — aria-describedby on the
   control means a screen reader reads it whether or not it is on screen. */
.v2-field-help {
    position: absolute;
    top: calc(100% + 6px);
    left: -8px;
    z-index: 5;
    width: max-content;
    max-width: 260px;
    margin: 0;
    padding: var(--space-2) var(--space-3);
    background: var(--surface-raised);
    border: 1px solid var(--border-subtle);
    border-radius: var(--radius-button-sm);
    box-shadow: 0 8px 24px rgba(0, 0, 0, 0.18);
    color: var(--text-body);
    font-size: var(--type-caption-size);
    font-weight: 500;
    line-height: 1.4;
    /* Normal weight and left-aligned: this is a sentence to read, not a label. */
    text-align: left;
    opacity: 0;
    visibility: hidden;
    transition: opacity 120ms ease, visibility 120ms;
}

.v2-field-info:hover + .v2-field-help,
.v2-field-info:focus-visible + .v2-field-help,
.v2-field-help:hover {
    opacity: 1;
    visibility: visible;
}

/* MOBILE: the icon goes, and the text with it — visually. It stays in the DOM
   and stays referenced by aria-describedby, so a screen reader on a phone still
   gets it. Dropping the help is a layout decision, not an accessibility one. */
@media (max-width: 640px) {
    .v2-field-info-wrap { display: none; }
}


/* ── Patient consent ──────────────────────────────────────────────────────
   The tick, what it commits to, and where the full basis lives. Expandable
   rather than hidden (Daniel, 25-Aug: "consent information needs to be
   included. even if in an expandable form... along with the privacy policy").

   <details> natively, so the disclosure needs no script and no state class —
   keyboard, screen reader and the open/closed marker all come free. */
.v2-consent {
    display: grid;
    gap: var(--space-3);
    /* The text column of .v2-check — its 20px control plus the gap beside it.
       Everything below the tick lines up with the WORDS rather than the box
       (Daniel, 25-Aug), and derived from the same token as the gap so the two
       cannot drift apart if the checkbox is ever resized. */
    --consent-indent: calc(20px + var(--space-3));
}

/* Indented to the text column. The native marker sits OUTSIDE the padding, so it
   lands in the checkbox's column while the words line up with the words above —
   the triangle reads as the control, the text as the label. */
.v2-consent-detail {
    padding-left: var(--consent-indent);
}

.v2-consent-detail > summary {
    cursor: pointer;
    color: var(--text-link);
    font-size: var(--type-caption-size);
    font-weight: 600;
    /* The default triangle is the platform's, and it is the one affordance here
       worth keeping — it says "there is more" without a word of copy. */
    list-style-position: outside;
}

.v2-consent-detail > summary:focus-visible {
    outline: 2px solid var(--border-focus);
    outline-offset: 2px;
    border-radius: 2px;
}

/* Reading matter, not UI: normal weight, a measure that does not run the width
   of the panel, and room between paragraphs. */
.v2-consent-body {
    margin-top: var(--space-3);
    max-width: 68ch;
    color: var(--text-body);
    font-size: var(--type-caption-size);
    font-weight: 400;
    line-height: 1.55;
}

.v2-consent-body p { margin: 0 0 var(--space-2); }
.v2-consent-body p:last-child { margin-bottom: 0; }

.v2-consent-body ol {
    margin: 0 0 var(--space-2);
    padding-left: 1.4em;
}

.v2-consent-body li { margin-bottom: 4px; }

.v2-consent-policy {
    margin: 0;
    /* Same column as the disclosure above it and the label above that, so the
       three read as one stack rather than three separately-placed things. */
    padding-left: var(--consent-indent);
    font-size: var(--type-caption-size);
    font-weight: 500;
}

.v2-consent-policy a { color: var(--text-link); }


/* ── Form submit ──────────────────────────────────────────────────────────── */
/* CENTRED, and stacked rather than side by side. The button was left-aligned
   with the status line beside it, which read as one more field in a form of
   forty rather than the end of it. Centred it is unmistakably the action, and
   putting the message underneath means a long message no longer shoves the
   button sideways as it appears. */
.v2-form-submit {
    display: flex;
    flex-direction: column;
    align-items: center;
    gap: var(--space-3);
    margin-bottom: var(--space-8);   /* room below, so the button is not the
                                        last thing against the panel edge */
}

/* Wide enough that the two states are the same size — a button that grew as it
   became available would move the ground under the pointer. */
.v2-form-submit .v2-button {
    min-width: 220px;
    transition: background-color 120ms ease, color 120ms ease, box-shadow 120ms ease;
}

.v2-form-submit-note {
    margin: 0;
    text-align: center;
    font-size: var(--type-caption-size);
    font-weight: 500;
    color: var(--text-muted);
}

.v2-form-submit-note--error { color: var(--intent-danger-text); }

/* Consent unticked on submit. The card is marked rather than the tiny control —
   a 20px checkbox with a red outline is easy to miss on a form this long, and
   the whole block is what has not been agreed to. */
.v2-consent.is-invalid {
    border: 1px solid var(--intent-danger);
}

/* NOT READY: every rule has not yet passed, or consent is not ticked.
   Daniel, 25-Aug: "maybe goes live when all validation is passed and consent is
   checked?"

   An outline rather than a wash. The note on .v2-button[disabled] above says a
   genuine unavailable treatment is not in the frames and should not be invented
   as a side effect — this is the case it anticipated, so it is deliberate and
   one for Justin to confirm rather than a placeholder:

     * NOT an opacity fade. That is the exact thing that dimmed the Download
       button to 1.5:1 and had to be torn out.
     * NOT `disabled`. The button stays clickable, because clicking it is how
       you find out what is missing.
     * NOT colour alone. Outline versus fill is a change of form, so the state
       survives greyscale and colour blindness (WCAG 1.4.1).

   The label keeps --text-body, so it measures the same as body copy either way
   and nothing about this state is harder to read. Measured on --surface-page:

     LIGHT   outline Flint 300 on Seafoam 200   3.74:1  (1.4.11 needs 3:1)
             label   Flint 500 on Seafoam 200   8.48:1  (needs 4.5:1)
     DARK    outline Pebble 400 on Flint 500    3.51:1
             label   Seafoam 200 on Flint 500   8.48:1
     hover   label on --surface-raised          9.23:1 light, 5.89:1 dark */
.v2-form-submit[data-v2-submit-ready="no"] .v2-button {
    background-color: transparent;
    color: var(--text-body);
    box-shadow: inset 0 0 0 1px var(--border-strong);
}

.v2-form-submit[data-v2-submit-ready="no"] .v2-button:hover {
    background-color: var(--surface-raised);
}

/* Nobody should be watching the button pulse as they type. */
@media (prefers-reduced-motion: reduce) {
    .v2-form-submit .v2-button { transition: none; }
}


/* ══════════════════════════════════════════════════════════════════════════
   THE COMBO — a dropdown whose list we can theme

   The note above .v2-select explains why the native option list could never be
   made to work: it is painted by the PLATFORM, on the platform's own background,
   so a colour that fixed light mode broke dark. select.js replaces the view and
   keeps the <select> as the model; this is what the view looks like.

   The BUTTON deliberately carries .v2-field as well, so it inherits the field's
   metrics, border, fill and pinned colour rather than restating them. Anything
   here is only what a button needs and an input does not.
   ══════════════════════════════════════════════════════════════════════════ */

/* Enhanced away. display:none rather than aria-hidden + tabindex: a hidden
   control still POSTS its value, and it leaves the accessibility tree and the tab
   order on its own — no chance of a keyboard landing on something a screen reader
   has been told is not there. */
.v2-select.is-enhanced { display: none; }

.v2-combo {
    position: relative;
    /* The field grid gives cells a min-width of 0; without this the absolutely
       positioned list would size to the longest option and push nothing, but the
       BUTTON would still shrink-wrap. */
    width: 100%;
}

.v2-combo-button {
    display: flex;
    align-items: center;
    /* Room for the chevron, matching .v2-select's own 44px. */
    padding-right: 44px;
    text-align: left;
    cursor: pointer;

    /* The same chevron as the native control, so nothing shifts when a page
       renders before select.js has run. */
    background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%230A5E86' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M6 9l6 6 6-6'/%3E%3C/svg%3E");
    background-repeat: no-repeat;
    background-position: right 16px center;
    background-size: 16px 16px;
}

/* Nothing chosen yet. Reproduces .v2-select:has(option[value=""]:checked), which
   is the state Daniel flagged on 25-Aug: the placeholder SHOULD look faded, and
   the options in the list should not. Here they cannot — the fade is on the
   button, and the list is a different element. */
.v2-combo-button.is-placeholder .v2-combo-value { color: var(--colour-flint-200); }

/* ── The search box a combo can turn into while open ─────────────────────
   Only exists for a <select> carrying data-v2-select-search. Daniel, 2-Sep:
   "can the clinic bar when opened turn into a search bar too to filter the drop
   down results?" — so it is the same bar, not a second control appearing above
   it, which is why it is positioned OVER the button rather than inserted into
   the layout. Nothing moves when it appears.

   inset: 0 against .v2-combo works because the button is the combo's only
   in-flow child, so the combo's box IS the button's box. The list is absolutely
   positioned below and is never covered.

   It takes .v2-field in markup, so the border, radius, background and type all
   come from the field it is standing in for — the only differences are the ones
   that make it a text box: no chevron, and room where the chevron was so a long
   clinic name doesn't run under it. */
.v2-combo-search {
    position: absolute;
    inset: 0;
    z-index: 1;                        /* above the button; the list's 20 is
                                          higher again, and they don't overlap */
    width: 100%;
    padding-right: 44px;               /* the chevron's space, kept so the text
                                          doesn't sit under it */
    background-image: none;            /* .v2-field draws a chevron for selects */
}

.v2-combo-value {
    flex: 1 1 auto;
    min-width: 0;
    overflow: hidden;
    white-space: nowrap;
    /* An exemption option is a full sentence and WILL overflow a half-width
       field. An ellipsis reads as "there is more"; a mid-word cut reads as
       broken. The list shows every option in full, so nothing is lost. */
    text-overflow: ellipsis;
}

.v2-combo-button:focus-visible {
    outline: 2px solid var(--border-focus);
    outline-offset: 2px;
}

/* The invalid and locked states are mirrored onto the button by select.js,
   because everything that sets them sets them on the <select>. */
.v2-form .v2-combo-button.is-invalid {
    border-color: var(--intent-danger);
    border-radius: var(--radius-button) var(--radius-button) 0 0;
}

.v2-combo-button.is-locked[disabled] {
    background-color: var(--colour-seafoam-200);
    color: var(--colour-flint-500);
    -webkit-text-fill-color: var(--colour-flint-500);
    opacity: 1;
    background-image: none;
    padding-right: var(--space-4);
}

/* ── The list, at last ours ─────────────────────────────────────────────── */

/* Must match OPEN_MS and MAX_LIST_H in select.js. The height is MEASURED there
   and animated here, so a disagreement shows as a list that jumps at the end of
   its own animation. */
:root { --combo-open: 180ms; }

.v2-combo-list {
    position: absolute;
    z-index: 20;
    top: calc(100% + 4px);
    left: 0;
    right: 0;
    margin: 0;
    padding: var(--space-2);
    list-style: none;
    max-height: 240px;                 /* = MAX_LIST_H; select.js flips upward past this */
    overflow-y: auto;

    /* IT COMES OUT OF THE FIELD AND GOES BACK INTO IT (Daniel, 26-Aug: "drop
       downs overlaying but still expanding from the field and closing back into
       the field on close? again its that fluidity").

       HEIGHT, and a measured pixel one — never max-height. That lesson is
       already written down against the validation bed: a transition on
       max-height runs over the DECLARED value, so it starts instantly and
       finishes early, which reads as a jump rather than a fold. select.js
       measures the list's natural height on open and animates to that number.

       And not transform: scaleY either, which is the other obvious way — it
       would squash the option text on the way past.

       Anchored top by default so it grows DOWN out of the field; .is-above
       re-anchors to the bottom, and growing the height then moves the top edge,
       so it still comes out of the field rather than dropping onto it. */
    /* HEIGHT ALONE IS NOT ENOUGH, and this is the bed's bug arriving here
       exactly as its note warns: "Padding on the container would keep the box
       16px tall at 0fr and stop it closing." With box-sizing: border-box, this
       list's 8px padding and 1px border give it an 18px FLOOR — height: 0 got
       it down to an 18px sliver and `hidden` then snapped that away. That snap
       is the glitch (Daniel, 26-Aug).

       The bed could move its padding to an inner span and let the collapsing
       track clip it. This cannot: an extra element inside role="listbox" would
       break the listbox-to-option relationship, which is not a trade worth
       making for an animation. So the padding and the border COLLAPSE WITH the
       height instead — same duration, same curve, so they read as one movement.

       Opacity is on a shorter clock on purpose: the last sliver is gone before
       the height lands, which is what stops the final pixel reading as a
       flicker. */
    transition: height          var(--combo-open) var(--modal-ease, ease),
                padding-block   var(--combo-open) var(--modal-ease, ease),
                border-width    var(--combo-open) var(--modal-ease, ease),
                opacity calc(var(--combo-open) * 0.6) ease;
    border-radius: var(--radius-button);
    /* PINNED WHITE, like the field it belongs to. The list is an extension of the
       control, and a white field opening a dark list would read as two different
       components. */
    background-color: #fff;
    border: 1px solid var(--colour-pebble-200);
    box-shadow: 0 12px 28px rgba(0, 0, 0, 0.18);
}

/* ── NO SCROLLBAR WHILE IT IS STILL OPENING ────────────────────────────────
   Daniel, 20-Sep: "the drop downs trigger the scroll bar while its expanding.
   then it goes when its not needed. could we suppress it until drop down as
   fully expanded then it will only show if actually needed?"

   Of course it did: the list animates from a height of 0 to its measured one,
   and for every frame before the last its content is taller than it is. So
   overflow-y: auto had a bar to draw the whole way up, on a list that needs
   none once it lands.

   AN INLINE HEIGHT MEANS MID-ANIMATION, and that is select.js's own signal
   rather than a guess: it writes list.style.height to run the transition and
   CLEARS it when the open lands — "handed back to CSS once it is open, so a
   list whose content changes while open is not pinned to a stale number".
   height is the only inline style it ever sets on the list, so its presence is
   exactly the state being described.

   Once it is cleared the list is at its natural height, capped by max-height
   above, and the bar appears if and only if it is really needed. */
.v2-combo-list[style*="height"] { overflow-y: hidden; }

.v2-combo-list[hidden] { display: none; }

/* ── "No results found" ─────────────────────────────────────────────────────
   Daniel, 16-Sep: "when nothing found i get this little squashed box thing here
   its unusual? wouldnt a No results found be better (unselectable ofcourse)".

   With every option hidden the list collapsed to its own padding — a two-pixel
   white sliver, which reads as something broken rather than as an answer. The
   row gives the box a height and the nurse a sentence.

   Styled deliberately UNLIKE an option: muted, italic, no hover, no pointer. It
   is not selectable and it should not look like it is — and it is aria-hidden
   with no role, so the keyboard walk cannot reach it either. */
.v2-combo-none {
    padding: var(--space-2) var(--space-3);
    color: var(--text-muted);
    font-size: 0.8125rem;
    font-style: italic;
    cursor: default;
    user-select: none;
}

.v2-combo-none[hidden] { display: none; }

/* AN <optgroup> HEADING — "Direct" / "Clinics" in the staff order panel's
   clinic picker (select.js render()). Not an option: no hover, no pointer,
   unselectable, and outside the keyboard walk. Ink PINNED to Flint 400 for the
   reason .v2-ro-option-meta gives: the list is white in both themes, and
   --text-muted is the dark theme's pale grey there. Flint 400 is ~6.4:1 on
   white. A group after the first is spaced off the option above it. */
.v2-combo-group {
    padding: var(--space-2) var(--space-3) var(--space-1);
    color: var(--colour-flint-400);
    font-size: 0.75rem;
    font-weight: 600;
    cursor: default;
    user-select: none;
}

.v2-combo-option + .v2-combo-group { margin-top: var(--space-2); }

.v2-combo-group[hidden] { display: none; }

/* THE COLLAPSED END OF THE ANIMATION. Everything that gives the box a size
   goes to zero together, so it really does close into the field rather than
   stopping 18px short of it. Toggled by select.js in the same frame as the
   height, which is what keeps them looking like one thing. */
.v2-combo-list.is-collapsed {
    padding-block: 0;
    border-top-width: 0;
    border-bottom-width: 0;
    opacity: 0;
}

.v2-combo-list { opacity: 1; }
.v2-combo-list { will-change: height; }

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

/* Opening upward when there is no room below. */
.v2-combo.is-above .v2-combo-list {
    top: auto;
    bottom: calc(100% + 4px);
}

.v2-combo-option {
    padding: var(--space-2) var(--space-3);
    border-radius: 8px;
    font-size: var(--type-body-size);
    font-weight: 500;
    /* Pinned for the same reason as the list's fill. */
    color: var(--colour-flint-500);
    cursor: pointer;
}

/* Where the keyboard or the pointer is. One state for both, so arrowing and
   hovering cannot disagree about what is about to be chosen. */
.v2-combo-option.is-active {
    background-color: var(--colour-seafoam-200);
}

/* What is chosen. Weight and a tick rather than colour alone (WCAG 1.4.1) —
   4.5:1 on Harbour is comfortable, but a selection people have to distinguish by
   hue is a selection some people cannot see. */
.v2-combo-option.is-selected {
    font-weight: 700;
    color: var(--brand-primary);
}

.v2-combo-option.is-selected::after {
    content: '';
    float: right;
    width: 14px;
    height: 14px;
    margin-left: var(--space-2);
    background-color: currentColor;
    -webkit-mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23000' stroke-width='3' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M20 6L9 17l-5-5'/%3E%3C/svg%3E");
            mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23000' stroke-width='3' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M20 6L9 17l-5-5'/%3E%3C/svg%3E");
    -webkit-mask-repeat: no-repeat;
            mask-repeat: no-repeat;
    -webkit-mask-size: contain;
            mask-size: contain;
}

/* ── AN OPTION THE SELECT SAYS IS DISABLED ──────────────────────────────────
   Shown, not omitted: a native select draws these too, and a gap in a list is a
   question while a greyed row is an answer. The RTEC exemption is the case this
   exists for — a value a clinician may KEEP but never assert, so it has to be
   visible as the current answer and unavailable as a new one.

   NO POINTER AFFORDANCE AND NO HIGHLIGHT. cursor: default because the row does
   not respond, and .is-active is refused outright in case a highlight ever
   reaches it by another route — a highlight is a promise that Enter will commit
   it, and here it will not.

   NOT `opacity`, which would fade the tick and the selected weight along with
   the text and leave the current value looking half-erased. Flint 200 is the
   same step the placeholder uses, which is the established "present but not a
   live choice" tone in this list. */
.v2-combo-option.is-disabled {
    color: var(--colour-flint-200);
    cursor: default;
}

.v2-combo-option.is-disabled.is-active {
    background-color: transparent;
}

/* The "nothing chosen" row. Faded like the placeholder it clears to, and kept
   selectable — clearing a choice is a legitimate thing to want. */
.v2-combo-option.is-placeholder { color: var(--colour-flint-200); }

/* AND IT NEVER SHOWS AS CHOSEN. When nothing has been picked the placeholder IS
   the selected option, so it was getting the tick and the bold brand colour —
   a tick against "Select Title" claims that is the answer, when it means there
   is no answer yet (visible in Daniel's screenshot, 26-Aug).

   aria-selected stays TRUE on it, because for the listbox it genuinely is the
   current option and a screen reader should say so. Only the visual claim is
   withdrawn — this is the one place where what the eye is told and what the
   accessibility tree is told should differ, and deliberately. */
.v2-combo-option.is-placeholder.is-selected {
    font-weight: 500;
    color: var(--colour-flint-200);
}

.v2-combo-option.is-placeholder.is-selected::after { content: none; }

/* THE SCROLLBAR IS THE PANELS' — WITH ITS COLOURS PINNED.
   -----------------------------------------------------------------------------
   The list takes .v2-scroll (see select.js), which brings the geometry the design
   asks for: 12px, no stepper arrows, an inset 6px thumb with a 1000px radius.
   Nothing about that needs restating.

   What DOES need restating is the two colours, and it is the same trap that made
   the locked exemption unreadable this morning: .v2-scroll paints its track with
   --surface-card and its thumb with --brand-secondary, both of which swap by
   theme — correct on a panel, which also swaps. This list is pinned #fff in BOTH
   themes, like the field it belongs to. Left alone, dark mode would draw a Flint
   600 track down the side of a white list, 11.6:1 against it, with a pale
   Rockpool 300 thumb chosen for a dark background it no longer has.

   So both are pinned to the light values, because the surface is pinned light.
   Measured: thumb #557A9E on track #EEEEEE is 3.88:1, over 1.4.11's 3:1 for a
   UI component, and 4.50:1 against the list fill itself.

   Firefox takes the standard properties instead — the note on .v2-scroll explains
   why the two can never be declared together, so this mirrors that gate. */
/* The trackless bar is now the GLOBAL treatment — see the note on
   ::-webkit-scrollbar-track above, which this list is what prompted. Nothing to
   override here any more; only the two COLOURS below, because this surface is
   pinned light while the shared ones follow the theme.

   The 12px gutter still comes out of the content box, deliberately: a genuinely
   overlaying scrollbar is not something CSS can ask for on the desktop, and
   reserving the space means the options never reflow when the list becomes
   scrollable. Floating is the LOOK; stable is the behaviour. */
.v2-combo-list::-webkit-scrollbar-thumb {
    background-color: var(--colour-rockpool-500);
}

@supports not selector(::-webkit-scrollbar) {
    .v2-combo-list {
        scrollbar-width: thin;
        /* Firefox takes the track colour as the second value; transparent is
           how it is told not to draw one. */
        scrollbar-color: var(--colour-rockpool-500) transparent;
    }
}

@media (max-width: 1024px) {
    /* The same 44px touch target the fields get — which is TRUE, and I briefly
       replaced this comment with a note claiming it wasn't. That note was wrong:
       overlays.css does give .v2-field a 44px min-height, but only below 640px,
       and I had searched components.css. Restored, with the two caveats that
       are actually worth knowing.

       ONE: the breakpoints differ. This is 1024, the fields' is 640, so between
       641 and 1024 a combo button IS taller than the text fields beside it.
       Deliberate or not, it is the one control that grows early.

       TWO: the fields' 44px comes from the mobile frames (1300:4964, 1300:4992)
       via §6; this 1024 figure is ours. If they should agree, the fields' value
       is the one with a frame behind it. */
    .v2-combo-button { min-height: 44px; }
    .v2-combo-option { padding: var(--space-3); }
}




/* ── Order details ───────────────────────────────────────────────────────
   OrderDetails/Desktop/Dark 1788:17704 (light 1818:8336, identical in layout and
   misnamed "PatiOrderDetails").

   EVERY VALUE BELOW IS READ FROM get_design_context. The first version of this
   block was built from get_metadata plus a screenshot, so its colours, type
   sizes, radii and icons were all inferred — and six of them were wrong. The
   node id is cited wherever a number came from one.

   SURFACES AND TEXT GO THROUGH THE SEMANTIC TOKENS, not the raw colours the
   frame names, because the frame is the DARK theme and raw values would leave
   light mode broken. The mapping is exact rather than approximate:

       frame colour/flint/600  ->  --surface-card    (Pebble 100 in light)
       frame colour/flint      ->  --surface-inset   (Pebble 200 in light)
       frame colour/seafoam    ->  --text-strong
       frame colour/flint/200  ->  --text-card-label / --text-muted

   The SATURATED FILLS stay raw — harbour-light, meadow, sea-pink — because the
   light frame draws the same blue, green and red. Those are brand colours, not
   surfaces, so they do not flip.

   STRUCTURE IS THREE LEVELS and flattening it was the main error: the COLUMN is
   a card (Section/FormField), the items / files / patient blocks are cards
   INSIDE it on a lighter surface, and the rows sit inside those. */
.v2-od {
    display: flex;
    flex-direction: column;
    gap: var(--space-5);            /* 20px, the frame's gap between blocks */
}

/* Section/FormField 1788:17741 / 1788:17816 and the progress card 1796:7684 are
   all the SAME component as Register's and Lead Times' sections — .v2-card
   already carries the 20px padding, 20px radius and --surface-card fill. Only
   the gap differs: .v2-card's 30px is the rhythm between a form section's
   heading and its body, and these want the frame's 20. */
.v2-od-block {
    display: flex;
    flex-direction: column;
    gap: var(--space-5);
    min-width: 0;
}

/* [frame] 1788:17747 / 1796:7691 — 24px Bold, which .v2-section-title already
   is. It colours itself --brand-primary for a form section's heading; a card
   title on this surface is seafoam, so that is the only override. */
.v2-od-title {
    color: var(--text-strong);
}

/* The SKU badge and the option chips sit ON an inner card, so they need a step
   off it. In DARK that is --surface-card (Flint 600 against a Flint card) and it
   reads right. In LIGHT --surface-card is Pebble 100, which against the
   seafoam-300 card reads as WHITE rather than as a plate — Daniel, 4-Sep: "i
   feel a light grey would be better". So light gets Pebble 200 and dark is
   left alone. */
:root {
    --v2-od-chip-surface: var(--colour-pebble-200);
}

[data-theme="dark"] {
    --v2-od-chip-surface: var(--colour-flint-600);
}

/* The nested cards: PatientDetailsCard, MyOrdersCard, Orders/Attachments —
   the patient block, the products table and the attached files. 12px sides,
   16px ends, 20px radius.

   THE TEAL LIVES HERE, and only here. [frame] light 1818:8409 fills these with
   colour/seafoam/300 (#D8EDE9) against a pebble-100 outer card; dark 1788:17751
   fills them with colour/flint. Daniel, 4-Sep: "its the colour of the patient
   details container and the products table and the attched files only".

   Not --surface-inset, which is Pebble 200 in light: that role is My Patients'
   recessed cells (861:2789, Pebble 200 on a Pebble 100 card) and the two frames
   genuinely differ. A local property keeps this frame's answer here instead of
   re-tinting every inset in the portal. */
:root {
    --v2-od-inner-surface: var(--colour-seafoam-300);
}

[data-theme="dark"] {
    --v2-od-inner-surface: var(--colour-flint-500);
}

.v2-od-inner {
    display: flex;
    flex-direction: column;
    gap: var(--space-3);                        /* 12px */
    padding: var(--space-4) var(--space-3);     /* 16px 12px */
    border-radius: var(--radius-card);
    background-color: var(--v2-od-inner-surface);
    min-width: 0;
}

/* [frame] 1817:8183 / 1818:8322 — 16px Bold. */
.v2-od-inner-title {
    margin: 0;
    font-size: 16px;
    font-weight: 700;
    color: var(--text-strong);
}

/* ── The progress card ─────────────────────────────────────────────────── */
/* One child now that the reference has moved to the order card, so this is
   just the title row. Kept as its own rule rather than dropped: the card's
   gap belongs to .v2-od-block and the heading should not inherit a flex
   context it no longer needs. */
.v2-od-progress-head {
    display: flex;
    align-items: baseline;
    gap: var(--space-4);
}

/* [frame] 24px Bold and GREY, which is the point — the reference is a quieter
   twin of the title, not a second heading. Each theme names its own grey:
   pebble/200 in dark (1796:7696), pebble/400 in light (1818:8373). Pinned
   rather than routed through --text-body, which is near-black in light and
   read as a second heading. Daniel, 4-Sep: "the order number top right should
   be grey". */
:root {
    --v2-od-ref-colour: var(--colour-pebble-400);
}

[data-theme="dark"] {
    --v2-od-ref-colour: var(--colour-pebble-200);
}

.v2-od-progress-ref {
    margin: 0;
    font-size: 24px;
    font-weight: 700;
    color: var(--v2-od-ref-colour);
}

.v2-od-cancelled {
    margin: 0;
    display: flex;
    flex-direction: column;
    gap: var(--space-2);
    font-size: 15px;
    color: var(--text-body);
}

.v2-od-cancelled-reason {
    font-size: 12px;
    color: var(--text-muted);
}

/* ── The steps ───────────────────────────────────────────────────────────
   One grid column per step so the marks land on an even rhythm whatever the
   labels do. The count comes through as a custom property rather than being
   fixed at four, because a cancelled order has none and the step list belongs to
   include/order_progress.php.

   THE MARKS ARE THE FRAME'S EXPORTED SVGs (Group 69 / 72 / 73) and each carries
   its own circle — check on a meadow disc, package on a meadow disc, truck in a
   pebble-300 ring. So there is no ring or fill in CSS here: the state IS the
   asset. The first version drew CSS circles around hand-written glyph paths,
   which is what Daniel rejected. */
.v2-od-steps {
    display: grid;
    grid-template-columns: repeat(var(--v2-od-step-count, 4), minmax(0, 1fr));
    margin: 0;
    padding: 0;
    list-style: none;
}

/* THE DATE BELONGS TO THE LABEL, so 4px between them and not 12. One flex gap
   was spacing all three the same, which left the timestamp floating midway
   between its own step and the row of labels — Daniel, 4-Sep: "i dont like the
   gap from title to date stamp". The label keeps its 12px from the mark via a
   margin, so the mark is no closer than it was. */
.v2-od-step {
    position: relative;
    display: flex;
    flex-direction: column;
    align-items: center;
    gap: var(--space-1);
    text-align: center;
    min-width: 0;
}

.v2-od-step-label {
    margin-top: var(--space-2);
}

/* THE TRACK IS ONE FULL-WIDTH GRADIENT.
   [frame] 1796:7753-7756 draws it as four absolutely-positioned bars across the
   whole width, two of them gradients: the green fades UP from nothing at the far
   left and the grey fades AWAY at the far right. That is the fade Daniel asked
   for twice.

   Built as a single element behind the marks, because the first attempt — a
   segment per step, each reaching back to the previous mark with
   left: calc(-50% - 20px) — put 6px of bar 20px OUTSIDE the card on both sides.
   That overflow is what scrolled the panel horizontally, and Daniel, 4-Sep:
   "never horizontal scroll".

   --v2-od-fill is where the green ends, computed in the view from the step the
   order has reached. It is the ONLY thing the view passes: the bar is meadow
   whatever state the order is in, because it is the trail of what is finished
   and the mark it runs into carries the state. It used to take a colour too,
   which turned the whole gradient blue on a Pending order. */
.v2-od-steps {
    position: relative;
}

.v2-od-steps::before {
    content: "";
    position: absolute;
    top: 22px;                  /* [frame] mt-[22px] — centres the 6px bar on the
                                   50px mark */
    left: 0;
    right: 0;
    height: 6px;
    /* TWO LAYERS, because one gradient cannot fade green INTO grey. Both colours
       had to sit at the same stop to hand over, and two stops at one position is
       by definition a hard edge — which is what was showing: "just the fade out
       on the colour isnt happening".

       So the grey is the whole track, laid down once with the frame's fade up at
       6% and away at 94%, and the green rides OVER it fading to transparent
       rather than to a colour. The grey underneath is what the green dissolves
       into, so the handover is a real blend and there is no edge anywhere.

       It also fixes the two ends. A single gradient with a fade band had to keep
       its stops in order, so a fully dispatched order at 100% pushed the grey
       tail off the end and lost the right-hand fade with it; here the tail
       belongs to the grey layer and is unaffected by where the green stops.

       --v2-od-fade is how long the green's tail is, and it STRADDLES the fill
       rather than following it: half before, half after, so the position the
       view computed is where the green is half gone. Running the whole fade
       after the fill put the last of the green a full 8% beyond it, which read
       as overshooting — Daniel, 4-Sep: "the fade out seems to happen a little
       late? looks more than half way through the bar".

       It is 0 whenever the green lands ON a mark, because a mark is an opaque
       50px disc drawn over the track and conceals the join by 25px either side.
       Only the positions BETWEEN marks set it: the half step, a part-finished
       step, and the right-hand end of a finished order. */
    background-image:
        linear-gradient(
            90deg,
            transparent 0,
            var(--colour-meadow) 6%,
            var(--colour-meadow) calc(var(--v2-od-fill, 0%) - var(--v2-od-fade, 0%) / 2),
            transparent calc(var(--v2-od-fill, 0%) + var(--v2-od-fade, 0%) / 2)
        ),
        linear-gradient(
            90deg,
            transparent 0,
            var(--colour-pebble-300) 6%,
            var(--colour-pebble-300) 94%,
            transparent 100%
        );
}

/* ── The marks, and Daniel's three-state colour logic ────────────────────
   Daniel, 4-Sep: "grey is inactive, green is done, blue or orange for ongoing".

   The frame cannot express that — each exported mark has its CIRCLE BAKED IN
   (step-box is a meadow disc plus the package, step-truck is the truck plus a
   pebble ring), so three states across four steps would be twelve assets. So
   the disc is drawn here and the GLYPH stays Justin's, with the <circle>
   stripped out of his exports — his path, our circle.

   DONE keeps his composite asset untouched, because done is always green and
   its tick is not separable anyway: that path is the outer ring and the tick as
   one shape.

   The two glyphs the frame never drew — Pending and Awaiting Prescription, which
   it only ever shows ticked — are lucide hourglass and file-text. Lucide because
   the frame itself draws from it: 1788:17748 is named "lucide/square-pen". Not
   invented, and not mine. */
.v2-od-step-mark {
    position: relative;             /* above the track */
    z-index: 1;
    box-sizing: border-box;
    display: inline-flex;
    align-items: center;
    justify-content: center;
    width: 50px;
    height: 50px;
    flex: 0 0 auto;                 /* never let a label squeeze the circle */
    border-radius: 50%;

    /* OPAQUE, so the track passes BEHIND the circle rather than through it.
       Daniel, 4-Sep: "the inactive icon circles need to be infront of the
       graidant progrss bar". The frame gets this for free — it draws the bar
       as four separate segments positioned to stop either side of each mark —
       but one continuous gradient runs the whole width, so an unfilled ring
       showed the bar straight through its middle. Card-coloured because that
       is what the mark sits on. */
    background-color: var(--surface-card);
}

/* DONE — the disc, for the done states that are NOT a finished step and so
   cannot use Justin's composite tick: an order in checks, and any step whose
   completion date never landed. Same meadow as the tick asset. */
.v2-od-step[data-state="done"] .v2-od-step-mark {
    background-color: var(--colour-meadow);
    color: var(--colour-seafoam);
}

/* PART DONE — THE RING IS CONSUMED, THE INSIDE STAYS EMPTY. Daniel, 4-Sep:
   "should fade out, not have a hard line there? and maybe only on the outline?
   as if the process is slowly being consumed, only on done does it fill?"

   So a part-finished step is the todo ring with its left arc turned green, and
   filling the disc is reserved for done — which makes the three states a
   progression rather than three treatments: empty ring, ring being eaten, solid
   disc. There is still no third colour; the first version filled the disc to
   the fraction and met the grey at a hard vertical edge.

   The gradient is painted on the MARK and the inside is covered back over by
   ::before, inset by the ring width. A gradient cannot go on border-color, and
   the mask-based alternative is the technique that already failed once on these
   glyphs — a relative url() in a custom property, resolved differently per
   engine. Two backgrounds and no mask.

   The handover straddles the fraction by 18% of the disc either side, about 9px
   of a 50px circle, so there is no line anywhere — it dissolves.

   The glyph keeps the todo colour, because the inside of the circle is in the
   todo state: the ring alone says how far through the step is, which is what
   "only on the outline" asks for. */
.v2-od-step[data-state="part"] .v2-od-step-mark {
    border: none;
    color: var(--colour-pebble-300);
    background-image: linear-gradient(
        90deg,
        var(--colour-meadow) calc(var(--v2-od-mark-fill, 0%) - 18%),
        var(--colour-pebble-300) calc(var(--v2-od-mark-fill, 0%) + 18%)
    );
}

.v2-od-step[data-state="part"] .v2-od-step-mark::before {
    content: "";
    position: absolute;
    inset: 3px;                 /* the ring width, so 3px of gradient survives */
    border-radius: 50%;
    background-color: var(--surface-card);
}

/* ::before is a positioned descendant and would otherwise paint OVER the
   glyph, which is ordinary inline content. Positioning the glyph puts it in the
   same layer, where tree order decides — and ::before comes first. z-index is
   not needed and is deliberately not used. */
.v2-od-step[data-state="part"] .v2-od-step-glyph {
    position: relative;
}

/* ── The (i) that opens the EPS modal ────────────────────────────────────
   HARBOUR, BECAUSE IT READS. This panel already uses harbour for View order PDF
   and amber for New query — the one action that writes — so a badge opening a
   modal of stored tokens belongs with the former. It reuses the exact fill and
   text pair .v2-button--harbour uses, so its contrast is the one already settled
   there and not a new decision.

   Positioned against the MARK, which is the only element near enough to be "top
   right of the prescription icon": the step is a full grid column wide, so its
   own top right is some 70px clear of the circle. */
.v2-od-step-info {
    position: absolute;
    top: -2px;
    right: -2px;
    display: inline-flex;
    align-items: center;
    justify-content: center;
    width: 18px;
    height: 18px;
    padding: 0;
    /* A ring in the card colour, so the badge reads as sitting ON the mark
       rather than merging into whichever of the three fills is behind it. */
    border: 2px solid var(--surface-card);
    border-radius: 50%;
    background-color: var(--colour-harbour-light);
    color: var(--text-on-brand);
    font-size: 11px;
    font-weight: 700;
    font-style: italic;
    line-height: 1;                 /* cursor and font come from base.css */
}

/* A 44px TAP TARGET UNDER AN 18px DOT. The badge is small on purpose — "small
   (i) ... completely non intrusive" — but 18px is not a usable tap area on a
   tablet, and growing the dot to meet the guidance would undo the point of it.
   So the hit area is a transparent circle around it instead. */
.v2-od-step-info::before {
    content: "";
    position: absolute;
    inset: -13px;
    border-radius: 50%;
}

.v2-od-step-info:hover {
    filter: brightness(1.08);
}

/* INACTIVE — the ring, at the same 3px and pebble-300 the truck's own baked
   ring uses, so the family holds. */
.v2-od-step[data-state="todo"] .v2-od-step-mark {
    border: 3px solid var(--colour-pebble-300);
    color: var(--colour-pebble-300);
}

/* The glyph is an INLINE SVG taking currentColor, which is what lets one
   drawing serve both the blue and the grey state. Sized per source: lucide
   draws in a 24 viewBox, Justin's exports in a 50 one, so it comes through
   per glyph rather than being averaged into something that suits neither. */
/* THE WRAPPER FOR EVERY INLINED SVG IN THIS PANEL. The endpoint strips width
   and height off the markup, so the size lives here and nowhere else — square
   by default via --v2-od-glyph-size, and a non-square icon (the paperclip, the
   two file buttons) sets its own width/height in its own rule. Shared with the
   progress steps rather than duplicated: it is the same three declarations. */
.v2-od-step-glyph,
.v2-od-glyph {
    display: inline-flex;
    width: var(--v2-od-glyph-size, 24px);
    height: var(--v2-od-glyph-size, 24px);
}

.v2-od-step-glyph svg,
.v2-od-glyph svg {
    width: 100%;
    height: 100%;
    display: block;
}

/* A STROKE-BASED GLYPH MUST NOT BE FILLED IN. base.css sets svg { fill:
   currentColor } so that Justin's filled exports take the text colour, but a
   CSS declaration beats a presentation attribute — so it also overrode the
   fill="none" that lucide's outline icons carry, and every one of their paths
   filled solid. The hourglass and the prescription document rendered as dark
   blobs rather than as line drawings, which is what Daniel was looking at when
   he said the icon needed fixing.

   Matched on the attribute the file itself declares, so it is the icon that
   decides, not a class somebody has to remember to add. fill inherits, so
   setting it on the root is enough for every path inside. */
.v2-od-step-glyph svg[fill="none"],
.v2-od-glyph svg[fill="none"] {
    fill: none;
}

/* [frame] 1796:7757 — 15px Medium seafoam, and the same colour on every step:
   the frame does not dim the ones not yet reached. */
.v2-od-step-label {
    font-size: 15px;
    font-weight: 500;
    color: var(--text-strong);
}

/* THE TIMESTAMP MUST NOT MOVE THE STEPPER. Daniel, 4-Sep: "I want to really
   limit the flex that timestamps put on the progress bar."

   Two things it was doing. It WRAPPED — "07/08/2026 16:12" is the longest
   string in the column, so on a narrow card it took a second line and made the
   whole block taller. And it appears on some steps and not others, so the
   block's height changed as an order progressed. nowrap with an ellipsis stops
   the first; max-width keeps it inside its own column even when the grid is
   narrower than the text.

   And the block's HEIGHT no longer depends on which steps have a stamp. The
   element is rendered for every step whether or not it has one, and min-height
   holds its line, so a date landing on a step does not push the stepper down.
   Reserving the line is why it is a block rather than an inline span: an empty
   inline box has no height to reserve. */
.v2-od-step-when {
    display: block;
    max-width: 100%;
    min-height: 16px;
    font-size: 12px;
    font-weight: 500;
    line-height: 16px;
    color: var(--text-muted);
    white-space: nowrap;
    overflow: hidden;
    text-overflow: ellipsis;
}

/* ── Tracking, under the stepper ─────────────────────────────────────────
   Daniel, 3-Sep: "tracking number in shipment should follow logic of classic and
   appear below yes". One row per PARCEL — one parcel can carry several lines. */
/* ── Tracking, a card at the foot of the progress block ──────────────────
   The plate itself is .v2-od-inner, shared with the patient, items and files
   cards, and the title is .v2-od-inner-title — so this adds the head row, the
   count and the rows, and nothing else.

   The head is the files card's pattern: title and count on one baseline. */
.v2-od-tracking-head {
    display: flex;
    align-items: baseline;
    gap: var(--space-2);
}

.v2-od-tracking-count {
    font-size: 12px;
    font-weight: 500;
    color: var(--text-muted);
}

.v2-od-tracking {
    display: flex;
    flex-direction: column;
    gap: var(--space-2);
    margin: 0;
    padding: 0;
    list-style: none;
}

/* A BORDERED ROW, the same shape as an attachment and an EPS barcode: the
   identifier on the left, its note on the right. justify-content rather than a
   gap, because the number and the metadata are the two ends of the row and not
   two things sitting next to each other. */
.v2-od-tracking-item {
    display: flex;
    align-items: baseline;
    justify-content: space-between;
    gap: var(--space-3);
    padding: var(--space-2) var(--space-3);
    border: 1px solid var(--colour-flint-200);
    border-radius: 12px;
    font-size: 12px;
}

.v2-od-tracking-link,
.v2-od-tracking-num {
    font-weight: 600;
    overflow-wrap: anywhere;    /* a tracking number is one long token */
    min-width: 0;
}

.v2-od-tracking-link { color: var(--text-link); }
.v2-od-tracking-num  { color: var(--text-body); }

.v2-od-tracking-meta {
    flex: 0 0 auto;
    color: var(--text-muted);
}

/* ── The two columns ─────────────────────────────────────────────────────
   [frame] 280 / 550 with a 20px gap.

   SUBGRID IS THE ALIGNMENT FIX Daniel asked for, and it earns its keep: the two
   column heads are genuinely different heights — the right one carries the
   action row, the provenance lines and the Edit history button, and WHICH
   buttons appear depends on the order's state — so no fixed height could be
   right in every case. Sharing the parent's first row makes the inner cards
   start level by construction.

   In the frame the left card starts at y=64 and the right at y=110, 46px out. */
.v2-od-cols {
    display: grid;
    grid-template-columns: 280px minmax(0, 550px);
    align-items: start;
    gap: var(--space-5);
}

/* A column is just .v2-card + .v2-od-block: a flex column with a 20px gap,
   which is exactly what Section/FormField is in the frame
   (flex flex-col gap-[20px] p-[20px] rounded-[20px]). Nothing else is needed —
   the two cards are grid items in the same row, so they start level on their
   own. align-items: start keeps them at their natural heights rather than
   stretching the shorter one to match. */

.v2-od-colhead {
    display: flex;
    align-items: center;
    justify-content: space-between;
    gap: var(--space-5);            /* [frame] 1801:7779 */
}

/* [frame] 1802:7858 is a ROW - the text stack on the left, ButtonSelector on
   the right. Not a column: stacking the buttons above the text put the three
   loudest things on the card into its top-left corner (Daniel, 4-Sep). */
.v2-od-colhead--actions {
    align-items: flex-start;
    gap: var(--space-3);
}

/* The three actions, with Edit history beneath them on the same right edge -
   Daniel's placement, styled as one of the set rather than as a text link. */
.v2-od-actions {
    display: flex;
    flex-direction: column;
    align-items: flex-end;
    gap: var(--space-2);
    flex: 0 0 auto;
}

.v2-od-actions-row {
    display: flex;
    flex-wrap: wrap;
    justify-content: flex-end;
    gap: var(--space-2);
}

/* EDIT HISTORY SPANS THE ROW ABOVE IT — from the left edge of the first
   button to the right edge of the last, and it keeps doing so when the set
   changes: a closed order shows only Repeat, and the column's width is
   whatever the row needs, so stretch follows it. Daniel, 4-Sep.

   And it carries a faint fill rather than sitting as a bare outline on the
   card ("should we add a small shaded colour", 4-Sep). --surface-inset is one
   step off the card in both themes, which is enough to read as a control
   without competing with the three filled buttons above it. */
.v2-od-actions > .v2-button:last-child {
    align-self: stretch;
    width: 100%;
    background-color: var(--surface-inset);
}

.v2-od-actions > .v2-button:last-child:hover {
    background-color: var(--surface-hover);
}

/* currentColor now that the glyph is inlined, so it carries no baked seafoam
   fill. --text-strong rather than a link colour: it sits beside a 16px Bold
   name, and the frame draws it the same weight. */
/* Two names, one definition. The behaviour is not order-detail's — it is "an
   icon acting as a button" — so Request Patient Order joins the selector rather
   than copying six lines under its own prefix. Rename both together if the od-
   prefix ever stops paying its way. */
.v2-od-icon-btn,
.v2-ro-icon-btn {
    display: inline-flex;
    align-items: center;
    justify-content: center;
    flex: 0 0 auto;
    color: var(--text-strong);
}

.v2-od-icon-btn:hover,
.v2-ro-icon-btn:hover {
    opacity: 0.75;
}

/* ── AND NOW od's USERS ARE BUTTONS TOO ─────────────────────────────────────
   The note below used to read ".v2-od-icon-btn's users are anchors, so this
   never came up there". As of 21-Sep one of them is a <button>: the patient
   arrow on the order panel was an <a href="view-patient.php"> and that href was
   one of the doors out of v2 that came out with the ordering work.

   A bare <button> brings its own border, background and padding, so without
   this the arrow would render as a grey chrome button in the middle of a card.
   Scoped to the element rather than added to the class, so the anchors that
   share the class are untouched. */
button.v2-od-icon-btn {
    padding: 0;
    border: 0;
    background: none;
    font: inherit;
    cursor: pointer;
}

/* A bare button needs its own reset — .v2-od-icon-btn's users are anchors, so
   this never came up there.

   AND IT GETS A CONTAINER (Daniel, 11-Sep). A glyph alone on a card reads as
   decoration, not as something you can press: there is no edge to aim at and
   nothing that says "control". A square with a border says both, and gives the
   hover and focus states somewhere to land.

   Square rather than snug, at 30px: it is under the 44px touch minimum, but this
   sits in the panel HEAD, which is desktop-only furniture — the head collapses
   below 1024px and the card drops to full width, where the rule below grows
   them. */
/* THE BORDER IS THE CONTAINER, not a fill. The card these sit on is already
   --surface-raised, so painting the button --surface-raised too made it
   invisible — the same colour as its own background, which is why the first
   version read as "no container at all".

   A bordered square works on any surface without having to know which one it
   landed on, and the fill arrives on hover where there IS somewhere to move
   from. Theme-proof by construction rather than by picking a second token and
   hoping it steps the right way in both themes. */
.v2-ro-icon-btn {
    width: 30px;
    height: 30px;
    padding: 0;
    background: transparent;
    border: 1px solid var(--border-medium);
    border-radius: var(--radius-button, 8px);
    cursor: pointer;
    color: var(--text-muted);
    transition: background-color .15s ease, color .15s ease, border-color .15s ease;
}

.v2-ro-icon-btn:hover {
    background: var(--surface-hover);
    border-color: var(--text-muted);
    color: var(--text-strong);
    opacity: 1;
}

/* On touch the card is full width and these are the only controls on their
   rows, so they take the size a thumb actually needs. */
@media (max-width: 1024px) {
    .v2-ro-icon-btn { width: 44px; height: 44px; }
}

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

.v2-ro-icon-btn:focus-visible {
    outline: 2px solid var(--accent-request-order);
    outline-offset: 1px;
}

/* The order's own facts, stacked: its reference, then who placed it and when.
   A column of its own so it takes only the width it needs and the action
   buttons keep their side of the row. */
.v2-od-meta {
    display: flex;
    flex-direction: column;
    gap: var(--space-2);
    min-width: 0;
}

/* "Order: NP#10200". 18px Bold against the 16px Medium beneath it — a step up
   rather than the same size, so it reads as the heading of the pair without
   becoming a second page title. Daniel, 4-Sep: "slightly bigger". */
.v2-od-ref {
    margin: 0;
    font-size: 18px;
    font-weight: 700;
    color: var(--text-strong);
}

/* [frame] 1788:17826 — 16px Medium flint/200, on two lines. */
/* ONE LINE EACH, TRUNCATED. A long name wrapped and pushed the action buttons
   down with it, so the card's whole top row reflowed around whoever placed the
   order — Daniel, 4-Sep: "on the created by: have the name ...off instead of
   flex". Each line is its own span so the ellipsis is per line and not just on
   the last one, and the full text stays in a title for anyone who needs it. */
.v2-od-provenance {
    display: flex;
    flex-direction: column;
    min-width: 0;
    margin: 0;
    font-size: 16px;
    font-weight: 500;
    line-height: 1.35;
    /* GREY, and pinned for the same reason .v2-od-field-label is: --text-muted
       is Pebble 600 in light, near enough to body copy that "Created by" stopped
       reading as secondary. [frame] 1788:17826 says colour/flint/200, which is
       what the field labels use too — so the two greys on this card match. */
    color: var(--colour-flint-200);
}

/* THE DIRECT HALVES ONLY. "Placed on: " is a span inside the second one, and
   an ellipsis on a two-word label that never overflows is noise at best. */
.v2-od-provenance > span {
    white-space: nowrap;
    overflow: hidden;
    text-overflow: ellipsis;
}

/* THE PIPE IS A MOBILE THING. Desktop stacks the two halves (1788:17826), and
   a separator between two lines separates nothing. The colour is stated here
   rather than in the media query so it is written once: marram, the same
   token and the same reasoning as .v2-panel-sub-sep — decorative, so a
   contrast too low for text is right for it. */
.v2-od-prov-sep {
    display: none;
    color: var(--colour-marram);
}

/* ── The action buttons ──────────────────────────────────────────────────
   All five — Edit, Repeat, Cancel, New query, View order PDF — are the same
   Button component at the same size ([frame] 1802:7826-7828, 1795:2560-2561):
   28px tall, 80px minimum, 16px sides, 8px radius, 15px Medium.

   So they REUSE .v2-button and add one size modifier plus two fills, rather than
   carrying their own geometry. All three additions are shared, not
   order-details-specific:

     --sm       the 28px size. Five buttons here use it and the head pair does
                too, so it belongs to the button, not to this panel.
     --harbour  the frame's colour/harbour-light. NOT .v2-button--primary: that
                is --brand-primary, which is harbour-400 in dark but harbour-500
                in light, and the frame names the raw alias — so it is #3A9FD4 in
                both themes by intent.
     --danger   sea-pink. There was no danger fill; two-action confirmations will
                want it as well.

   Repeat needs NOTHING new: .v2-button--accent is already meadow with
   --text-on-accent, and --text-on-accent is seafoam-50, which is exactly what
   1802:7827 draws. New query reuses --secondary, the outline role. Its border is
   --border-strong where the frame names pebble-200; left as the project's own
   documented choice rather than pinned, since --secondary exists precisely to be
   the outline button. */
.v2-button--sm {
    min-width: 80px;
    min-height: 28px;
    border-radius: var(--radius-button-sm);     /* 8px */
}

.v2-button--harbour {
    background-color: var(--colour-harbour-light);
    color: var(--text-on-brand);
}

.v2-button--danger {
    background-color: var(--colour-sea-pink);
    color: var(--text-on-brand);
}

/* AMBER, for New query — the one button on an order that writes anything.

   Golden Hour, the accent amber, with white on it and the same in both themes —
   Daniel's call. semantic.css holds the pair and records the contrast, which it
   is worth knowing does NOT pass AA — read that note
   before changing either value, because the shade and the text on it were
   settled together and neither is a default. */
.v2-button--amber {
    background-color: var(--surface-amber);
    color: var(--text-on-amber);
}

.v2-button--harbour:hover:not([disabled]),
.v2-button--danger:hover:not([disabled]),
.v2-button--amber:hover:not([disabled]) {
    filter: brightness(1.08);
}

/* The disabled twin of Cancel, once a request is already in. */
.v2-button--pending {
    background-color: var(--surface-inset);
    color: var(--text-muted);
    cursor: default;
}



/* ── Patient details ─────────────────────────────────────────────────────
   PatientDetailsCard 1788:17751. Fills its column, which is the other half of
   the alignment fix: in the frame it is 290 tall in a 468 row, and that 178px of
   dead space under it is most of what read as squashed. */
.v2-od-patient {
    gap: var(--space-5);            /* [frame] 1788:17752 */
}

/* The name row is the collapse toggle — Icon/DropDown 930:779 is a real asset in
   the frame, so the card is genuinely collapsible. Uses the shell's existing
   data-v2-collapse-toggle rather than a new script. */
/* THE CHEVRON CENTRES ON THE NAME. flex-start aligned the top of a 16px icon
   with the top of a 16px/1.2 line box, which left it a couple of pixels high —
   Daniel, 4-Sep: "the arrow to drop down the patient details is always not
   quite inline". Centre is right for the one-line case, which is every name
   short enough not to wrap, and stays reasonable for the ones that do. */
/* THE NAME ROW, holding the toggle and the open-record arrow as siblings.
   They cannot nest: an <a> inside a <button> is invalid and neither element
   would behave. Daniel, 7-Sep: "the view patient icon should be next to the
   patient name no? not on its own below the title?" */
.v2-od-patient-head {
    display: flex;
    align-items: center;
    gap: var(--space-3);
}

/* THE ARROW TAKES THE CORNER. margin-left: auto rather than
   justify-content: space-between on the row, so it is the arrow that is
   pushed out and not the gap between the name and its chevron - those two
   belong together and space-between would have separated them. */
.v2-od-patient-head .v2-od-icon-btn {
    margin-left: auto;
}

/* SHRINKS TO ITS CONTENT, where it used to fill the row. The toggle and its
   chevron now hug the name - the chevron cannot leave the button, since it is
   the button's own affordance - and the arrow is pushed to the far edge on its
   own. Daniel, 7-Sep, on seeing all three clustered together: "put the arrow
   in the right corner?" */
.v2-od-patient-toggle {
    display: flex;
    align-items: center;
    justify-content: space-between;
    gap: var(--space-5);
    flex: 0 1 auto;
    min-width: 0;
    padding: 0;
    border: 0;
    background: none;
    text-align: left;
    cursor: pointer;
}

/* [frame] 1788:17758 — 16px Bold. */
.v2-od-patient-name {
    /* TRUNCATES rather than wrapping, now that the toggle is content-sized:
       a long name would otherwise push the chevron and the arrow off the
       card on a 402px screen. */
    min-width: 0;
    overflow: hidden;
    text-overflow: ellipsis;
    white-space: nowrap;
    font-size: 16px;
    font-weight: 700;
    color: var(--text-strong);
}

.v2-od-patient-chevron {
    flex: 0 0 auto;
    --v2-od-glyph-size: 16px;   /* [frame] 1788:17759, Icon/DropDown at 16 */
    color: var(--text-strong);
    transition: transform 160ms ease;
}

.v2-od-patient-toggle[aria-expanded="false"] .v2-od-patient-chevron {
    transform: rotate(-90deg);
}

/* [frame] 1788:17777 — 12px Medium with a 14px gap, label in flint/200 above the
   value in seafoam. BOTH are 12px: the first version made the label 11 and the
   value 13, which is why the card read as cramped. */
.v2-od-fields {
    display: flex;
    flex-direction: column;
    gap: 14px;
    margin: 0;
}

.v2-od-field > * {
    margin: 0;
    font-size: 12px;
    font-weight: 500;
    line-height: normal;
}

/* GREY, not --text-card-label. That role is Seafoam 700 in light — a teal —
   and on the new teal panel the labels read as tinted text rather than as
   labels. [frame] 1788:17778 says colour/flint/200 in BOTH themes, so it is
   pinned. Daniel, 4-Sep: "to be Grey not Teal". */
.v2-od-field-label {
    color: var(--colour-flint-200);
}

.v2-od-field-value {
    color: var(--text-strong);
    overflow-wrap: anywhere;        /* a long email must not widen the column */
}

.v2-od-empty {
    color: var(--text-muted);
    font-style: italic;
}

/* ── The items table — MyOrdersCard 1817:8182 ───────────────────────────── */
.v2-od-table {
    width: 100%;
    border-collapse: collapse;
}

/* [frame] 1817:8185 is 16px Bold. Dropped to 14 on Daniel's call (4-Sep:
   "the order table text seeme to big? text is squashed") — the frame's row is
   484px wide with a 40px SKU badge, and real SKUs are nothing like 40px, so
   the columns need the room more than the headings do. */
.v2-od-table th {
    padding: 0 0 var(--space-3);
    font-size: 14px;
    font-weight: 700;
    color: var(--text-strong);
    text-align: left;
    /* Orders/List/Header 1817:8190 is a 1px rule in flint/200, exported as an
       SVG. A border is the same line and one fewer request. */
    border-bottom: 1px solid var(--colour-flint-200);
}

.v2-od-table td {
    padding: var(--space-3) 0;
    vertical-align: top;
    border-bottom: 1px solid var(--colour-flint-200);
}

.v2-od-table tbody tr:last-child td {
    border-bottom: 0;
}

/* THE SKU COLUMN HOLDS TEN CHARACTERS BEFORE IT FLEXES. The frame's badge is
   40px, which fits "OFL1" and almost nothing real — "PIP Code 422-1248" and
   "50143N03NNNNN000" are the shapes that actually turn up. 10ch is Daniel's
   number (4-Sep); past that the badge wraps rather than the column growing
   and squeezing the product name. */
.v2-od-c-sku {
    width: 1%;                      /* shrink-to-fit, floored by the min below */
    /* The badge's own cap plus the gutter, so the floor and the ceiling are the
       same number and the column is effectively fixed. */
    min-width: calc(10ch + 12px + var(--space-3));
    /* 12px. It went to 20 when the badge was still overrunning its cap and
       colliding with the name; with the cap actually holding, 20 left the name
       further out than it needed to be — Daniel, 4-Sep: "can bring the iten
       name left slightly too". */
    padding-right: var(--space-3);
}

/* CENTRED, WHICH IS THE FRAME'S ALIGNMENT. Worked out from the geometry rather
   than by eye: the Quantity heading (1817:8188) spans x 330-391 and its pill
   (1817:8207) spans 341-381 — centres 360.5 and 361. Dispatched is 403-484
   against 424-464, centres 443.5 and 444. So each pill is centred under its
   own heading, and this column was right-aligned. Daniel, 4-Sep: "can we
   centralise the numbers for quantity and dispatched to match spec?"

   The heading and the cell share these classes, so both centre on the same
   axis and the asymmetric left padding below cannot pull them apart.

   Quantity and Dispatched read as one word when their columns touch. The frame
   puts 43px between the two pills and 12px between the two headings; the
   padding keeps them apart at both sizes. */
.v2-od-c-qty,
.v2-od-c-disp {
    width: 72px;
    text-align: center;
    white-space: nowrap;
}

.v2-od-c-qty {
    padding-left: var(--space-4);
}

.v2-od-c-disp {
    padding-left: var(--space-5);
}

/* XS/Button 1817:8194 — 40x24, 6px radius, flint/600 fill, 12px Regular. On a
   nested card the fill is a step DOWN from the card, which is what flint/600 is
   against flint. --surface-card is that token in both themes. */
/* XS/Button 1817:8194 is 40x24. The width is a MINIMUM here, not a fixed
   size: a real SKU is longer than "OFL1" and clipping an identifier is worse
   than a taller badge. */
/* THE CAP IS IN ch, NOT A PERCENTAGE, and that is the whole fix. A real SKU is
   often one unbreakable token — "5013421234NNN000" — so it needs
   overflow-wrap to break at all; but max-width: 100% resolves against a width
   the table algorithm is still computing, so it imposed nothing and the column
   was handed enough room for twelve characters. The badge broke at 12 rather
   than at 10 and sat hard against the product name: Daniel, 4-Sep, "there is
   12 digits, then space before it flexs? causing colition with the item name
   still".

   10ch + 12px is ten characters plus the badge's own side padding, which
   box-sizing: border-box includes. An absolute cap means the cell's max-content
   width is the cap, so the table cannot grow the column past it and the product
   name starts at the same x on every row. */
.v2-od-sku {
    max-width: calc(10ch + 12px);
    overflow-wrap: anywhere;
    display: inline-flex;
    align-items: center;
    justify-content: center;
    min-width: 40px;
    min-height: 24px;
    padding: 0 6px;
    border-radius: 6px;
    background-color: var(--v2-od-chip-surface);
    font-size: 12px;
    font-weight: 400;
    line-height: 16px;
    color: var(--text-strong);
}

/* ── A COMPOSITION'S PARTS, STACKED IN THE SKU CELL ────────────────────────
   Daniel, 20-Sep: "they should be where the SKu normally goes. stacked. like
   classic." One pill per component, down the column, exactly as the single-SKU
   case reads — because that is what they are, and a run-on line under the
   product name read as a description of the garment instead. */
.v2-od-sku-stack {
    display: inline-flex;
    flex-direction: column;
    align-items: flex-start;
    gap: 2px;
}

/* ── THE SCHEMA, AS A FOOTER TO THE LINE ───────────────────────────────────
   Daniel, 20-Sep: "can schema go below options as a footer to the entry and
   lose the container pill".

   NO PILL, deliberately, and that is the whole point of the note. Everything
   pilled on this row is a THING — a SKU, an option added to the garment — and
   a pilled schema joined that set visually while belonging to a different one:
   it is the measurement the line is cut to, not another item on it. Plain text
   under the chips says "about all of the above" in a way a chip cannot.

   Block, so it takes its own line however many chips wrapped above it. */
.v2-od-schema,
.v2-od-variant {
    display: block;
    margin-top: 6px;
    font-size: 12px;
    line-height: 16px;
    color: var(--text-muted);
}

/* [frame] 1817:8197 — 15px Medium. */
.v2-od-item-name {
    display: block;
    font-size: 15px;
    font-weight: 500;
    color: var(--text-strong);
}

/* Sizing 1817:8198 — the glyph and the measurement, 12px gap, 12px Medium
   flint/200. */
.v2-od-sizing {
    display: flex;
    align-items: center;
    gap: var(--space-3);
    margin-top: var(--space-3);
    font-size: 12px;
    font-weight: 500;
    color: var(--text-muted);
}

.v2-od-sizing img {
    flex: 0 0 auto;
}

/* The option chips — XS/Button 1817:8202. 18px tall, 6px sides, 6px radius,
   10px Regular, 4px between them, and the quantity is a quieter flint/200 INSIDE
   the same chip (1817:8203). */
/* COLUMNS, not a wrap. Wrapped, four options of different lengths packed
   raggedly — two wide ones on the first line and two short ones on the second,
   with the right-hand edges landing wherever they fell. Daniel, 4-Sep: "they
   dont like so scattered below. a more uniform columnisation".

   TWO CONTENT-SIZED TRACKS, PACKED LEFT. The first attempt used
   minmax(110px, 1fr), and 1fr made each track fill half the name column — so
   the second column was flung to the far right and the pills read as more
   scattered, not less (Daniel, 4-Sep: "bring option columns together slightly").

   auto tracks size to their content and justify-content: start stops the grid
   spreading them, so the columns sit side by side while their left edges still
   line up — which is the uniformity that was wanted. justify-items keeps each
   pill its own width, so they stay pills rather than stretching into buttons. */
.v2-od-chips {
    display: grid;
    /* minmax(0, auto), not auto: an auto track takes its max-content width and
       will not shrink below it, so one long option label pushed the whole row
       past the card — Daniel, 7-Sep, "spanned way off the panel". A zero floor
       lets the track shrink so the chip wraps instead. */
    grid-template-columns: repeat(2, minmax(0, auto));
    justify-content: start;
    justify-items: start;
    gap: 4px var(--space-2);
    margin-top: var(--space-3);
}

.v2-od-chip {
    display: inline-flex;
    align-items: center;
    gap: 3px;
    /* min-height rather than height: a label long enough to wrap needs the
       room, and a clipped option reads worse than a two-line pill. Short ones —
       which is most of them now the curated name is used — still sit at the
       frame's 18px. */
    min-height: 18px;
    padding: 1px 6px;
    border-radius: 6px;
    background-color: var(--v2-od-chip-surface);
    font-size: 10px;
    font-weight: 400;
    line-height: 16px;
    color: var(--text-strong);
    overflow-wrap: anywhere;
}

.v2-od-chip-qty {
    color: var(--text-muted);
}

/* The quantity and dispatched pills — XS/Button 1817:8207 / 1817:8209. Same
   40x24 geometry as the SKU badge; quantity is harbour-light, dispatched is
   meadow, both with a seafoam label.

   NO AMBER. The first version made a part-dispatched line amber, which was my
   judgement substituted for the design's — Daniel asked for the frame. The
   shortfall is still legible: the number itself differs from the quantity beside
   it, and the order-level state is on the card in My Orders and on the
   timeline's own dispatch step. */
.v2-od-pill {
    display: inline-flex;
    align-items: center;
    justify-content: center;
    width: 40px;
    height: 24px;
    padding: 0 6px;
    border-radius: 6px;
    font-size: 12px;
    font-weight: 500;
    line-height: 16px;
    color: var(--colour-seafoam);
}

.v2-od-pill--qty  { background-color: var(--colour-harbour-light); }
.v2-od-pill--disp { background-color: var(--colour-meadow); }

/* A line that never ships — an NHS fee, an MTM option — gets a dash, not a 0. A
   0 in this column reads as "outstanding", which for a line that cannot ship is
   a lie the totals row then appears to contradict. */
.v2-od-na {
    font-size: 12px;
    color: var(--text-muted);
}

/* [frame] 1817:8214 — 16px Bold, above a rule. */
.v2-od-total th,
.v2-od-total td {
    padding-top: var(--space-3);
    border-top: 1px solid var(--colour-flint-200);
    border-bottom: 0;
    font-size: 16px;
    font-weight: 700;
    color: var(--text-strong);
    text-align: left;
}

/* CENTRED, like the rows above. This override exists at all because the rule
   above it sets the whole total row left for its "Order total" label — so when
   the item rows went from right to centre, this one kept its own right and the
   totals sat 18px out from the pills they total. Daniel, 4-Sep: "need to
   centralise the totasl quanitty at the bottom too". */
.v2-od-total .v2-od-c-qty,
.v2-od-total .v2-od-c-disp {
    text-align: center;
}

/* Spans SKU + name, so it needs neither's width rule. */
.v2-od-c-total {
    text-align: left;
}

/* ── Notes, between the items and the files ──────────────────────────────
   Daniel's placement: not under Patient details, and not beside — the 280/550
   split has no room for a third column. Between the two reads in order: what was
   ordered, what was said about it, what was attached. */
.v2-od-note-body {
    margin: 0;
    font-size: 12px;
    font-weight: 500;
    line-height: 1.5;
    color: var(--text-strong);
}

.v2-od-note-label {
    margin: 0;
    font-size: 10px;
    font-weight: 500;
    color: var(--text-card-label);
}

.v2-od-notice-body {
    margin: 0;
    display: flex;
    flex-direction: column;
    gap: var(--space-2);
    font-size: 12px;
    font-weight: 500;
    color: var(--text-strong);
}

.v2-od-notice-reason {
    color: var(--text-muted);
}

/* ── Attached files — Orders/Attachments 1818:8317 ───────────────────────
   Glyph, title and count on one baseline. The frame stacks the title and the
   count (1818:8318); they sit side by side here on Daniel's call. */
.v2-od-files-head {
    display: flex;
    align-items: baseline;
    gap: var(--space-2);
}

/* 14x16, and it needs a colour of its own: the export is seafoam/50 and
   currentColor would otherwise inherit whatever the card happens to set. */
.v2-od-files-glyph {
    flex: 0 0 auto;
    width: 14px;
    height: 16px;
    align-self: center;
    color: var(--text-strong);
}

/* [frame] 1818:8323 — 12px Medium flint/200. */
.v2-od-files-count {
    font-size: 12px;
    font-weight: 500;
    color: var(--text-muted);
}

.v2-od-file-list {
    display: flex;
    flex-direction: column;
    gap: var(--space-3);
    margin: 0;
    padding: 0;
    list-style: none;
}

/* [frame] 1818:8324 — a BORDERED row, not a filled one: 1px flint/200, 12px
   sides, 12px radius. The first version used a filled inset plate.

   PADDING RATHER THAN A HEIGHT. The frame is 64px tall with its text stack
   inset 11px (1818:8325 sits at y=11 in a 64px box), but that stack is only
   42px because Figma trims each text box to its cap height. Real line boxes are
   taller, so 64px left about 5px above the file name and it sat against the
   border — Daniel, 4-Sep: "can we bump the name of file down slightly closer to
   the other text to give me padding to the edge?"

   So the frame's inset is honoured directly and the height follows from it. A
   min-height would now never bind, so it is dropped rather than left as a
   number that looks meaningful and is not. */
.v2-od-file {
    display: flex;
    align-items: center;
    justify-content: space-between;
    gap: var(--space-3);
    padding: var(--space-3);
    border: 1px solid var(--colour-flint-200);
    border-radius: 12px;
}

/* 8px, not the frame's 14 (1818:8325) — the other half of "bump the name of
   file down slightly closer to the other text". The frame's 14 separates a
   9px-tall trimmed name from a 19px trimmed block; against real line boxes it
   read as a gap rather than as a pair. */
.v2-od-file-text {
    display: flex;
    flex-direction: column;
    gap: var(--space-2);
    min-width: 0;
}

/* [frame] 1818:8326 is 12px Bold. 14 on Daniel's call (4-Sep: "make the file
   name larger") — it is the one thing in the row anybody reads, and it was set
   the same size as the metadata under it. */
.v2-od-file-name {
    margin: 0;
    font-size: 14px;
    font-weight: 700;
    color: var(--text-strong);
    overflow-wrap: anywhere;
}

/* [frame] 1818:8327 — 10px Medium flint/200, as ONE text node of two lines.

   11px rather than 10, which is Daniel's "slightly bigger" (4-Sep) and the only
   departure from the frame here.

   FLINT/200 RATHER THAN --text-muted, which is what "more grey more in line
   with the spec" is asking for and the frame agrees: --text-muted resolves to
   Pebble 200 in dark, a LIGHTER grey than the frame's Flint 200, so the
   metadata was competing with the file name above it. Pinned to the frame's own
   value the same way .v2-od-provenance is, and MEASURED at 2.4:1 on the light
   card's Seafoam — recorded rather than overridden, per the standing rule that
   the frame decides. It is the same number that block already carries.

   The paragraphs inside carry no margin, so the two lines are spaced by their
   own line-height and the 14px above belongs to the gap from the name. */
.v2-od-file-meta {
    font-size: 11px;
    font-weight: 500;
    color: var(--colour-flint-200);
}

.v2-od-file-meta p {
    margin: 0;
}

/* Reuses the option pill outright — same 18px plate, same surface, same 10px
   label — because it is the same thing: a short fact hanging off the line above
   it. Only the alignment differs, since this one sits inside a 14px text line
   rather than in a grid of its own. */
.v2-od-file-tag {
    margin-left: var(--space-2);
    vertical-align: 2px;
    font-weight: 700;
}

.v2-od-file-actions {
    display: flex;
    align-items: center;
    gap: var(--space-2);
    flex: 0 0 auto;
}

/* THE BOX IS A CSS BORDER, NOT THE EXPORT'S OWN RECT. Daniel, 7-Sep: "can we
   make the little boxes a little wider each" — and that could not be done by
   widening the link. [frame] 1818:8329/8333 draw the outline INSIDE the SVG, as
   <rect width="31" height="23" stroke>, so the visible box is part of the
   drawing: a wider link just letterboxes a 32-wide picture in it and nothing
   moves. Worse, forcing the SVG to fill would stretch the glyph with the box.

   So the rect is hidden and the border drawn here instead. The glyph then sits
   at its natural size, centred by the SVG's own aspect ratio, and the box is
   whatever this rule says — 40px, up from the frame's 32. Flint 200 and a 6px
   radius are the rect's own values (rx="5.5"), so it looks like the export;
   only the width is Daniel's.

   currentColor stays with the glyph alone, which is what keeps the two tones
   the frame draws: a quiet outline holding a bright mark. --text-strong rather
   than the frame's literal Seafoam, because Seafoam is near-white and would
   vanish on the light card, while strong is white in dark and near-black in
   light — the frame's intent in both. */
/* SCALED, NOT JUST WIDENED. 40x26 kept the glyph at its natural 16px inside a
   wider box, so it filled 40% of the width where the frame fills 50% (a 16px
   glyph in a 32px button) — which is what Daniel's v2-vs-spec comparison
   showed. 40x30 is the frame's 32x24 at 1.25, so the SVG scales to fit and the
   glyph grows with the box instead of sitting in more space.

   The radius scales with it: rx="5.5" on a 32-wide rect is ~17% of the width,
   which is 7px here, rounded to 8. */
/* inline-flex, AND THAT IS NOT COSMETIC. An <a> is inline by default, so
   width and height were being ignored outright: the border hugged whatever the
   inline content measured — a 24px glyph wrapper — with baseline descender
   space under it, which is why Daniel saw it off-centre. It did not show while
   the border lived inside the SVG, because the drawing was the box. */
.v2-od-file-actions a {
    display: inline-flex;
    align-items: center;
    justify-content: center;
    width: 40px;
    height: 30px;
    border: 1px solid var(--colour-flint-200);
    border-radius: 8px;
    color: var(--text-strong);
}

/* The wrapper is SQUARE by default (--v2-od-glyph-size), and these two icons
   are not — 32x24 and 32x23. Given the whole content box instead, the SVG's own
   aspect ratio scales it to fit and centres it, which is what puts the glyph in
   the middle of the button. */
.v2-od-file-actions .v2-od-glyph {
    width: 100%;
    height: 100%;
}

.v2-od-file-actions rect {
    display: none;      /* the border is the rule above now */
}

.v2-od-file-actions a:hover {
    color: var(--brand-primary);
    border-color: var(--brand-primary);
}

/* ── The cancel modals ───────────────────────────────────────────────────
   .v2-field already carries the white fill, the pinned border and the
   colour-scheme every v2 control needs, so the reason box only adds what a
   textarea needs on top of an input: room to type, and a resize handle that
   cannot break the modal's width. */
.v2-od-reason {
    min-height: 96px;
    resize: vertical;
    line-height: 1.4;
}

/* Server-side refusals, shown where the action was taken rather than as an
   alert() — these are business rules ("this order has already moved on"), not
   crashes, and the nurse needs to read one next to the button that caused it.
   Hidden via [hidden] so it occupies nothing until there is something to say. */
.v2-od-modal-error {
    margin: 0;
    font-size: var(--type-body-size);
    font-weight: 500;
    color: var(--intent-danger-text);
}

/* ── Edit history, in the modal ──────────────────────────────────────────
   One session per act of editing, its changes beneath it. The accent is a left
   border rather than an icon — v2 has no icon font, and nine SVG assets for one
   modal is not a trade worth making when the colour and the label already say
   what kind of change it was.

   Four accents, taken from the meaning the rest of the panel already gives
   them: meadow for something gained, sea-pink for something lost, golden-hour
   for something altered, harbour for everything else. Set once as a custom
   property so the border and the label cannot drift apart. */
.v2-od-hist {
    display: flex;
    flex-direction: column;
    gap: var(--space-4);
    margin: 0;
    padding: 0;
    list-style: none;
}

.v2-od-hist-session {
    display: flex;
    flex-direction: column;
    gap: var(--space-2);
    min-width: 0;
}

/* ── Each edit is a row you open ────────────────────────────────────
   The summary carries the three things Daniel asked for at a glance — when,
   who, and how much changed — so the closed list reads as the order's journey
   and the detail is one click in.

   A grid rather than flex, so the DATE column is the same width on every row
   and the dates line up as a column to scan down. auto for the name, then the
   count and the chevron pushed to the right edge. */
.v2-od-hist-toggle {
    display: grid;
    grid-template-columns: auto minmax(0, 1fr) auto auto;
    align-items: center;
    gap: var(--space-3);
    width: 100%;
    padding: var(--space-3);
    border: 1px solid var(--colour-flint-200);
    border-radius: 10px;
    background: none;
    font: inherit;
    text-align: left;
    cursor: pointer;
    transition: background-color 120ms ease;
}

.v2-od-hist-toggle:hover {
    background-color: var(--surface-hover);
}

/* When it happened leads, who did it trails — the date is what somebody scans
   for, the name is what they check once they have found it. */
.v2-od-hist-when {
    margin: 0;
    font-size: 13px;
    font-weight: 700;
    color: var(--text-strong);
    white-space: nowrap;
}

/* No "by" prefix any more: in a column of names under a heading row it reads
   as noise, and the grid already separates it from the date. */
.v2-od-hist-who {
    overflow: hidden;
    text-overflow: ellipsis;
    white-space: nowrap;
    font-size: 13px;
    font-weight: 500;
    color: var(--colour-flint-200);
}

/* Quiet: it is a count, not a label, and it sits beside the affordance. */
.v2-od-hist-count {
    font-size: 12px;
    font-weight: 500;
    color: var(--colour-flint-200);
    white-space: nowrap;
}

.v2-od-hist-chev {
    flex: 0 0 auto;
    --v2-od-glyph-size: 16px;
    color: var(--colour-flint-200);
    transition: transform 160ms ease;
}

/* Points down when shut, up when open. The patient card rotates -90 from a
   down-facing rest; this one rests down too, so the open state is the rotation. */
.v2-od-hist-toggle[aria-expanded="true"] .v2-od-hist-chev {
    transform: rotate(180deg);
}

/* Indented under its own summary, so an open session reads as belonging to the
   row above rather than as a new top-level block. */
.v2-od-hist-body {
    display: flex;
    flex-direction: column;
    gap: var(--space-3);
    padding: var(--space-3) 0 var(--space-2) var(--space-3);
}

.v2-od-hist-row {
    display: flex;
    flex-direction: column;
    gap: 2px;
    padding-left: var(--space-3);
    border-left: 3px solid var(--v2-hist-accent, var(--colour-harbour-light));
    min-width: 0;
}

.v2-od-hist-row[data-accent="meadow"]      { --v2-hist-accent: var(--colour-meadow); }
.v2-od-hist-row[data-accent="sea-pink"]    { --v2-hist-accent: var(--colour-sea-pink); }
.v2-od-hist-row[data-accent="golden-hour"] { --v2-hist-accent: var(--colour-golden-hour); }
.v2-od-hist-row[data-accent="harbour"]     { --v2-hist-accent: var(--colour-harbour-light); }

/* The label carries the accent, so the kind of change is legible without
   reading the border. Small caps rather than a chip: there can be several in a
   session and a stack of filled pills reads as noise. */
.v2-od-hist-label {
    margin: 0;
    font-size: 11px;
    font-weight: 700;
    letter-spacing: 0.04em;
    text-transform: uppercase;
    color: var(--v2-hist-accent, var(--colour-harbour-light));
}

.v2-od-hist-title {
    margin: 0;
    font-size: 14px;
    font-weight: 500;
    color: var(--text-strong);
    overflow-wrap: anywhere;    /* a product name, or a whole address */
}

.v2-od-hist-meta {
    margin: 0;
    font-size: 11px;
    font-weight: 500;
    color: var(--colour-flint-200);
}

/* from -> to, on one line and wrapping when it cannot be. The old value is
   struck through as well as dimmed, because on a quantity change the two
   numbers are otherwise a pair with nothing saying which way round it went. */
.v2-od-hist-change {
    display: flex;
    align-items: baseline;
    flex-wrap: wrap;
    gap: var(--space-2);
    margin: 0;
    font-size: 13px;
}

.v2-od-hist-from {
    color: var(--colour-flint-200);
    text-decoration: line-through;
}

.v2-od-hist-arrow {
    color: var(--colour-flint-200);
}

.v2-od-hist-to {
    font-weight: 600;
    color: var(--text-strong);
}

/* ── EPS barcodes, in the modal ──────────────────────────────────────────
   Reuses the attachment row's shape — a bordered plate with the value on the
   left and its action on the right — because it is the same kind of row and
   the modal sits over the same card. Not shared as one class: an attachment
   row is 64px tall for two lines of metadata and this one has a single line. */
.v2-od-eps-list {
    display: flex;
    flex-direction: column;
    gap: var(--space-2);
    margin: 0;
    padding: 0;
    list-style: none;
}

.v2-od-eps-row {
    display: flex;
    align-items: center;
    justify-content: space-between;
    gap: var(--space-3);
    padding: var(--space-2) var(--space-3);
    border: 1px solid var(--colour-flint-200);
    border-radius: 12px;
}

/* MONOSPACE, because this is a value somebody reads a character at a time to
   type it in again — and in a proportional face a barcode's 1, l and I are
   near enough to cost somebody the retype. anywhere, because it is one long
   unbreakable token and the modal must not widen to fit it. */
.v2-od-eps-value {
    font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
    font-size: 14px;
    font-weight: 500;
    letter-spacing: 0.02em;
    color: var(--text-strong);
    overflow-wrap: anywhere;
    min-width: 0;
}

.v2-od-empty-plate {
    margin: 0;
    font-size: 12px;
    font-weight: 500;
    color: var(--text-muted);
}

/* The head pair — 1795:2559. Both are .v2-button--sm; the fills come from
   --secondary (outline) and --harbour above, so nothing is restated here. */
.v2-od-head-actions {
    display: flex;
    align-items: center;
    gap: var(--space-2);
    flex-wrap: wrap;
}

/* ── ACTIONS: THE ORDER'S SMALL MENU ─────────────────────────────────────
   Jake, 27-Oct: View order PDF became "Actions" — View order PDF and Copy
   order to clipboard. No Figma frame yet; built from what this surface has,
   for review as it stands.

   THE SURFACE IS --surface-card-raised, NOT --surface-raised. In dark,
   --surface-raised, --surface-hover and --border-subtle are all flint-400,
   so a menu on raised would have had no visible edge and no visible hover.
   card-raised is white in light and flint-500 in dark, and both of the
   others show against it. Measured: text 9.2:1 (light) and 8.5:1 (dark) at
   rest, 7.6:1 and 5.9:1 on hover; the focus ring 5.1:1 and 4.1:1.

   A soft 1px border all round and the shadow .v2-field-help already uses —
   no accent edge. */
.v2-od-menu {
    position: relative;
}

.v2-od-menu-chev {
    flex: none;
    transition: transform 120ms ease;
}

.v2-od-menu-toggle[aria-expanded="true"] .v2-od-menu-chev {
    transform: rotate(180deg);
}

.v2-od-menu-list {
    position: absolute;
    top: calc(100% + 6px);
    right: 0;
    z-index: 10;
    display: flex;
    flex-direction: column;
    gap: 2px;
    width: max-content;
    max-width: calc(100vw - 32px);
    padding: 4px;
    background: var(--surface-card-raised);
    border: 1px solid var(--border-subtle);
    border-radius: var(--radius-button);
    box-shadow: 0 8px 24px rgba(0, 0, 0, 0.18);
}

/* The class sets display, which would otherwise beat the attribute. */
.v2-od-menu-list[hidden] { display: none; }

.v2-od-menu-item {
    display: flex;
    align-items: center;
    width: 100%;
    min-height: 36px;
    padding: var(--space-2) var(--space-3);
    background: none;
    border: 0;
    border-radius: var(--radius-button-sm);
    font: inherit;
    font-size: var(--type-body-size);
    font-weight: 500;
    line-height: 1.2;
    color: var(--text-body);
    text-align: left;
    text-decoration: none;
    white-space: nowrap;
    cursor: pointer;
}

.v2-od-menu-item:hover,
.v2-od-menu-item:focus-visible {
    background: var(--surface-hover);
    color: var(--text-strong);
}

.v2-od-menu-item:focus-visible {
    outline: 2px solid var(--border-focus);
    outline-offset: -2px;
}

/* A thumb's worth on touch. */
@media (max-width: 1024px) {
    .v2-od-menu-item { min-height: 44px; }
}

@media (prefers-reduced-motion: reduce) {
    .v2-od-menu-chev { transition: none; }
}

/* ── THE ACTIONS' BODY HOST ──────────────────────────────────────────────
   Empty and collapsed on desktop, where record.js puts the pair in the head
   instead. No display: none — an empty div takes no room anyway, and hiding it
   would mean a second rule to unhide, keyed on the same width the JS already
   reads. One source for that decision is enough.

   The pair itself is .v2-od-head-actions wherever it lands, so its own flex row
   and the mobile rules that make its buttons share the width apply unchanged. */
/* COLLAPSED WHEN EMPTY, and no margin when it is not. .v2-od is a flex column
   with a 20px gap, so an empty host still opened a 20px gap above the progress
   card on desktop — where record.js keeps the pair in the head — and a
   populated one was getting that gap PLUS a margin of its own. The gap is the
   rhythm; this only has to get out of the way. */
.v2-od-actions-host:empty { display: none; }

/* ── A COUNT INSIDE A BUTTON ─────────────────────────────────────────────
   Daniel, 10-Sep: "a small (N) would be good inside the button to show how many
   open queries there are (just as a visual)".

   NOT A NOTIFICATION BADGE. The nav's .v2-nav-badge is a coloured disc that has
   to carry meaning on its own next to an icon; this sits inside a labelled
   button that already says what it counts, so it only has to read as a number
   belonging to that word. A tint of the button's own ink, at the same weight,
   set on the label's baseline.

   INHERITS colour rather than naming one, so it works on all three fills the
   head can hand it — amber here, and whatever a future one is — instead of
   being a fourth thing to keep in step. 22px min-width keeps a 1 and a 10 the
   same shape, and tabular figures stop the box twitching as the count changes. */
.v2-button-count {
    display: inline-grid;
    place-items: center;
    min-width: 22px;
    height: 18px;
    margin-left: 6px;
    padding: 0 5px;
    border-radius: var(--radius-pill);
    background-color: rgb(0 0 0 / 16%);
    color: inherit;
    font-size: 11px;
    font-weight: 700;
    font-variant-numeric: tabular-nums;
    line-height: 1;
}

/* ── Tablet and phone ────────────────────────────────────────────────────
   [frame] OrderDetails/Mobile/Dark 2180:13983, with Justin's own card ids
   quoted per rule. This REPLACES the stopgap that was here — Daniel, 7-Sep:
   "make sure to undo the mobile/Tablet work you created in line with justins
   work as i dont want extra css or anything" — so nothing of the guessed
   version survives except where his frame happens to agree with it.

   THREE DEPARTURES FROM THE FRAME, all Daniel's:
     - the step LABELS stay ours (Stock allocated / Fully Dispatched, not the
       frame's Order dispatched / Out for delivery): "Our step labels are good
       and dont need changing now".
     - NO "< Back to My Orders". The frame puts it inside the card; Daniel
       wants any breadcrumb ABOVE the panel card so the card's own flow is
       unchanged, and concluded that is "too much" for now. It is also a shell
       concern shared with view-patient rather than this panel's.
     - NO "Add attachment" button. classic only accepts an attachment inside
       the Edit flow (ajax-edit-order.php ~1362), so leaving it out loses
       nothing; putting it on a view panel would be new behaviour needing a
       rule for an order past its edit window.

   At the END of the file deliberately: a media query adds no specificity, so
   position is the only thing that decides between it and the rules above. */
@media (max-width: 1024px) {
    /* 280 + 20 + 550 needs 850, which only the desktop panel gives. */
    .v2-od-cols {
        grid-template-columns: minmax(0, 1fr);
    }

    /* AND THE ORDER GOES ON TOP. Side by side the patient reads first because
       it is the left column; stacked, that puts a name and an address above
       the thing the page is actually about. Daniel, 7-Sep: "the order
       information should be above the patient details."

       Done with order rather than by moving the markup, because the DOM order
       is right for desktop and reads correctly either way - the patient card
       is the shorter of the two and belongs first in the source.

       The notes card lives INSIDE the patient column, so it travels with it
       and the stack reads: order, files, patient, notes. */
    .v2-od-col--order   { order: 0; }
    .v2-od-col--patient { order: 1; }
}

/* ── The head pair spans the panel, and swaps ──────────────────────
   Daniel, 7-Sep: "should be View Order left side and Ne wQWuery right side
   and spanning the panel?"

   900px, NOT 640 OR 1024, and the number is not arbitrary: it is the width
   at which .v2-panel-head--control stops being a title-plus-control row and
   becomes two stacked rows (overlays.css, @media max-width 900). Above it
   the pair sits in a content-sized column beside a 36px title and has
   nothing to span; below it the pair owns a full-width row of its own, and
   left as content-sized buttons it was two small controls adrift at the
   left of it.

   THE ORDER SWAPS BY POSITION, because the row is a PAIR and the frame's
   own left-to-right is New query then View order PDF. Keying on the colour
   modifiers would have tied the layout to the fills, which have already
   changed once (New query went amber on 4-Sep). */
@media (max-width: 900px) {
    .v2-od-head-actions {
        /* Nothing left to wrap onto a second line once both buttons flex. */
        flex-wrap: nowrap;
    }

    .v2-od-head-actions > .v2-button {
        flex: 1 1 0;
        /* --sm carries min-width: 80px, which would stop an even split on a
           narrow phone and force the row wider than the panel. */
        min-width: 0;
    }

    /* Actions is a wrapper, not a .v2-button, so it takes its half here and
       its toggle fills it. */
    .v2-od-head-actions > .v2-od-menu {
        flex: 1 1 0;
        min-width: 0;
    }

    .v2-od-head-actions .v2-od-menu-toggle {
        width: 100%;
        min-width: 0;
    }

    /* Under the left half, so it opens from its left edge into the row
       rather than off the side of the screen. */
    .v2-od-menu-list {
        left: 0;
        right: auto;
        min-width: 100%;
    }

    .v2-od-head-actions > :first-child { order: 1; }   /* New query, right */
    .v2-od-head-actions > :last-child  { order: 0; }   /* Actions, left */
}

@media (max-width: 640px) {
    /* ── The progress bar turns VERTICAL ─────────────────────────────────
       [frame] 2180:13301 stacks the four steps with the mark on the left and
       the label and its timestamp to the right, joined by a vertical track.

       AND THE FILL MATHS TRANSFERS UNCHANGED, which is the whole reason this
       is CSS and not a second implementation: a mark sits at (i+0.5)/N along
       the axis whichever axis that is, so --v2-od-fill, --v2-od-fade and the
       part-filled mark all mean exactly what they meant horizontally. Only
       the gradients rotate. */
    .v2-od-steps {
        grid-template-columns: minmax(0, 1fr);
        row-gap: var(--space-4);
    }

    /* Down the left rather than across the top. 22px is the 50px mark's
       centre less half the 6px track, the same sum the desktop rule does on
       the other axis. */
    .v2-od-steps::before {
        top: 0;
        bottom: 0;
        left: 22px;
        right: auto;
        width: 6px;
        height: auto;
        background-image:
            linear-gradient(
                180deg,
                transparent 0,
                var(--colour-meadow) 6%,
                var(--colour-meadow) calc(var(--v2-od-fill, 0%) - var(--v2-od-fade, 0%) / 2),
                transparent calc(var(--v2-od-fill, 0%) + var(--v2-od-fade, 0%) / 2)
            ),
            linear-gradient(
                180deg,
                transparent 0,
                var(--colour-pebble-300) 6%,
                var(--colour-pebble-300) 94%,
                transparent 100%
            );
    }

    /* Grid rather than flex, because the row has THREE children and the last
       two belong in a stack beside the first. Areas do that without asking the
       PHP for a wrapper it does not need on desktop. */
    .v2-od-step {
        display: grid;
        grid-template-columns: 50px minmax(0, 1fr);
        grid-template-areas:
            "mark label"
            "mark when";
        align-items: center;
        column-gap: var(--space-4);   /* [frame] 16px */
        /* 2px, not the frame's 6. Daniel, 7-Sep: "The timestamp needs to be
           slightly closer to its parent" - at 6px with a 16px label line and
           a 16px stamp line the two read as separate rows rather than as a
           label and its date. */
        row-gap: 2px;
        text-align: left;
    }

    .v2-od-step-mark  { grid-area: mark; }
    .v2-od-step-label { grid-area: label; margin-top: 0; }
    /* AND IT GREYS DOWN. --text-muted is pebble-600 in light but pebble-200 in
       dark - LIGHTER than the frame's flint/200 - so on the dark frame the
       stamp was reading as loud as the label above it. Daniel, 7-Sep: "more
       greyed out vs the spec i showed you here". flint/200 is the frame's own
       value and the same correction .v2-od-file-meta already carries. */
    .v2-od-step-when  { grid-area: when; color: var(--colour-flint-200); }

    /* The part-filled ring fills LEFT TO RIGHT on desktop because that is the
       way the bar runs. Vertically the bar runs downward, so the ring should
       too — otherwise a half-done step reads across the flow it belongs to. */
    .v2-od-step[data-state="part"] .v2-od-step-mark {
        background-image: linear-gradient(
            180deg,
            var(--colour-meadow) calc(var(--v2-od-mark-fill, 0%) - 18%),
            var(--colour-pebble-300) calc(var(--v2-od-mark-fill, 0%) + 18%)
        );
    }

    /* ── The items table STAYS A TABLE ────────────────────────────────────
       THE RESTACKING THAT WAS HERE IS GONE. The frame (2180:13453) breaks the
       row into a stack with Quantity and Dispatched as labelled pills below
       the name, and built out it read as a list of little forms rather than a
       list of products. Daniel, 7-Sep: "the order table is off and im not a
       fan... the order product rows with quanitty is horrible."

       His replacement, and it is simpler than the frame: "on mobile drop the
       shipped column (visually) kjeep the quant to the right side. inline with
       the total below so the logic remains SKU | Name | Quantity?" Three
       columns do fit 402px, and Dispatched is the one a clinician has least
       use for on a phone - it is 0 until staff ship, and the progress bar
       above the table already says how far along the order is.

       HIDDEN, NOT DROPPED FROM THE MARKUP, and that is what keeps the column
       count honest: display: none takes the cell out of the heading row, out
       of every body row and out of the total row together, so no colspan has
       to differ by width and the same table serves both layouts. */
    .v2-od-c-disp {
        display: none;
    }

    /* RIGHT, not the desktop centre. Centred under a heading works when there
       are two number columns to line up; with one left it just floats in a
       72px box. Against the card edge it reads as the row's value - and the
       total beneath lines up with it for free, because it is the same column
       of the same table. */
    /* ── AND THE HEADING GOES WITH IT ────────────────────────────────────
       This rule has been here since the mobile pass and only ever moved the
       CELLS: .v2-od-table th sets text-align: left at one class AND one element,
       which outranks a bare class — so the numbers went right and "Quantity"
       stayed left above them. Daniel, 9-Sep: "the Quantity column, the title to
       column is left alligned? can we on mobile do SKu left, Product Left, Quant
       Right?"

       SKU and Product name are left already, so the th selector is the whole of
       it. Desktop untouched: both number columns stay centred there, which is
       what lines them up with each other. */
    .v2-od-c-qty,
    .v2-od-table th.v2-od-c-qty {
        padding-left: var(--space-2);
        text-align: right;
    }

    /* The total row sets itself centred to undo the left it inherits for its
       "Order total" label, so it needs saying again at this width. */
    .v2-od-total .v2-od-c-qty {
        text-align: right;
    }

    /* 8px rather than 12 between the badge and the name. The SKU column keeps
       its 10ch floor - that floor is what starts every product name at the
       same x - and the gutter is where the width comes back from. */
    .v2-od-c-sku {
        padding-right: var(--space-2);
    }

    /* AND THE NAME MUST BE ABLE TO BREAK. It is the only column with a
       flexible width, so it is the one that decides whether the table fits;
       a product name that is one long token would otherwise push the table
       past the card and scroll the panel sideways, which Daniel ruled out
       outright (4-Sep: "never horizontal scroll"). The backstop at the end
       of this file caps the card widths but cannot break a word. */
    .v2-od-item-name {
        overflow-wrap: anywhere;
    }

    /* ── The action row ──────────────────────────────────────────────────
       [frame] 2180:13434 — 44px tall throughout, Cancel reduced to a square
       icon button so Edit and Repeat keep readable labels on a 402px screen,
       and Edit history full width beneath, which is Daniel's own layout:
         [Bin] [ Edit ] [ Repeat ]
         [       Edit History     ]
       The bin glyph is not in the repo yet, so Cancel keeps its word for now
       and simply shares the row — flagged rather than drawn from nothing. */
    /* THE BUTTON COLUMN FILLS THE CARD. Desktop aligns it flex-end and lets
       it hug its content, which is right beside a title on a 550px card. At
       402px it meant the row was only as wide as whatever buttons happened
       to be in it, so flex: 1 1 0 below divided a width that changed with
       the set - three buttons filled the card and two sat in two thirds of
       it, with Edit history stretching under them either way. Daniel, 7-Sep:
       "when the edit button goes, the buttons dont shift dynamically
       creating the mess."

       align-items is the fix rather than a width, because this is a flex
       COLUMN: its children lay out down the cross axis, and stretch is what
       makes them full width. The parent already stretches this column. */
    .v2-od-actions {
        align-items: stretch;
    }

    .v2-od-actions-row {
        display: flex;
        gap: var(--space-2);
    }

    .v2-od-actions-row > .v2-button {
        flex: 1 1 0;
        min-width: 0;
        height: 44px;           /* [frame] 44px, up from the desktop 28px */
    }

    .v2-od-actions > .v2-button:last-child {
        height: 44px;
    }

    /* The head pair takes the same 44px, so a phone has one button height
       rather than a 28px pair above a 44px row. Kept out of the 900px block
       above: on a tablet the frame's compact --sm height still reads right
       beside the title block, and it is only the phone that needs the tap
       target. */
    .v2-od-head-actions > .v2-button,
    .v2-od-head-actions .v2-od-menu-toggle {
        height: 44px;
    }

    /* ── Cards ───────────────────────────────────────────────────────────
       [frame] the section cards are flint/600 at 20px radius with 20px
       padding, and the cards INSIDE them return to flint at the same radius.
       On desktop the inner plates are the teal --v2-od-inner-surface; the
       mobile frame drops that in favour of the panel colour. */
    .v2-od-block {
        padding: var(--space-5);
        border-radius: 20px;
    }

    .v2-od-inner {
        border-radius: 20px;
        padding: var(--space-4) var(--space-3);   /* [frame] py-16 px-12 */
    }

    /* The head row stacks: a 24px title and a reference cannot share 402px. */
    .v2-od-colhead,
    .v2-od-colhead--actions {
        flex-direction: column;
        align-items: stretch;
        gap: var(--space-3);
    }

    /* ONE LINE, as the frame has it: "Created by Jenni Wood | 13/08/2026
       15:44". This rule used to claim that in a comment and only change the
       font size — the two halves stayed stacked and there was no pipe at all,
       which is what Daniel was comparing against the spec. */
    .v2-od-provenance {
        flex-direction: row;
        align-items: baseline;
        gap: var(--space-2);
        font-size: 15px;
    }

    /* THE NAME IS WHAT GIVES when the row runs out of width: it already
       truncates with the full value in a title, whereas half a timestamp is
       unreadable and a clipped pipe is nonsense. */
    .v2-od-provenance > span {
        min-width: 0;
    }

    .v2-od-prov-sep,
    .v2-od-provenance > span:last-child {
        flex: 0 0 auto;
    }

    .v2-od-prov-sep {
        display: inline;
    }

    /* The pipe is doing this label's job now. */
    .v2-od-prov-label {
        display: none;
    }

    /* NO STACKING IN THE FILE ROW. It used to go flex-direction: column here, on
       the theory that a pair of buttons squeezed against a wrapping filename
       would be worse — and it read as the buttons falling out of the row.
       Daniel, 7-Sep: "the icons in the attchement field should be on the right
       side still? they are flexed..."

       So the rule is GONE rather than rewritten: the desktop row is already
       correct at this width. It is space-between with .v2-od-file-text at
       min-width: 0 and .v2-od-file-actions at flex: 0 0 auto, so the name is the
       one that gives and the buttons hold their size on the right. */

    /* One column of pills: two content-sized tracks do not fit beside each
       other in the name column at this width. */
    .v2-od-chips {
        grid-template-columns: minmax(0, auto);
    }
}

/* NOTHING HERE MAY SCROLL THE PANEL SIDEWAYS. Daniel, 4-Sep: "never horizontal
   scroll". The progress track overflowing its card is what did it once; this is
   the backstop so the next thing that tries cannot. */
.v2-od,
.v2-od-cols,
.v2-od-block,
.v2-od-inner {
    max-width: 100%;
}

.v2-od-progress {
    overflow: hidden;
}


/* ══ ORDER QUERIES ══════════════════════════════════════════════════════════
   FRAMES: 1913:10944 desktop dark, 1918:11789 desktop light,
           2183:14530 mobile list, 2183:14756 mobile thread.

   TWO PANES ON DESKTOP, TWO SCREENS ON TOUCH, from one set of rules: the same
   two sections either sit side by side or take turns, and data-view on .v2-q
   is what decides. Nothing below is a per-breakpoint copy of anything above.

   AT THE END OF THE FILE deliberately, as the view-order mobile block is: a
   media query adds no specificity, so file position is what settles a tie
   between the base rules here and the touch block after them. */
.v2-q {
    display: grid;
    /* THE LIST TAKES A THIRD, and it is a fraction rather than a pixel width so
       the split holds at every panel width. 210px was measured off the frame and
       read too narrow in the build — Daniel, 8-Sep: "the left pane should be
       slightly wider and the chat slightly less" (and, the round before, "it
       appears the percentages are off?").

       A CAP AS WELL AS A FRACTION: past about 300px the pane is just a wide
       column of short titles, and the conversation is the thing that wants room.
       minmax lets it grow to a third and stop. */
    grid-template-columns: minmax(0, min(32%, 300px)) minmax(0, 1fr);
    /* ONE ROW, AND IT IS THE HEIGHT AVAILABLE. minmax(0, 1fr) rather than auto is
       what gives both columns a DEFINITE height to scroll inside — with an auto
       row they size to their content and a child's overflow never triggers. The 0
       floor is the half that matters: 1fr alone has a min-content floor, so a long
       conversation would push the row taller than the panel instead of scrolling.

       This is what makes the two columns scroll independently, which is the whole
       ask — Daniel, 8-Sep: "when scrolled, the chat scrolls and goes through the
       fade (left pane untouched) and vice versa when scrolling the left pane." */
    grid-template-rows: minmax(0, 1fr);
    gap: var(--space-5);
    align-items: stretch;
    min-height: 0;
    height: 100%;
}

/* ── TWO ENTRY POINTS, THREE STATES ───────────────────────────────────────
   data-scope says where you came FROM and data-view says what you are looking
   at, and the three combinations that exist are:

     scope=order,  view=list|thread    the frame's two panes. "Queries on this
                   order" beside the thread, one at a time below 900px. This is
                   what Justin drew (1913:10944).
     scope=global, view=list           the nav landing: a full-width list of
                   record cards. No frame exists for it; built from
                   .v2-record-card like My Patients and My Orders, on Daniel's
                   instruction (8-Sep) that the nav "should always land there".
     scope=global, view=thread         THE SAME TWO PANES as the order scope.

   THAT LAST ONE REPLACED A FULL-WIDTH THREAD, and the reason is worth keeping:
   a full-width thread was the first build and it left the conversation with no
   context at all. Daniel, 8-Sep: "going from the qeuries tab it should take you
   directly to that query again, with Other Queries from this Order on the left
   pane?" So opening a card gives you the frame's layout, with the pane holding
   that ORDER's queries — the same component, the same fetch (list_sessions),
   reached from somewhere else.

   Which leaves the landing list as the only single-column state, and it is the
   only one that needs a rule here at all: everything else is the base grid. */
.v2-q[data-scope="global"][data-view="list"] {
    grid-template-columns: minmax(0, 1fr);
    /* The landing is a normal growing list, not a bounded pair of columns —
       collection.js's cards and Load more want the panel to grow and the shell
       to square it off, exactly as on My Orders. So the row goes back to auto
       and nothing here is height-capped. */
    grid-template-rows: auto;
    height: auto;
}

/* The landing list and the two columns are never both wanted. New query is not
   in either of them — it is in the panel head, where the frame has it, and
   queries.js decides whether to show it from whether an order is known. */
.v2-q[data-scope="global"][data-view="list"]   .v2-q-list   { display: none; }
.v2-q[data-scope="global"][data-view="list"]   .v2-q-thread { display: none; }
.v2-q[data-scope="global"][data-view="thread"] .v2-q-mine   { display: none; }
.v2-q[data-scope="order"]                      .v2-q-mine   { display: none; }

/* ── ...EXCEPT WHEN THE ORDER'S OWN PANE HAS BEEN ASKED FOR ──────────────────
   "1 other query on this order" wants the list of THIS order's queries, and in
   the landing scope the list view means the landing: every query on every order.
   Daniel: "its not if you go to the query via the queries tab and not through
   the order?" — exactly, and it is why the control cannot just be the back
   button.

   TWO RULES, AND THAT IS THE WHOLE MECHANISM. An earlier version kept
   view="thread" and hid the thread instead, which meant fighting the panel head,
   New query, the --v2-q-vh height and the bottom padding with a :not() each —
   four rules to keep in step, and the first thing Daniel said was "needs to
   include the heading like the second image has". Being the real list view means
   all of that is simply correct, and this flips the two rules that would
   otherwise show the wrong list.

   (0,4,0) against the (0,3,0) above, so weight settles it rather than order. */
.v2-q[data-scope="global"][data-view="list"][data-others] .v2-q-list { display: flex; }
.v2-q[data-scope="global"][data-view="list"][data-others] .v2-q-mine { display: none; }


/* ── The filter row belongs to the LANDING LIST alone ────────────────────
   Daniel, 8-Sep: "i dont think we need the filter once you are inside an orders
   quiries as you can see them on the left. or a search bar in fairness." Right:
   inside a thread the pane IS the list, and a filter that could empty it under
   you is worse than no filter at all. So the head's row shows for exactly one
   state rather than for one scope. */
.v2-panel[data-scope="order"] .v2-q-filters { display: none; }
.v2-panel[data-view="thread"] .v2-q-filters { display: none; }

/* ── The head's two controls take turns ──────────────────────────────────
   The ViewSlider switches the landing's card grid, so it only means anything
   on the landing list. Assigned to is a property of a conversation, so it only
   means anything in a thread. They share one head slot because they are never
   both wanted — see the note in _order-queries-head.php.

   queries.js hides the assign cell as well, from the other direction: it also
   has to know whether a session is open at all. This rule is the layout half. */
.v2-panel[data-view="thread"]  .v2-view-toggle { display: none; }
.v2-panel[data-scope="order"]  .v2-view-toggle { display: none; }
.v2-panel[data-view="list"]    .v2-q-assign    { display: none; }

/* Nothing selected is only possible in the ORDER scope, where the panel opens
   on an order rather than on a chosen query. Arriving from the landing there is
   always a thread, because a card is what got you here. */
.v2-q[data-scope="global"] .v2-q-none { display: none; }


/* ── The landing ─────────────────────────────────────────────────────────
   .v2-record-grid, .v2-collection-status and .v2-collection-more all come from
   My Patients and carry their own rules, so this section only has to provide
   the gap between the filter row and the cards — the same 20px the frame gives
   .v2-record-section on My Orders. */
.v2-q-mine {
    display: flex;
    flex-direction: column;
    gap: var(--space-5);
    min-width: 0;
}

/* ── THE FILTER ROW, WHICH LIVES IN THE PANEL HEAD ────────────────────────
   It was in the body first, and that was wrong twice over.

   IT CLIPPED THE DROPDOWN. Daniel, 8-Sep: "same issue with the drop down not
   overlaying the panel here." .v2-panel-body is overflow-y: auto, so it is a
   scroll container, and an absolutely-positioned .v2-combo-list cannot escape
   one — the options were cut off at the panel edge. The panel head sits
   OUTSIDE .v2-panel-body, which is exactly why My Orders' filters do not have
   this problem and why .v2-panel[data-v2-overflowing] is the only clip left to
   reason about (see overlays.css).

   AND IT WAS ALREADY THE RULE. _order-filters.php moved out of the body for
   its own reason — Daniel, 18-Aug: "could it not just be there by stanadard
   then there would be no hard shift?", because the head is the pinned region
   and a filter you lose on scroll is worse than one that never moved. Put in
   the body here on the reasoning that the head is shared with the order scope,
   which was a layout worry answered by one selector instead.

   HIDDEN OUTSIDE THE LANDING LIST: applyScope() in queries.js writes data-scope
   AND data-view onto the PANEL, not only onto .v2-q, so the head can read both.
   The two rules that do it are up beside the scope switch. */

/* Search takes the room; the status select keeps a definite width and wraps
   under it when there is not enough for both. */
.v2-q-filters {
    display: flex;
    align-items: center;
    gap: var(--space-3);
    flex-wrap: wrap;
}

.v2-q-filters .v2-search {
    flex: 1 1 260px;
    min-width: 0;
}

/* ON THE CELL, NOT THE <select> — the same trap as .v2-q-assign and
   .v2-order-filter-cell above: select.js hides the native control and inserts a
   .v2-combo at width:100% beside it, so a width set on .v2-select styles the
   one element that is display:none by then. */
.v2-q-filter-cell {
    display: inline-flex;
    flex: 0 0 auto;
    width: 200px;
}



/* NO .v2-q-main AND NO .v2-q-new. New query is back in the panel head beside
   Assigned to, which is the frame's placement — it spent a round in the body and
   pushed the conversation card out of line with the query list. .v2-q-head styles
   it now, with the rest of the head's controls. */

/* ── THE LEFT COLUMN: the query list ──────────────────────────────────────
   HUGS ITS CONTENT, THEN SCROLLS. flex: 0 1 auto inside the stretched grid area
   is what does both — the card is only as tall as its rows until they exceed the
   space, and then min-height: 0 lets it be shorter than them so it can scroll.
   Which keeps the frame's short card while satisfying "vice versa when scrolling
   the left pane". */
.v2-q-list {
    align-self: start;
    max-height: 100%;
    min-height: 0;
    overflow-y: auto;
    padding: var(--space-4);
    display: flex;
    flex-direction: column;
    gap: var(--space-3);
    min-width: 0;
}

/* The title stays put while the rows move under it. */
.v2-q-list-title { position: sticky; top: 0; }

/* A HEADING COLOUR, not the brand one. It used to carry .v2-section-title as
   well, which is the 24px panel heading in --brand-primary — so a 15px pane
   label came out harbour 400 in dark mode. Daniel, 8-Sep: "Quries on this order
   in dark mode is in blue, should b in whiite?" --text-strong is seafoam 50 in
   dark and flint 700 in light, which is what every other heading uses. */
/* The disclosure is a phone thing: on desktop the pane is on screen already. */
.v2-q-others { display: none; }

.v2-q-list-title {
    margin: 0;
    font-size: 15px;
    font-weight: 700;
    color: var(--text-strong);
}

.v2-q-threads {
    display: flex;
    flex-direction: column;
    gap: var(--space-2);
    margin: 0;
    padding: 0;
    list-style: none;
}

/* THE ROW IS THE HIT AREA, not the title inside it.

   A COLUMN ON DESKTOP, where the pane is a 210px strip and the title needs all
   of it: a title beside a pill there read "Rede: Temp" with the badge across it.
   Nothing else is in the row at that width — the pill is hidden and the order
   moved to the subtitle — so the column is a column of one.

   Below 900px it becomes a row with the pill beside the title, which is the
   mobile frame (2183:14530) and has the width for it. */
.v2-q-thread-row {
    display: flex;
    flex-direction: column;
    align-items: flex-start;
    gap: 4px;
    width: 100%;
    /* 12px RADIUS, 8px OF VERTICAL PADDING. The radius was 8 and too square
       ("more rounder edges i believe"); the padding then went to 12 all round and
       was too tall ("seems to have more vertical padding than spec"). The frame's
       row is a wide, shallow fill — more horizontal inset than vertical — so the
       two are not the same number. */
    padding: var(--space-2) var(--space-3);
    border: 0;
    border-radius: 12px;
    background: none;
    font: inherit;
    font-size: 14px;
    font-weight: 500;
    color: var(--text-muted);
    text-align: left;
    cursor: pointer;
    transition: background-color 120ms ease, color 120ms ease;
}

/* ── WHICH ONE IS THE LIVE QUERY, WITHOUT OPENING IT ────────────────────────
   Daniel: "should have a grey highlight container on the most recent query in
   the list as an identifier."

   It earns its place on a phone, where nothing is auto-opened and the list is
   the whole screen: without it every row looks equally like the one to tap.
   Written on the row by queries.js from leadSession(), which is the same
   function that decides which thread desktop opens — so the marker cannot point
   at a row the panel would not go to.

   BEFORE :hover AND BEFORE aria-current, DELIBERATELY. All three are one class
   plus one attribute or pseudo-class, so all three weigh (0,2,0) and source
   order is the entire mechanism. Reading down the file: recent is weakest, hover
   beats it, the open row beats both. That is the right precedence — "this is the
   newest" is a standing fact, where hover and selection are things happening
   now, and a fact should not shout over an action.

   --surface-inset rather than a Seafoam step: it is the neutral Pebble/Flint
   family, which is what makes it read as GREY against the panel in both themes
   instead of as the same surface at a different height. That is the role it was
   defined for (semantic.css). No accent and no weight change: an identifier, not
   a status — the pill already says the status. */
.v2-q-thread-row[data-recent] {
    background-color: var(--surface-inset);
    color: var(--text-strong);
}

.v2-q-thread-row:hover {
    background-color: var(--surface-hover);
    color: var(--text-strong);
}

/* The open thread is a filled harbour row. Keyed on aria-current rather than a
   class of its own: the state is already in the ARIA, and two sources drift. */
/* NO BOLD ON THE OPEN ROW — Daniel, 8-Sep: "The query title in the blue container
   is bold, in spec its not." The fill is what says which one is open; weight on
   top of it is saying it twice, and it also made the row jump width as the
   selection moved. */
.v2-q-thread-row[aria-current="true"] {
    background-color: var(--colour-harbour-light);
    color: var(--text-on-brand);
}

/* IT WRAPS, TO TWO LINES, and then it stops. A 210px pane cannot hold a real
   query title on one line, so cutting it at one produced the "Rede: Temp"
   Daniel saw. Two lines is enough to tell two queries apart, and the clamp
   stops a long one pushing the pill and the order off the row.

   -webkit-box is not a legacy prefix here: line-clamp only works in that
   display mode and it is what every engine implements. */
/* WHAT REPLACED THE PILL. The frame's pane rows are plain titles with the open
   one filled, and that leaves nothing to say a query is finished — so a resolved
   row is dimmed. Quieter than a badge, and it matches how the frame greys the
   rows you are not reading. data-status comes off the row in queries.js. */
/* THE ROW PILL IS A MOBILE-ONLY THING. The desktop pane is a 210px column and
   a badge across the title is what made it read "Rede: Temp" (Daniel: "i dont
   like the pill"). The mobile frame 2183:14530 draws them and the list is the
   whole screen there, so it comes back at that width and only that width.

   display: none rather than visibility, so it leaves the accessibility tree
   with the pixels — on desktop the dimming below is what says "resolved", and a
   screen reader should hear one of the two, not both. */
.v2-q-row-pill { display: none; }

.v2-q-thread-row[data-status="resolved"] { opacity: 0.62; }

.v2-q-thread-row[data-status="resolved"][aria-current="true"] { opacity: 1; }

.v2-q-thread-name {
    display: -webkit-box;
    -webkit-box-orient: vertical;
    -webkit-line-clamp: 2;
    line-clamp: 2;
    overflow: hidden;
    min-width: 0;
    /* A reference number with no spaces in it must not widen the pane. */
    overflow-wrap: anywhere;
}



/* READ STATE AS A MARKER, NOT A NUMBER. Daniel, 8-Sep: "the read is just a
   helper tool" — the nav badge counts what is WITH the clinician, and this only
   says "there is something here you have not seen". A dot rather than a count
   for that reason: the quantity of unread messages is not the point.

   aria-label on the element carries it for a screen reader; it is not
   aria-hidden, because the information is real and is not written anywhere else
   in the row. */
.v2-q-unread {
    display: inline-block;
    width: 7px;
    height: 7px;
    margin-right: 6px;
    border-radius: 50%;
    background-color: var(--colour-sea-pink);
    /* Sits on the title's baseline rather than the line box, so it reads as
       attached to the words and not floating above them. */
    vertical-align: middle;
}

.v2-q-thread-row[aria-current="true"] .v2-q-unread {
    background-color: currentColor;
}

/* The same dot inside a record card's name, where the type is larger. */
.v2-record-name .v2-q-unread {
    width: 8px;
    height: 8px;
    margin-right: 7px;
}

/* ── The status pill, IN THE THREAD HEAD ONLY ─────────────────────────────
   THREE TONES WHERE THE FRAMES DRAW TWO. Justin has Outstanding (sea-pink)
   and Resolved (meadow). The service also has awaiting_nurse, which classic
   surfaces as "Query awaiting your reply" and is the one state that needs the
   clinician to act — so it takes golden-hour, this system's "this one is
   yours" colour, already carrying New query. Dropping it would lose a
   feature classic has.

   AND NOT ON THE PANE ROWS, which is also where the frame does not have it:
   the rows there are plain titles with the open one filled. A short
   Open/Resolved pill was tried on them and taken out again — Daniel, 8-Sep: "i
   dont like the pill". A resolved row is dimmed instead, below. */
/* Between the two it has been: 11px was too small once it left the pane rows,
   13px too big — Daniel, 8-Sep: "The status pill top right can be slightly
   smaller?" */
.v2-q-pill {
    flex: 0 0 auto;
    padding: 4px var(--space-2);
    border-radius: var(--radius-pill);
    font-size: 12px;
    font-weight: 700;
    white-space: nowrap;
    color: var(--text-on-brand);
    background-color: var(--colour-harbour-light);
}

.v2-q-pill[data-tone="sea-pink"] { background-color: var(--colour-sea-pink); }

.v2-q-pill[data-tone="golden-hour"] {
    background-color: var(--colour-golden-hour);
    color: var(--text-on-accent);
}

.v2-q-pill[data-tone="meadow"] {
    background-color: var(--colour-meadow);
    color: var(--text-on-accent);
}

/* ── The thread ───────────────────────────────────────────────────────────── */
.v2-q-thread {
    display: flex;
    flex-direction: column;
    /* NO 30px GAP. .v2-card sets one — it is the space every Section/FormField in
       the file puts between its heading and its content — and a flex container
       honours gap just as a grid does, so the conversation started 30px below the
       title however tight the head's own padding got. That gap is what "the fade
       is too low down the chat" was, asked three times: the head padding came down
       twice and was never the thing holding it open. */
    gap: 0;
    padding: var(--space-4);
    min-width: 0;
    /* min-height: 0 lets this card be shorter than the conversation inside it,
       which is what makes .v2-q-messages the scroller and keeps the composer
       beneath it on screen — Daniel, 8-Sep: "the text box to type in should never
       drop off the bottom of the screen." It fills its grid area, so no flex
       sizing is needed. */
    min-height: 0;
}

/* NO .v2-q-back — the panel had its own "< Queries" link and it is gone.
   Daniel, 8-Sep: "the < Queries is redundant. we have the nav on desktop and
   the back button on mobile for that?" The shell's .v2-back now treats the
   thread as a step (overlays.js asks the panel with a cancelable v2:panel-up),
   so there was nothing left for this to do. */

/* NO RULE UNDER THE TITLE. The frame does not draw one (1913:10944) and
   Daniel, 8-Sep: "lose the line seperator in chat between title and chat. per
   spec." The scroll fade on .v2-q-messages is what separates the head from the
   conversation now, and only when there is something to separate it from. */
/* A COLUMN, because .v2-q-others sets its own align-self (flex-END since
   10-Sep, so it lands under the status pill) and the title row and order line
   stack under it. No gap: each child owns its own spacing, and a gap here would
   apply to the two that are hidden on desktop as well.

   align-self on the children rather than align-items here, deliberately: the
   title row has to STRETCH or the pill loses its right edge and the title stops
   eating the slack. */
.v2-q-thread-top {
    display: flex;
    flex-direction: column;
    min-width: 0;
}

/* ── THE ORDER REFERENCE IS A PHONE THING ───────────────────────────────────
   Daniel: "on mobile. unlike desktop. there is no direct button to go to the
   order view when in the query? no reference of the order number?"

   Desktop has this already, in the panel subtitle: "SO#… | Patient" with the
   reference as a button into the order. A phone hides the panel head to give
   the thread the whole screen, and the subtitle went with it.

   So it is display: none here and shown only inside the mobile thread block —
   exactly the widths where .v2-panel-head is hidden, so precisely one of the
   two is ever on screen and there is never a second copy to notice. */
.v2-q-thread-order { display: none; }

/* Line 2 of the thread screen's header — and ONLY of that screen's. On desktop
   the panel head is on show and carries the way back, so this row would be a
   second copy of it. Shown in the mobile thread block, where the head is not. */
.v2-q-thread-nav { display: none; }

.v2-q-thread-head {
    display: flex;
    align-items: center;
    justify-content: space-between;
    gap: var(--space-3);
    /* TIGHT UNDER THE TITLE — asked for twice: "have the fade a little closer
       to the title / status pill", then "the fade is too low down the chat".
       The conversation's own top padding is zero now, so this is all of it. */
    padding-bottom: 6px;
}


.v2-q-thread-title {
    margin: 0;
    min-width: 0;
    font-size: 20px;
    font-weight: 700;
    color: var(--text-strong);
    overflow: hidden;
    text-overflow: ellipsis;
}


/* ── The messages ─────────────────────────────────────────────────────────
   THE CONVERSATION SCROLLS ON ITS OWN, independently of the pane beside it —
   Daniel, 8-Sep: "the chat box should have an independant scroll to the pane on
   the left side." So the composer stays put while the messages move under it,
   and a long thread never drags the query list off screen.

   A CAP, SO THE PANEL STILL BEHAVES LIKE EVERY OTHER ONE. There was a detour
   through a full-height panel (data-v2-fill) to get it to square off at the
   bottom, and Daniel corrected it: "that doesnt match my patients and my orders,
   if the panel is genuinely short then it folds rounded... if it has content
   enough to span length of page then it falls off the page like my orders and my
   patients does."

   Which is right, and it needs nothing special: the panel grows with its
   content and .v2-panel[data-v2-overflowing] squares the edge off when that
   content spans the fold, exactly as on My Patients. The cap here bounds the
   conversation so it does not run away with the page, and the pane, the head
   and the composer around it all still count toward the panel's own height —
   so a long query list, or a short window, still tips the panel into spanning
   and squaring off on its own.

   min-height: 0 is what lets this box shrink below its content and therefore
   scroll at all. */
.v2-q-messages {
    flex: 1 1 auto;
    min-height: 0;
    overflow-y: auto;

    /* ── THE SAME FADE THE PANEL HEAD HAS ────────────────────────────────
       Daniel, 8-Sep: "the fade out on scroll should be there too just like the
       design we have in the opther panels."

       A MASK rather than the head's scrim-plus-blur pair, and for a reason:
       the head's ::before fades to var(--surface-page) because it sits over
       the panel, and this box sits over a CARD — a gradient in the page colour
       would be visibly the wrong grey against it. A mask fades the content to
       transparent instead, so whatever the card is stays correct in both
       themes and needs no second colour to keep in step.

       ONLY WHILE SCROLLED, which is the rule the head already follows (Daniel,
       14-Aug: "the fade bleeds into the first container? it should only really
       activate on scroll no?"). At scrollTop 0 there is nothing above to hide
       and the fade would just eat the first message. queries.js sets
       data-scrolled on this element. */
    transition: -webkit-mask-image 140ms ease, mask-image 140ms ease;
    /* overflow-y alone is not enough: a box with overflow-y set and overflow-x
       left at visible computes overflow-x to auto, and the bubble tails hang
       6px past their bubble — which grew a horizontal scrollbar under the
       conversation. The side padding below keeps the tails from being clipped
       by the hidden that fixes it. */
    overflow-x: hidden;
    /* EXPLICIT, because overflow-y on its own is not enough: a box with
       overflow-y set and overflow-x left at visible computes overflow-x to
       auto, and the bubble tails hang 6px past their bubble — so the thread
       grew a horizontal scrollbar under the conversation (Daniel, staging,
       8-Sep). The side padding below is what keeps the tails from being
       clipped by the hidden that fixes it. */
    overflow-x: hidden;
    display: flex;
    flex-direction: column;
    gap: var(--space-4);
    /* ── THE TOP PADDING IS THE REST GAP, AND IT SCROLLS AWAY ────────────
       Two things that sound contradictory and are not. Daniel, 8-Sep: "the gap
       between the title and pill in the chat is to tight. the fade should be
       close but the chat shouldnt get that close every. only on scroll when the
       fade appears should the chat scroll up and fade out below the pill and
       title."

       The FADE has to start where this box starts, just under the title — so
       the box itself stays tight up against the head. The CONVERSATION should
       sit lower than that when it is not scrolled.

       Padding on a scroll container is part of its scrollable content, so this
       does both: at rest the first message sits 20px down, and scrolling takes
       that padding away with everything else, carrying the message up under a
       fade that has not moved. A margin on the first message, or a gap on the
       flex column, would have done the same — padding is simply the one that
       does not care which element happens to be first.

       No bottom padding: the composer is in flow below this box, so it ends
       where the composer begins. */
    padding: var(--space-5) var(--space-2) 0;
}

/* ── A SHORT CONVERSATION SITS AT THE BOTTOM OF ITS BOX ───────────────────────
   Daniel, 9-Sep: "i seem to be able to scroll down creating a huge gap between
   the last message sent and the composer box?" — and then, with a screenshot of
   what he wanted: "i would want it fixed like this at the bottom from the last
   message, naturally pushing up as i enter a new message into the chat or a new
   message is received".

   The box is a flex COLUMN filling the room between the head and the composer,
   so a conversation shorter than that room stacked from the TOP and left the
   rest empty — under the last message, above the composer, which is the gap. It
   became obvious the moment the head started giving way for the keyboard,
   because that made the box taller.

   margin-top: auto ON THE FIRST MESSAGE, not justify-content: flex-end on the
   box. Those look equivalent and are not: with flex-end, content TALLER than the
   box overflows the top and several browsers make that overflow unreachable — the
   first message would be unscrollable-to, which is the fault Daniel has spent the
   afternoon reporting in another form. An auto margin absorbs free space when
   there is some and resolves to zero when there is none, so a full conversation
   scrolls exactly as it did.

   It also gives the growth direction he described for nothing: anchored at the
   bottom, each new message pushes the older ones up.

   EVERY WIDTH, not just a phone — a two-message query had the same gap on
   desktop, and messages hugging the composer is right in both. */
.v2-q-messages > :first-child { margin-top: auto; }

/* 28px rather than 40: a shorter run reads as the title's own edge rather than
   a band of its own, and it gives back a line of conversation. */
.v2-q-messages[data-scrolled] {
    -webkit-mask-image: linear-gradient(to bottom, transparent 0, #000 28px);
            mask-image: linear-gradient(to bottom, transparent 0, #000 28px);
}

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

.v2-q-msg {
    display: flex;
    flex-direction: column;
    align-items: flex-start;
    max-width: 78%;
}

/* Mine right, theirs left — frame 1913:10944. */
.v2-q-msg--mine {
    align-items: flex-end;
    align-self: flex-end;
}

/* ── AND THE NURSE'S OWN MESSAGES ARE BLUE ────────────────────────────────────
   Daniel: "the nurses messages should be in the blue bubbles. all are white
   right now". They were, and I deleted this rule: it sat immediately above the
   ::after block, and one of the cuts I made while chasing the tail took it with
   the apparatus around it.

   Which is the risk in every large index-based splice I have made today. The
   guards I wrote checked that what I MEANT to remove was gone; none of them
   checked what else went with it, and a colour rule three lines above the
   anchor is exactly what that misses. */
.v2-q-msg--mine .v2-q-bubble {
    background-color: var(--colour-harbour-light);
    color: var(--text-on-brand);
}

/* The tail is a pseudo-element, not an image: it has to take the bubble's own
   colour and there are two of those. */
.v2-q-bubble {
    position: relative;
    padding: var(--space-3) var(--space-4);
    /* 18px, AND NOT THE FRAME'S 36. Node 2183:14811 measures r=36, but it measures
       it on a 280 x 84 bubble — three lines of text — where 36 is a proportionate
       corner. Applied as a fixed radius it is not: CSS clamps a radius to half the
       shorter side, so a one-line bubble becomes a lozenge and an attachment chip
       becomes a blob. Daniel, 9-Sep: "smaller messages get weirdly shaped. should
       always be the traditional message bubble".

       THE FRAME HAS NO SHORT MESSAGE IN IT to have caught this — both its bubbles
       are the same three-line block — so 36 is a measurement of one case rather
       than a rule, and I applied it as though it were the rule. Reading a value
       correctly is not the same as knowing what it governs.

       18 reads as a bubble at every height, which is the actual requirement, and
       it sits between the frame's softness and the 12 this had before. It also
       makes the tail join better: at 14.5px in from the edge a 36px arc has lifted
       the bottom edge ~7px, which the tail's shoulder had to cover, where an 18px
       arc lifts it by a third of a pixel. */
    border-radius: 18px;
    font-size: 15px;
    font-weight: 500;
    line-height: 1.4;
    /* A pasted message keeps its line breaks; an unbroken order reference must
       not push the bubble past the card. */
    white-space: pre-wrap;
    overflow-wrap: anywhere;
    /* ── AND IT IS A THEMED COLOUR, NOT ONE VALUE ──────────────────────────
       Daniel: "in light mode the patient choice messages have a faded teally
       colour against the white background."

       Because --colour-seafoam is #EFF7F5, which IS --surface-page in light
       mode: seafoam-200, the same value. The bubble was the exact colour of the
       page behind it. In dark mode the same token is a near-white bubble on a
       Flint panel and reads correctly, which is why it only showed in one
       theme, and why a single value was never going to serve both.

       THE FRAMES GIVE BOTH. OrderQueriesMessage/Mobile/Light fills the incoming
       bubble #D8EDE9 (= seafoam-300, one step darker than the page it sits on),
       and the Dark frame keeps it near-white. --v2-q-theirs carries the pair. */
    background-color: var(--v2-q-theirs);
    /* --colour-flint (#3A4A50), NOT flint-700 (#232C30). Daniel: "is th etext
       weight in the chat, seems heavy on v2 compared to spec" — and the WEIGHT is
       already right: ours is 500 and both frames specify Roboto Medium. It was the
       colour. flint-700 is two steps darker than the frames' own
       colour/flint, and at a fixed weight darker type reads as heavier, which is
       exactly the word he used.

       Both message frames agree on it: OrderQueriesMessage/Mobile/Light and the
       Dark one both put the incoming bubble's text at colour/flint. */
    color: var(--colour-flint);
}

/* ══ THE TAIL, THE FRAME'S OWN OUTLINE ════════════════════════════════════════
   Daniel: "use the URL from figma, we need the excat way it is in there. it
   shouldnt be this difficult". Agreed on both counts. This is node 2183:14811's
   own path, and the reason it took eight attempts is worth writing down because
   it was never the geometry.

   THE FRAME DRAWS BUBBLE AND TAIL AS ONE VECTOR — "remember the tails are all
   one image" — 280 x 90.946, unioned. The tail portion runs x 251.8 to 265.5, y
   83.2 to 90.94; translated into its own box that is the path below, with 4.28px
   of straight shoulder on top to overlap up into the bubble (the frame does not
   need a shoulder because it has no join to hide).

   AND THE ENCODING IS WHY IT KEPT DISAPPEARING. I shipped this same path as a
   mask twice with raw <, > and " in the data URI. Safari will not parse that, an
   unparsable mask-image is simply NO mask, and no mask on a 14x12 box is the flat
   rectangular tab Daniel photographed. Then I read "no change" as the shape being
   wrong and went on adjusting numbers inside a rule that was never applying.

   components.css:4997 had the correct form all along: %3C and %3E for the angle
   brackets, single quotes for attributes, %23 for a hash. Copying the pattern this
   file already used would have saved most of a day.

   A MASK RATHER THAN AN IMAGE, so background-color: inherit still colours it: one
   rule serves incoming and outgoing, both themes, with no second asset and no
   colour to keep in step. And one path mirrored with scaleX(-1), so there is a
   single shape to correct.

   THE ONE PLACE THIS STILL DIVERGES: the frame's bubble has r=36 corners, and at
   36 a one-line bubble becomes a lozenge (Daniel, earlier: "smaller messages get
   weirdly shaped"). Ours stays at 18, so the tail meets a flatter edge than it
   does in the frame. The tail itself is the frame's, exactly. */
.v2-q-bubble::after {
    content: "";
    position: absolute;
    /* 4.28 of the 12 is shoulder inside the bubble, 7.72 protrudes. */
    bottom: -7.72px;
    width: 14.2px;
    height: 12px;
    background-color: inherit;

    -webkit-mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 14.2 12' preserveAspectRatio='none'%3E%3Cpath d='M0 0H12.78V4.28C12.24 4.36 11.78 4.76 11.78 5.3C11.78 6.83 11.93 7.34 12.41 8.35C12.63 8.8 13.19 9.63 13.68 10.27C14.17 10.91 14.01 12.1 12.75 12C12.33 11.97 10.65 11.23 8.69 10.15C6.42 8.9 3.68 6.8 2.97 6.3C1.64 5.37 0.99 5.06 0 5.06Z' fill='black'/%3E%3C/svg%3E");
            mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 14.2 12' preserveAspectRatio='none'%3E%3Cpath d='M0 0H12.78V4.28C12.24 4.36 11.78 4.76 11.78 5.3C11.78 6.83 11.93 7.34 12.41 8.35C12.63 8.8 13.19 9.63 13.68 10.27C14.17 10.91 14.01 12.1 12.75 12C12.33 11.97 10.65 11.23 8.69 10.15C6.42 8.9 3.68 6.8 2.97 6.3C1.64 5.37 0.99 5.06 0 5.06Z' fill='black'/%3E%3C/svg%3E");
    -webkit-mask-repeat: no-repeat;
            mask-repeat: no-repeat;
    -webkit-mask-size: 100% 100%;
            mask-size: 100% 100%;
}

/* The frame puts it 14.5px in from the bubble's edge, not on the corner. */
.v2-q-msg .v2-q-bubble::after {
    left: 14.5px;
    transform: scaleX(-1);
}

/* Mine: the tail on the RIGHT, as the path is drawn (Daniel, 5-Oct: "the tail
   on the blue speech bubble should be on the right side no?"). Every bubble is
   also .v2-q-msg, so the incoming rule above applied here too: its left: 14.5px
   beat this right (with a fixed width, left wins) and its mirror flipped the
   tail. Both undone. */
.v2-q-msg--mine .v2-q-bubble::after {
    left: auto;
    right: 14.5px;
    transform: none;
}

/* The frame's caption: "Name, Nurse | 25 Aug 09:56" under the bubble — smaller
   than the first pass and inset from the pane edge rather than flush to it
   (Daniel, 8-Sep: "the message info below each message should be smaller and
   matching the spec (not aligned to edge of pane)").

   The inset is padding rather than a margin because .v2-q-msg is the flex box
   doing the left/right alignment: a margin would fight it, padding just moves the
   text inside the box that is already in the right place. */
/* CENTRED UNDER THE MESSAGE, not aligned to its edge — Daniel, 8-Sep: "spec has
   them flaoting more central below the message". align-self overrides the
   left/right alignment .v2-q-msg gives everything else in the column, so the
   caption sits under the middle of the bubble whichever side it is on. */
/* ── UNDER THE MESSAGE, NOT UNDER THE MIDDLE OF IT ───────────────────────────
   Daniel: "the message info, for the patient choice the text is seperated from
   the message as its too central?"

   This was align-self: center, which puts the caption under the bubble's
   midpoint — so on a wide incoming bubble it drifts away from the text it
   belongs to and reads as its own line rather than as a footnote. flex-start
   keeps it under the bubble's left edge, where the eye already is after the
   last line of the message.

   THE MOBILE BLOCK HAS SAID THIS SINCE 9-Sep and even argues the case in a
   comment: "flex-start on both... That is what the frame draws and it also
   reads better". Desktop simply never got the same correction, because I read
   "more central below the message" off the desktop frame and never revisited it
   after the mobile frame contradicted me. Same value both ways now, so there is
   one answer rather than two. */
.v2-q-cap {
    align-self: flex-start;
    margin: 6px 0 0;
    font-size: 10px;
    font-weight: 500;
    color: var(--colour-flint-200);
}

/* ── A system note ────────────────────────────────────────────────────────
   NOT A BUBBLE, because nobody said it. The resolve note, the auto-resolve
   note and the 72h chase all arrive as is_system = 1 messages; they explain
   why a thread stopped, so they belong in the timeline, but as a marker
   across it rather than as somebody speaking. Centred, ruled, and quiet. */
.v2-q-sys {
    display: flex;
    align-items: center;
    gap: var(--space-3);
    align-self: stretch;
}

/* The rules either side are pseudo-elements on the flex row, so the text
   keeps its intrinsic width and the lines take whatever is left. */
.v2-q-sys::before,
.v2-q-sys::after {
    content: "";
    flex: 1 1 auto;
    height: 1px;
    background-color: var(--colour-flint-200);
    opacity: 0.4;
}

.v2-q-sys-text {
    flex: 0 1 auto;
    font-size: 11px;
    font-weight: 500;
    color: var(--colour-flint-200);
    text-align: center;
}

/* Where a message came from, when a merge moved it here. Migration 171 keeps
   the originating session on the row for exactly this. */
.v2-q-merged {
    padding: 1px 6px;
    border-radius: var(--radius-pill);
    background-color: var(--surface-hover);
    font-size: 10px;
    font-weight: 700;
}

/* IT OVERLAPS THE BUBBLE ABOVE IT, slightly — "i think slightly overlap any
   accompanying message". The negative margin is what ties the file to the
   message it came with rather than leaving it floating under it, and the
   z-index keeps it over the bubble's own tail. */
.v2-q-files {
    position: relative;
    z-index: 1;
    display: flex;
    flex-wrap: wrap;
    gap: var(--space-2);
    margin-top: -6px;
    max-width: 100%;
}

/* A SENT ATTACHMENT: the icon AND the name, truncated. Daniel, 8-Sep: "i think
   slightly overlap any accompanying message and do include the file name ...if
   its too long (longer than 15/20 characters?" — so the name is back, capped at
   about twenty characters' worth of width and ellipsised past that, with the
   full name still on hover and in the accessible name.

   Which settles a point that went back and forth: icon-only was right about the
   MESSAGE being the content, and wrong about the file being unidentifiable. A
   short name is identification; a long one truncates without costing anything,
   because the tooltip and the screen-reader label both carry the whole thing. */
.v2-q-file {
    display: inline-flex;
    align-items: center;
    gap: 6px;
    max-width: 100%;
    padding: 4px var(--space-2) 4px 6px;
    border: 1px solid var(--border-subtle);
    border-radius: 8px;
    background-color: var(--surface-card);
    font-size: 11px;
    font-weight: 500;
    color: var(--text-strong);
    text-decoration: none;
}

/* ~20 characters at 11px. A cap in ch units rather than px so it tracks the
   font rather than needing a new number if the type changes. */
.v2-q-file-name {
    max-width: 20ch;
    overflow: hidden;
    text-overflow: ellipsis;
    white-space: nowrap;
}

/* One drawing of a file, from fileIcon() in queries.js, at two sizes. */
/* ── THE ORDER NUMBER IN THE SUBTITLE IS A CONTROL ────────────────────────
   Daniel, 8-Sep: "can we also make the order number clickable for the nurse to
   be able to go to the order from the query?" It reads as the subtitle it is
   part of, not as a button — the underline on hover is what says it does
   something, the same way a link in prose does. */
.v2-q-sub-order {
    padding: 0;
    border: 0;
    background: none;
    font: inherit;
    color: inherit;
    cursor: pointer;
    text-decoration: underline;
    text-decoration-color: transparent;
    text-underline-offset: 3px;
    transition: text-decoration-color 120ms ease, color 120ms ease;
}

.v2-q-sub-order:hover,
.v2-q-sub-order:focus-visible {
    color: var(--colour-harbour-light);
    text-decoration-color: currentColor;
}

/* The seen tick, a shade stronger than the caption around it so it reads as a
   state rather than more text. */
.v2-q-seen { color: var(--colour-harbour-light); font-weight: 700; }

.v2-q-file-icon { display: inline-flex; }
.v2-q-file-icon svg { display: block; }

.v2-q-file:hover { background-color: var(--surface-hover); }

/* ── Files chosen but not yet sent ────────────────────────────────────────
   IT FOLDS OUT OF THE FIELD, which is the shell's established motion for
   anything that appears attached to an input — see the note above
   .v2-combo-list (Daniel, 26-Aug: "expanding from the field and closing back
   into the field on close? again its that fluidity").

   grid-template-rows 0fr -> 1fr is what animates it: a height transition needs
   two definite values and the content height is not known, and max-height would
   run over the declared value rather than the real one — the trap already
   written down against .v2-combo-list. The 0fr/1fr pair is the one way to fold
   out to auto height with a real transition.

   [hidden] still wins, from base.css, so the strip is display:none when empty
   rather than a zero-height row holding a gap open. */
/* 0fr -> 1fr IS THE FOLD. A height transition needs two definite values and
   the content height is not known; max-height would transition over the
   DECLARED value rather than the real one, so it starts instantly and finishes
   early — the trap already written down against .v2-combo-list. A grid row
   between 0fr and 1fr is the one way to fold out to automatic height and still
   animate.

   The element is never display:none, because a transition cannot run across a
   display change. data-empty is the closed state. */
.v2-q-chosen {
    display: grid;
    grid-template-rows: 1fr;
    /* Inside the field box, so the inset matches the textarea below it. */
    padding: var(--space-2) var(--space-3) 0;
    transition: grid-template-rows 180ms ease, padding 180ms ease;
}

.v2-q-chosen[data-empty] {
    grid-template-rows: 0fr;
    padding-top: 0;
    padding-bottom: 0;
}

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

/* THE CLIPPING CHILD. A 0fr row sizes the ROW; without overflow: hidden here
   the chips would simply hang out of a zero-height row. It is also the
   scroller, for more files than the composer is wide. */
.v2-q-chosen-list {
    min-height: 0;
    overflow: hidden;
    display: flex;
    gap: var(--space-2);
    overflow-x: auto;
    scrollbar-width: none;
}

.v2-q-chosen-list::-webkit-scrollbar { display: none; }

/* A chip: the name, its size, and a way to take it back off. */
/* A little tile per file, so the strip reads as a count at a glance. The fill
   is the HOVER surface rather than the inset one: the field around it is
   already the inset colour, and inset-on-inset is invisible. */
.v2-q-chosen-file {
    display: inline-flex;
    align-items: center;
    gap: var(--space-2);
    flex: 0 0 auto;
    padding: 4px 4px 4px var(--space-2);
    border-radius: 8px;
    background-color: var(--surface-hover);
}

/* THE NAME IS VISIBLE HERE, and only here. Daniel, 8-Sep, settling which is
   which: "the file attched icon in the chat once sent with the tooltip showing
   name. attched pre send should be with the name embeded."

   Which is the right way round: before sending, the name is how you check you
   picked the correct file and know which one to remove. Once sent, the message is
   the content and the attachment is an object hanging off it — an icon, with the
   name on hover (see .v2-q-file). */
.v2-q-chosen-icon {
    display: inline-flex;
    flex: 0 0 auto;
    color: var(--text-muted);
}

.v2-q-chosen-icon svg { display: block; }

.v2-q-chosen-name {
    min-width: 0;
    max-width: 160px;
    overflow: hidden;
    text-overflow: ellipsis;
    white-space: nowrap;
    font-size: 12px;
    font-weight: 500;
    color: var(--text-strong);
}

/* 24px, which is under the 44px touch target the rest of the shell holds to —
   deliberately: it sits inside a 12px chip, and a 44px hit area would be
   larger than the thing it belongs to. The paperclip that adds files is full
   size, and removing one wrongly costs a re-pick rather than any data. */
.v2-q-chosen-x {
    display: inline-flex;
    align-items: center;
    justify-content: center;
    flex: 0 0 auto;
    width: 20px;
    height: 20px;
    padding: 0;
    border: 0;
    border-radius: 50%;
    background-color: transparent;
    font: inherit;
    font-size: 15px;
    line-height: 1;
    color: var(--text-muted);
    cursor: pointer;
}

.v2-q-chosen-x:hover {
    background-color: var(--surface-hover);
    color: var(--text-strong);
}


/* ── PATIENT CHOICE IS TYPING ─────────────────────────────────────────────
   Between the conversation and the composer, in the foot, so it does not move
   the messages when it appears — a row that pushed the stream every time
   somebody started and stopped typing would be worse than no indicator.

   THE DOTS ARE THE ONLY ANIMATION IN THIS PANEL, and this is the one place it
   earns its keep: something has to say the pause means somebody is writing
   rather than that nothing is happening. Reduced-motion stands them still and
   leaves the words, which say the same thing. */
.v2-q-typing {
    display: flex;
    align-items: center;
    gap: var(--space-2);
    margin: 0 0 var(--space-2);
    padding-left: var(--space-2);
    font-size: 11px;
    font-weight: 500;
    color: var(--text-muted);
}

.v2-q-typing-dots {
    display: inline-flex;
    align-items: center;
    gap: 3px;
}

.v2-q-typing-dots span {
    width: 5px;
    height: 5px;
    border-radius: 50%;
    background-color: currentColor;
    animation: v2-q-typing 1.2s ease-in-out infinite;
}

/* Staggered by a fifth of the cycle each, so the three read as a wave rather
   than as one flash. */
.v2-q-typing-dots span:nth-child(2) { animation-delay: 0.2s; }
.v2-q-typing-dots span:nth-child(3) { animation-delay: 0.4s; }

@keyframes v2-q-typing {
    0%, 60%, 100% { opacity: 0.3; transform: translateY(0); }
    30%           { opacity: 1;   transform: translateY(-2px); }
}

@media (prefers-reduced-motion: reduce) {
    .v2-q-typing-dots span {
        animation: none;
        opacity: 0.7;
    }
}


/* ── THE FOOT: the composer and its footnote ──────────────────────────────
   IN FLOW AT THE BOTTOM, not floating over the conversation. It was absolute for
   one round, which put the scroll region behind it — Daniel, 8-Sep: "Scroll goes
   under the text box now. should stop just before it."

   Which is the better behaviour anyway: a conversation that runs under its own
   composer means the last thing said is the thing you cannot see. In flow, the
   messages box takes the space above this and stops exactly at its top edge, so
   the fade is the only thing that ever covers a message.

   It still reads as floating, because the field has its own fill and outline and
   this wrapper has neither. */
.v2-q-foot {
    flex: 0 0 auto;
    display: flex;
    flex-direction: column;
    gap: 4px;
    padding-top: var(--space-2);
}

/* ── The composer ─────────────────────────────────────────────────────────
   The frame: a square clip button, then the field with Send inside its right
   edge. Send sits IN the field rather than beside it because the frame draws
   it that way, and it keeps the row to two boxes on a 402px screen. */
.v2-q-composer {
    display: flex;
    align-items: flex-end;
    gap: var(--space-2);
}

.v2-q-clip {
    display: inline-flex;
    align-items: center;
    justify-content: center;
    flex: 0 0 auto;
    width: 40px;
    height: 40px;
    /* The same hairline as the field beside it, and the same reasoning: an edge,
       not an outline. It was Flint 200, which is the ring that was too strong. */
    border: 1px solid var(--border-subtle);
    border-radius: 10px;
    color: var(--text-strong);
    cursor: pointer;
}

.v2-q-clip:hover { background-color: var(--surface-hover); }

/* A file input styled through its own label keeps the native picker and its
   keyboard behaviour, which a div with a click handler does not — so the
   focus ring has to be drawn from the input onto the label. */
.v2-q-composer input[type="file"]:focus-visible + .v2-q-clip {
    border-color: var(--colour-harbour-500);
    box-shadow: 0 0 0 3px rgb(12 117 168 / 18%);
}

/* ── THE FIELD IS A BOX ───────────────────────────────────────────────────
   The fill and the radius are HERE rather than on the textarea, which is what
   lets the attachment strip be part of the field instead of a row above it —
   Daniel, 8-Sep: "should fold out of the text box but stay attched to it?
   growing the number of files attched." The box grows as files are added; the
   textarea inside it is transparent and borderless. */
.v2-q-field {
    flex: 1 1 auto;
    min-width: 0;
    /* A HAIRLINE, WHICH THE SPEC HAS — Daniel, 8-Sep: "small outline around
       attchements and text box in spec missing in v2." It was taken off a round
       earlier on "the text field has a ring around it unlike spec", and both are
       right about different things: what was wrong was Flint 200 at full strength
       reading as a ring, not the presence of an edge. --border-subtle is the
       role for exactly this — Pebble 200 in light, Flint 400 in dark — so it
       reads as an edge in both themes rather than an outline in one. */
    border: 1px solid var(--border-subtle);
    border-radius: 12px;
    background-color: var(--surface-inset);
    /* The strip is clipped by this radius on the way out. */
    overflow: hidden;
}

/* The focus ring goes on the BOX, from the textarea inside it, so focusing the
   message field rings the whole field including any attachments on it. */
.v2-q-field:has(.v2-q-body:focus-visible) {
    box-shadow: 0 0 0 3px rgb(12 117 168 / 18%);
}

.v2-q-input {
    position: relative;
    display: flex;
    align-items: center;
    min-width: 0;
}

/* A textarea, not an input: a query is prose, and a single-line box invites
   the wrong length of message. rows=1 with a cap keeps it small until it
   isn't. */
.v2-q-body {
    flex: 1 1 auto;
    min-width: 0;
    /* Room for Send, so a long message never runs underneath it. */
    padding: 10px 84px 10px var(--space-3);
    /* NO BORDER AND NO FILL — both belong to .v2-q-field now, and the frame
       draws no outline on the field at all. Daniel, 8-Sep: "the text field has
       a ring around it unlike spec." */
    border: 0;
    background-color: transparent;
    font: inherit;
    font-size: 15px;
    line-height: 1.4;
    color: var(--text-strong);
    resize: none;
    max-height: 120px;
}

/* The ring is drawn by .v2-q-field around the whole box. */
.v2-q-body:focus-visible { outline: none; }

.v2-q-input [data-v2-q-send] {
    position: absolute;
    right: 6px;
}

/* KEPT, BUT QUIETER — Daniel, 8-Sep: "keep the footer remark about the file
   size but again, reduce the padding". Worth printing (somebody who picks a
   20MB scan should learn that before the upload fails) and not worth a row of
   its own, so it rides under the field inside the floating foot. */
.v2-q-limits {
    display: flex;
    align-items: center;
    gap: 6px;
    margin: 0;
    padding-left: 52px;      /* clears the paperclip, so it lines up with the field */
    font-size: 10px;
    font-weight: 500;
    color: var(--colour-flint-200);
}

/* REOPEN REPLACES THE COMPOSER on a resolved thread rather than sitting
   beside it — posting into a resolved query and reopening it are the same
   intent, and the service has reopen_session for exactly that. */
/* ── A RESOLVED QUERY SAYS SO ───────────────────────────────────────────────
   Daniel: "when resolved, can we better display the text maybe a green banner
   or something on design."

   It was two lines of muted prose and an amber button sitting in the space the
   composer had been — which read as the composer having failed to load rather
   than as a state the query had reached. The end of a conversation is worth a
   block of its own.

   LITERALLY THE PILL'S TWO VALUES. Daniel: "should matc the pill... white text
   same meadow as the resolved pill (lose the side border)."

   My first attempt used the intent tokens — Meadow 50 with Meadow 700 text in
   light, Meadow 900 with Meadow 200 in dark — which is the correct pattern for
   an ADVISORY block and the wrong answer here: "oooo no. its way too much." A
   tinted panel with a coloured left edge is a callout, and this is not calling
   anything out. It is the status, said once more at the point where the
   composer would have been.

   So it takes exactly what .v2-q-pill[data-tone="meadow"] takes, --colour-meadow
   and --text-on-accent, and nothing else. The pill and this are then the same
   object at two sizes rather than two greens a few steps apart, which is what
   made the first version read as a second, louder statement.

   A COLUMN, NOT A ROW. It was space-between, so the note and the button sat on
   one line and the note wrapped to two under the button's width. Daniel: "be
   one line too and above the Raise Query". Stacked, the note gets the full
   width and the button sits under it.

   THE BUTTON STAYS AMBER. It is the one action on offer and amber is this
   system's "do this next". On meadow it is still the only thing in the block
   that is not the block, which is what a single action wants to be. */
/* THE GREEN IS ON THE NOTE, NOT THE CONTAINER, AT EVERY WIDTH. Daniel, twice:
   "take the button out of the banner. on desktop", then "still the orange open a
   new query button is inside the green container of the resolved message, it
   should be below it, underneath, spanning page".

   I had read the first as desktop-only and left the phone with the fill on the
   container, so the button stayed inside the green there. It was one instruction
   about where the button goes, not two about where the fill goes. The container
   is layout only now and the banner is the sentence, so the button is outside
   the green everywhere.

   NO align-items, WHICH IS THE "SPANNING PAGE" HALF. A flex column defaults to
   align-items: stretch, so the note and the button both take the full width
   without either needing to say so. It was flex-start, which is what made the
   button hug its label. */
.v2-q-reopen {
    display: flex;
    flex-direction: column;
    gap: var(--space-3);
    margin-top: var(--space-3);
}

.v2-q-reopen-note {
    margin: 0;
    padding: var(--space-3);
    border-radius: 12px;
    background-color: var(--colour-meadow);
    font-size: 14px;
    font-weight: 500;
    color: var(--text-on-accent);
}

.v2-q-empty {
    margin: 0;
    font-size: 14px;
    font-weight: 500;
    color: var(--text-muted);
}

/* Its own element, not a message in the thread pane: with nothing selected
   there is no thread, and a pane with a title and a composer for a query that
   does not exist invites typing into nowhere. */
.v2-q-none {
    align-self: center;
    justify-self: center;
    margin: 0;
    padding: var(--space-6);
    font-size: 14px;
    font-weight: 500;
    color: var(--text-muted);
}

.v2-q-error {
    margin: var(--space-3) 0 0;
    font-size: 14px;
    font-weight: 500;
    color: var(--colour-sea-pink);
}

/* The raise modal's message box. .v2-field gives it everything but the shape:
   a textarea inherits the field's single-line height and needs telling it may
   be several, and resize:vertical because a query can be a sentence or a page
   and the nurse is the one who knows which. */
.v2-q-raise-body {
    min-height: 120px;
    line-height: 1.4;
    resize: vertical;
}

/* ── The head controls ────────────────────────────────────────────────────
   ONLY THE THINGS THAT HOLD AT EVERY WIDTH ARE HERE. The desktop arrangement —
   the head's columns mirroring the body's, the two controls side by side — is
   in overlays.css's min-width: 1025px block, NOT here, and that is not a
   preference.

   .v2-panel-head--control and .v2-panel-head-aside are declared in overlays.css,
   the layout loads that file AFTER this one, and its mobile block turns the head
   into rows on purpose. An override written here with enough specificity to beat
   that mobile rule beats it AT EVERY WIDTH — which is what put a two-column head
   on a phone and had Daniel reporting the panel "not rendering correctly" on
   mobile. The note above that mobile block warns about this exact trap, and says
   it has already cost a 56px nav column on a phone once.

   ONE HEIGHT FOR THE PAIR, though, and that does hold everywhere: they were 32
   and 44, because .v2-button--sm sizes itself from its padding while select.js's
   combo takes .v2-field's 44. Two numbers that have to agree should be one. */
.v2-q-head {
    --v2-q-ctl-h: 32px;
    display: flex;
    align-items: center;
    gap: var(--space-3);
}

.v2-q-head > .v2-button { min-height: var(--v2-q-ctl-h); }

/* THE COMBO, NOT THE <select>: select.js hides the native control and inserts
   .v2-combo beside it, so a height set on .v2-select sizes the one element that
   is display:none by then. Both the wrapper and the button, because the wrapper
   is what the cell sizes to and the button is what is drawn. */
.v2-q-assign .v2-combo,
.v2-q-assign .v2-combo-button {
    min-height: var(--v2-q-ctl-h);
    height: var(--v2-q-ctl-h);
}

/* The pipe in "SO#… | Patient" had no room around it and read as one word. The
   separator is a shared span, so the spacing is scoped here rather than handed
   to every panel's subtitle. */
.v2-panel[data-v2-panel="order-queries"] .v2-panel-sub-sep {
    margin: 0 6px;
}

/* ON THE CELL, NOT ON THE <select>, and the note above .v2-order-filter-cell
   is where this was learned: select.js hides the native control and inserts a
   .v2-combo beside it at width:100%, so a width set on .v2-select styles the
   one element that is display:none by then. The label is what both of them
   size to. 180px, roughly the frame's twice-the-button. */
.v2-q-assign {
    display: inline-flex;
    width: 180px;
    flex: 0 0 auto;
}


/* ── Tablet and phone ─────────────────────────────────────────────────────
   FRAMES 2183:14530 and 2183:14756 show ONE at a time: the list, or the
   thread with its own "< Queries" back link. So the grid collapses to a
   single column and data-view picks which child is in it.

   900px, matching Order details' head pair: it is the width where
   .v2-panel-head--control stops being a title-plus-control row, so it is
   where this panel's head stops having room for two panes beneath it. */
@media (max-width: 900px) {
    /* ── THE PANEL REACHES DOWN THE PAGE ─────────────────────────────────
       On a phone every panel grows with its content and the page scrolls, so My
       Orders and Order details only LOOK full — they have enough in them. A thread
       list with one row does not, and the panel stopped a third of the way down.

       A PROPORTION HERE, where the thread step below gets a calc: this step's
       content grows, so there is no exact height to hit — only a floor to clear.
       76dvh from a panel starting around a fifth down lands near the fold.

       On the queries panel and the LIST step only. */
    .v2-panel[data-v2-panel="order-queries"][data-view="list"] { min-height: 76dvh; }

    .v2-q { grid-template-columns: minmax(0, 1fr); }

    /* ── THE FILTER COLLAPSES TO ITS CHEVRON AND EXPANDS LEFTWARDS ───────
       Daniel, 9-Sep: "move the filter to inline? make it the filter icon that
       expands into the search box? (put it on the right and expands left? (the
       search box gets smaller when the filter expands etc."

       Which is a pattern this file already has, on the clinic filter in My
       Orders' search bar — and the hook is the same one, so this needs no
       JavaScript either: select.js sets aria-expanded on open, and sets it
       BEFORE it measures the list, so the widening is in the layout by the time
       the open animation is calculated.

       One row, the search taking what is left, the control on the right at
       button width with its value hidden — and 55% of the row when open, which
       the search shrinks to make room for. The list is left/right 0 of the
       control, so opening it wider from the right edge grows it leftwards on
       its own. */
    .v2-q-filters {
        flex-wrap: nowrap;
        gap: var(--space-2);
    }

    .v2-q-filters .v2-search {
        flex: 1 1 auto;
        min-width: 0;
    }

    /* 44px AND NOTHING WIDER. .v2-field carries a min-width and 44px of
       right-hand padding for the chevron that is no longer drawn, so the cell
       asked for 44 and the button took about 60 — "and its spanning too wide?".
       The floor and the padding are both cleared below. */
    .v2-q-filter-cell {
        width: 44px;
        flex: 0 0 44px;
        transition: width 180ms ease, flex-basis 180ms ease;
    }

    .v2-q-filter-cell .v2-combo,
    .v2-q-filter-cell .v2-combo-button {
        min-width: 0;
        width: 100%;
    }

    /* Just the icon. The accessible name is on the visually-hidden label in the
       markup, so nothing is lost by dropping the value text. */
    .v2-q-filter-cell .v2-combo-value { display: none; }

    /* ── A FILTER ICON, NOT A CHEVRON ────────────────────────────────────
       Daniel, 9-Sep: "Icon for filter is missing (drop down Arrow not needed.
       should be the 3 horizontal lines with dots on i believe?" — the sliders
       glyph. A chevron says "this opens"; on a control collapsed to one button
       what it needs to say is WHAT it opens.

       The chevron is .v2-field's own background image, so it is turned off and
       the glyph drawn as a mask on the button — a mask rather than a background
       so it takes currentColor and follows the theme, which is how every
       .v2-sidenav-icon in this shell works. */
    .v2-q-filter-cell .v2-combo-button {
        background-image: none;
        padding: 0;
        /* flex explicitly, so centring the glyph does not depend on what the
           shared button happens to be laid out as. */
        display: flex;
        align-items: center;
        justify-content: center;
    }

    .v2-q-filter-cell .v2-combo-button::after {
        content: "";
        width: 18px;
        height: 18px;
        background-color: currentColor;
        --v2-mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23000' stroke-width='2' stroke-linecap='round'%3E%3Cpath d='M3 6h18M3 12h18M3 18h18'/%3E%3Ccircle cx='8' cy='6' r='2' fill='%23000'/%3E%3Ccircle cx='16' cy='12' r='2' fill='%23000'/%3E%3Ccircle cx='10' cy='18' r='2' fill='%23000'/%3E%3C/svg%3E");
        -webkit-mask-image: var(--v2-mask);
                mask-image: var(--v2-mask);
        -webkit-mask-repeat: no-repeat;
                mask-repeat: no-repeat;
        -webkit-mask-position: center;
                mask-position: center;
    }

    /* Open, it is a real dropdown again: the value returns and the icon goes,
       because at 55% of the row the control has room to say what is selected and
       an icon beside the words would be decoration. */
    .v2-q-filters:has(.v2-combo-button[aria-expanded="true"]) .v2-q-filter-cell .v2-combo-button::after {
        display: none;
    }

    .v2-q-filters:has(.v2-combo-button[aria-expanded="true"]) .v2-q-filter-cell {
        width: 55%;
        flex: 0 0 55%;
    }

    .v2-q-filters:has(.v2-combo-button[aria-expanded="true"]) .v2-q-filter-cell .v2-combo-value {
        display: block;
    }

    /* ── AND IT LINES UP WITH THE OPTIONS UNDER IT ───────────────────────
       Daniel, 9-Sep: "the selected is landing slightly too much to the left,
       should be inline with the options below it?" The padding was zeroed for
       the collapsed icon state, so when the value came back it came back flush
       to the edge while the list below kept its own inset.

       THE SUM RATHER THAN A MEASURED 20px: the list is left/right 0 of this
       cell and pads itself by --space-2, and each option pads itself by
       --space-3, so the option text sits at the sum of the two. Adding them is
       what keeps the two aligned if either ever changes; 20px would only be
       right until then. */
    .v2-q-filters:has(.v2-combo-button[aria-expanded="true"]) .v2-q-filter-cell .v2-combo-button {
        padding-left: calc(var(--space-2) + var(--space-3));
    }

    /* ── ONE THING AT A TIME, IN BOTH SCOPES ─────────────────────────────
       These were scoped to data-scope="order", which left the nav route showing
       the query LIST stacked above the open thread on a phone — Daniel, 8-Sep:
       "on mobile when you go through queries, you get a logic bug, the header
       shows the patient and the order but all queries for all patients?" The
       pane was that order's queries, but sitting under a header about one
       patient with a conversation below it, it read as a list of everything.

       It is also what he asked for next in the same message: "once you have
       chosena query on mobile we should better collapse the other queries for
       the order and the new query (which should be on the step before?" — one
       screen at a time, whichever route you came in by. */
    .v2-q[data-view="list"]   .v2-q-thread { display: none; }
    .v2-q[data-view="thread"] .v2-q-list   { display: none; }

    /* The pane-instead-of-the-landing rules live with the scope/view matrix they
       override — see .v2-q[data-scope="global"][data-view="list"][data-others]
       earlier in this file. Nothing extra is needed at this width: the list view
       already hides the thread two rules up. */

    .v2-q-others {
        /* UNDER THE PILL, NOT UNDER THE TITLE. Daniel, 10-Sep: "move the N other
           query on this order to the right isde? above or under the status pill
           for the query?"

           flex-start put it at the column's left edge, which read as a subtitle
           to the query's own name. Flush right it joins the same stack as the
           status, which is what it is — another fact about the ORDER rather than
           about this query. */
        align-self: flex-end;
        text-align: right;
        /* LAST IN THE HEADER NOW, so the space goes above it. Daniel: "i dont
           like the gap in image 3 above the blue text what about moving the blue
           text to below the status pill (or above)". It was first, which put a
           link where the eye looks for the title. */
        margin-top: var(--space-2);
        padding: 0;
        border: 0;
        background: none;
        font: inherit;
        font-size: 12px;
        font-weight: 500;
        color: var(--colour-harbour-light);
        text-decoration: underline;
        text-underline-offset: 3px;
        cursor: pointer;
    }


    /* Never on touch: with one pane there is no empty second pane to explain. */
    .v2-q-none { display: none; }

    /* ══ THE TWO STEPS WANT OPPOSITE THINGS ══════════════════════════════════
       This was one rule for both and that is why the conversation would not move.
       Daniel, 9-Sep: "I cant seem to scroll down on the chat on mobile... see how
       the text box is fixed to the bottom, there would be an invisible line above
       that which is where the chat box scrolls matching desktop logic no?"

       Yes — and it is only the LIST step that wants the page to scroll.

       THE LIST grows and the page carries it, like every other panel on a phone. A
       bounded scroll region inside a moving page is a box you have to find and drag.

       THE THREAD is the opposite, and 2183:14756 draws it: the composer is pinned to
       the bottom of the panel and the conversation scrolls above it, with an
       invisible line between them. That is desktop logic exactly, so the thread step
       keeps the base rules rather than overriding them — and the panel is given a
       DEFINITE height so the flex chain that pins the composer has something to
       divide up. min-height alone would not do it: .v2-q-messages can only scroll if
       every box above it knows how tall it is.

       .v2-panel is only a flex column above 1025px, so that is set here too — the
       chain is panel -> body -> .v2-q -> .v2-q-thread -> .v2-q-messages, and it
       breaks at the first link that shrugs. */
    .v2-q[data-view="list"] {
        grid-template-rows: auto;
        height: auto;
    }

    .v2-q[data-view="list"] .v2-q-messages {
        overflow-y: visible;
        min-height: 200px;
    }

    .v2-q[data-view="list"] .v2-q-list {
        max-height: none;
        overflow-y: visible;
    }

    .v2-q-list-title { position: static; }


    .v2-panel[data-v2-panel="order-queries"][data-view="thread"] {
        display: flex;
        flex-direction: column;
        /* ── EXACTLY THE ROOM THERE IS, so the PAGE never scrolls ─────────
           A panel taller than the room left for it gives the page somewhere to
           go, and the top row scrolls away — which is what looked like the panel
           expanding upward. Fit it to the room and that cannot happen. */
        /* ── THE ROOM THERE IS, MEASURED BY queries.js ─────────────────────
           --v2-q-vh is the panel's own offset subtracted from the VISUAL
           viewport height, so it fits the space exactly and shrinks by exactly
           the keyboard when one is up: the composer lands just above the keys
           with the conversation directly above that, and the page gets no
           scroll range to be left stranded in.

           THE FALLBACK IS THE OLD CALC, for a browser with no VisualViewport
           API. It is close rather than exact — which is what it was, and why it
           is a fallback now. Every term is a real number this shell states; the
           trouble with a sum of terms is that it is only right while it is
           complete, and a measurement cannot be incomplete. */
        /* ── NO EASING ON THE HEIGHT, AND THE READOUT SAYS WHY ────────
           I added one for fluidity and it produced the opposite. Daniel's reading,
           caught mid-transition:

               avail 428   got 604   bot 604   now max 347

           avail is what the JS set; got is what the panel actually WAS, still
           animating down from 604. So for the length of the animation the panel is
           deliberately the wrong size: its bottom sits 176px below the fold, the
           composer is off screen, and the document gains 347px of draggable range.
           "appears the messages move up after the keyboard has" is exactly that
           lag, and it was mine.

           iOS reports the viewport REPEATEDLY WHILE the keyboard animates, so
           setting the height on each report already tracks it — the fluidity is
           free, and comes from the browser's own animation. Easing on top only
           means arriving late. An easing is right for a change with no external
           referent; this one has to match something already in motion. */
        /* 100% OF THE CHAIN ABOVE, and --v2-q-vh only if queries.js has had to
           step in — which it does only on a browser whose LAYOUT viewport does
           not shrink for the keyboard. Where it does (see the note by
           html[data-v2-q-fit] .v2-shell) the var is absent and this resolves to
           the browser's own reflow: one movement, no JavaScript.

           The old fallback here was calc(100dvh - the top row - the band - the
           section gap), which was only ever right while that list of terms was
           complete. 100% of a definite parent cannot be incomplete. */
        height: var(--v2-q-vh, 100%);
    }

    .v2-panel[data-v2-panel="order-queries"][data-view="thread"] .v2-panel-body {
        flex: 1 1 auto;
        min-height: 0;

        /* ── AND THE ROW INSIDE IT IS THE HEIGHT AVAILABLE, NOT THE CONTENT ─
           THIS IS WHAT MAKES THE CONVERSATION SCROLL. Daniel, 9-Sep: "i think just
           need to add a chat only scroll bar so when the keyboard is active i can
           scroll the chat INSIDE the view of the screen as currently it gets lost
           off screen".

           It was lost because .v2-panel-body is display: grid with IMPLICIT rows,
           and an implicit row is auto — it sizes to its content. So .v2-q was as
           tall as the whole conversation, its own minmax(0, 1fr) row resolved
           against that instead of against the screen, .v2-q-messages never became
           shorter than its content, and nothing had a reason to scroll. The
           overflow: hidden below then clipped the surplus, which is why it
           vanished rather than spilling visibly.

           One declaration fixes the whole chain, and every link was already in
           place waiting for it: .v2-q carries grid-template-rows: minmax(0, 1fr),
           .v2-q-thread has min-height: 0, and .v2-q-messages has flex: 1 1 auto
           with overflow-y: auto. All of them were correct and all of them were
           resolving against an auto row.

           MOBILE THREAD ONLY: on desktop the panel is capped by max-height and the
           body gets its definite height that way, which is why this never showed
           there. */
        grid-template-rows: minmax(0, 1fr);
        /* The conversation scrolls, not this. */
        overflow: hidden;
    }

    /* THE COMPOSER IS THE FOOTER, so there is almost nothing under it. Daniel,
       9-Sep: "The text box is sitting too high need to be at footer". .v2-panel
       carries 60px of bottom padding — right for a panel whose content ends and
       leaves an edge showing, wrong for one whose last row IS the edge.

       8px, DOWN FROM 16: "the grey area between the text box and attchement info
       to the panel is still fairly large? wasted space?" The attachments line is
       8px type, so 16px under it read as a margin rather than as the edge of the
       panel. This is now the smaller of the two gaps around that line, which is
       what makes it belong to the composer. Enough to clear the corner radius. */
    /* ── THE TOP PADDING IS THE SHELL'S AGAIN ───────────────────────────
       This used to cut --panel-pad-top to 16px here. The reason was sound at the
       time: "losing some of the head room above the title" — the padding is
       sized for a panel whose TITLE sits in it, and this view hid the panel head
       entirely to give the thread the screen, so the room was holding up
       nothing.

       It is holding up the header now. Since 10-Sep the card's own top IS this
       screen's header — the way back, the query's title, the order, the
       other-queries link and the status — so it wants exactly the room every
       other panel gives its own. Leaving the override in is what made this one
       screen sit higher than the rest: Daniel, 10-Sep, with the two screenshots
       side by side — "so how the heading area sits higher on the panel than the
       other screen shot whch is the default for all?"

       Removed rather than re-tuned. A number chosen for this screen alone is how
       the last one drifted; the shell's value is the one that keeps it level
       with everything else for free. */
    .v2-panel[data-v2-panel="order-queries"][data-view="thread"] {
        padding-bottom: var(--space-2);

        /* ── THE GREY CARRIES ON BELOW THE PANEL ───────────────────────────
           Daniel has asked for this three times in different words: "with the
           grey background panel always", and the strip of wallpaper that kept
           appearing under the composer.

           iOS paints the page BEHIND its own bottom chrome — the floating URL
           pill sits over the page rather than beside it — but the visual
           viewport, which is what any height here can be measured against, ends
           at the top of that chrome. So a panel sized correctly to the last
           measurable pixel still leaves ~30px of page showing behind the pill,
           and no amount of arithmetic reaches it because the number simply is
           not exposed.

           A SHADOW RATHER THAN HEIGHT, and that is the point: it paints 120px of
           the panel's own colour below its bottom edge and takes part in no
           layout at all — no box to size, no scroll range created, nothing for a
           measurement to feed back on. Every previous attempt at this strip
           tried to grow the panel and every one of them broke the scroll,
           because in this shell height IS scroll range.

           --surface-page IS .v2-panel's background (overlays.css:437), so this is
           the same colour in both themes by construction rather than by a matching
           pair of values that can drift.

           IT WAS --surface-raised FOR ONE COMMIT and Daniel spotted it instantly:
           "the background colour under the keyboard is a light grey? why?" Because
           I read a background off the wrong selector — .v2-panel-todo, a
           placeholder component 270 lines further down that file — and then wrote a
           comment claiming it was the panel's. raised is Flint 400 in dark against
           the panel's Flint 500, so it read as a lighter strip. Worth the note: a
           token that LOOKS right in one theme is not evidence, and neither is a
           declaration found by grepping for the property instead of the rule. */
        box-shadow: 0 120px 0 0 var(--surface-page);
    }

    /* ── THE FIELD IS DARKER AND SMALLER HERE ────────────────────────────
       Daniel, 9-Sep: "Make the text box darker matching spec and resize it inc
       the footer text."

       --surface-sunken RATHER THAN --surface-inset, and that is the whole fix:
       inset is Flint 500 in dark, which is the PANEL colour — and in the mobile
       thread the card behind it is transparent, so the field was the same shade
       as what it sat on and simply disappeared. sunken is the "wells, inset
       areas" role and is a step darker than the page in BOTH themes (Flint 600
       in dark, Seafoam 300 in light), which is what the frame draws. */
    .v2-q-field { background-color: var(--surface-sunken); }

    /* Room for a smaller Send — see below. NO font-size HERE: 14px is what was
       triggering iOS's zoom-on-focus, and the coarse-pointer rule above puts
       every control at 16. A smaller composer is not worth a zoomed page. */
    .v2-q-body {
        padding: 8px 68px 8px var(--space-3);
    }

    /* 32px both — Daniel, 9-Sep: "the send button and attachment icon is slightly
       too big". They are the two controls in a 390px row and they were sized for a
       desktop card. Under the 44px convention on purpose: the FIELD between them
       is the target that matters, and it keeps its height. */
    .v2-q-clip {
        width: 32px;
        height: 32px;
    }

    .v2-q-input [data-v2-q-send] {
        min-width: 0;
        min-height: 26px;
        padding: 0 var(--space-3);
        font-size: 12px;
    }

    /* ── THE FOOTNOTE STARTS UNDER THE PAPERCLIP ─────────────────────────
       Daniel, 9-Sep: "with the attchement text far smaller, not flexing and
       starting under the attchement box not the text box."

       All three were the same mistake: it had 46px of left padding to line up
       with the FIELD, which pushed a sentence that barely fits a 390px screen
       into two lines. It is a rule about attachments, so it belongs under the
       control that adds them — flush left, 8px, and nowrap so it cannot wrap
       again. If it ever exceeds the width it scrolls rather than reflows the
       composer above it. */
    .v2-q-limits {
        padding-left: 0;
        font-size: 8px;
        white-space: nowrap;
        overflow: hidden;
        text-overflow: ellipsis;
    }

    /* SMALLER HERE. Daniel, 9-Sep: "The status pill is also quite large i feel" —
       and it is sharing a line with a 22px title on a 390px screen rather than
       sitting in a wide card head. The frame's is smaller too. */
    .v2-q-pill {
        padding: 3px var(--space-2);
        font-size: 11px;
    }

    /* ── THE TAIL SITS A LITTLE LOWER HERE ───────────────────────────────
       The frame drops it so it shares a band with the caption under it.

       HEIGHT NEAR THE WIDTH IS WHAT KEEPS IT A FLICK: taller than it is wide puts
       the mass BELOW the bubble and it reads as a block hanging off the corner.
       Two pixels lower than the desktop 12x12, and no further. */
    /* ── THE TAIL: THE DESKTOP GEOMETRY, DROPPED 2px ─────────────────────
       Third go, and the lesson is that the SHAPE was right from the start and
       only the numbers moved. 14x20 at -8, then 12x16 at -4, both put the mass
       BELOW the bubble instead of beside it — which is what "the speech bubble
       is still miles away" describes: a wedge sitting under the corner rather
       than a flick growing out of it.

       The clip-paths were always correct: a vertical edge flush against the
       bubble's side, a horizontal edge along its bottom, and the apex out and
       down. Height near the width is what keeps it a flick; height much greater
       than the width is what made it a blob. So: the desktop 12x12 at 11x13,
       two pixels lower, and no further. */
    /* NO SIZE OVERRIDE HERE. 2183:14756 IS the mobile frame, so the numbers on
       .v2-q-bubble::after are already the phone's; the 11x13 that used to sit here
       was a guess layered on a guess. */

    /* ══ NO SQUARED CORNER. THIS WAS THE "FLATLINE". ══════════════════════
       These two set one bottom corner of every bubble to 4px on a phone, so
       the old triangle tail would read as one shape with it rather than as a
       wedge parked beside a rounded corner. Sound reasoning while there WAS a
       triangle. There is no tail now, so every bubble had three rounded
       corners and one nearly square one.

       THAT IS WHAT DANIEL HAS BEEN DESCRIBING ALL ALONG: "the bottom of the
       bubbles flatline, it should be a bubble. rounded and ....rounded". A
       squared bottom corner IS a flat bottom. I spent six rounds adjusting a
       TAIL while the flat corner sat here untouched, and read "no change" as
       my tail rules not applying when the thing he was pointing at was never
       the tail.

       Worth the note: he described the fault accurately every time and I kept
       mapping it onto the part I happened to be working on. */

    /* ── THE CAPTION SITS UNDER THE BUBBLE'S LEFT EDGE ───────────────────
       Daniel, 9-Sep: "the text placement and size is wrong" — and 2183:14756
       has it left-aligned under the bubble on BOTH sides, not centred under it.
       Centring was my reading of "more central below the message" on the
       desktop frame; the mobile frame is explicit and it is his own screenshot
       of it, so it wins.

       flex-start on both, so a right-hand bubble's caption starts at the
       bubble's left edge rather than its right. That is what the frame draws
       and it also reads better: the eye is already at the left after the last
       line of the message. */
    /* align-self is the base value now — see the note there. Only the size
       changes on a phone. */
    .v2-q-cap { font-size: 9px; }

    /* The mobile bubbles run wider, there being no list beside them competing
       for the width. */
    .v2-q-msg { max-width: 88%; }

    /* 8px top and bottom rather than 12 — Daniel, 9-Sep: "the padding on messages
       is too much top and bottom". The horizontal 16 stays: that is what stops a
       line of text touching the bubble's curve. */
    .v2-q-bubble { padding: 8px var(--space-4); }

    /* ── INLINE WITH THE TITLE, TOP RIGHT ────────────────────────────────
       Two columns for this panel only. The shell turns the head into ROWS at this
       width — right for an aside of stat tiles, which would squeeze a title, and
       wrong for one small button, which sat on its own row under the subtitle.

       align-items: start so it aligns with the TITLE and not with the bottom of
       the title-and-subtitle block; nowrap and a smaller size so it can never wrap
       or shrink the copy beside it.

       Justin's mobile frames omit both head controls entirely — the same omission
       as Add attachment on the view-order frames — so this placement is decided
       rather than read. Raised with Justin. */
    /* grid-auto-flow: ROW, and this is the correction. Column flow put the head's
       three children in three columns and the explicit span on the third only
       scrambled the order further — the search row landed BESIDE the title with
       the heading pushed to the right of it (Daniel, 9-Sep: "the title is now
       flexed? should be header / Search Filter").

       Row flow with two columns puts them where DOM order says: the copy and the
       button share row one, the filter row takes row two and spans it.

       --split RATHER THAN --control since 10-Sep: this panel's head took the
       split shape when it grew a back control, and the two modifiers are
       mutually exclusive by construction (see dashboard.php). The geometry
       asked for here is what --split already declares, so this is now only the
       auto-flow guard — kept because it is what stops a future child added to
       this head from landing in a third column. */
    .v2-panel[data-v2-panel="order-queries"] .v2-panel-head--split {
        grid-auto-flow: row;
        align-items: start;
    }

    .v2-panel[data-v2-panel="order-queries"] .v2-q-filters {
        grid-column: 1 / -1;
    }

    .v2-q-head {
        align-items: flex-end;
        gap: var(--space-2);
    }

    .v2-q-head > .v2-button {
        white-space: nowrap;
        padding: 0 var(--space-3);
        font-size: 13px;
    }

    .v2-q-assign .v2-combo,
    .v2-q-assign .v2-combo-button {
        /* The shell's touch-target floor, which beats the 32px the two share on
           desktop — .v2-combo-button already has this at 44 below 1024. */
        min-height: 44px;
        height: auto;
    }

    /* ── NEW QUERY IS 30px HERE, NOT 44 ──────────────────────────────────
       Daniel, 10-Sep: "the new qury button in an orders query list was too
       wide. could be less padding top and bottom?" — then, at 36px: "make
       thinner still".

       It started on the 44px touch floor with the Assign combo, which made a
       short amber label into a slab. 30px is two steps down from that and just
       above .v2-button--sm's own 28px floor, so it is still the shell's shape
       rather than an invented one.

       WCAG 2.5.8 asks 24px and this clears it; it is under Apple's 44pt
       guidance, which is the trade being asked for and is named here rather
       than buried. It stays a fair target in practice because it now spans its
       whole column — see the stretch in overlays.css — so what came off the
       height went onto the width. The Assign combo keeps 44: it opens a list,
       and a mis-tap there costs more than one on a button whose modal closes.

       A filled button cannot use .v2-panel-back's trick of padding out to 44
       and pulling back with a negative margin, because the padding is what you
       can see.

       flex: 1 so it fills the stretched slot. Only one of the two controls in
       this row is ever visible — New query on the list, Assign in a thread —
       so neither takes width from the other. */
    .v2-q-head > .v2-button {
        min-height: 30px;
        height: auto;
        flex: 1 1 auto;
    }

    /* NEW QUERY BELONGS TO THE LIST STEP. In a thread it is offering to start a
       different conversation from inside this one, and on a phone it is the only
       amber thing on screen while you are reading — Daniel, 8-Sep: "New query
       when in a query from the orders makes no sense there" and "the new query
       (which should be on the step before?" Going back to the list brings it
       back, which is where it acts. */
    .v2-panel[data-view="thread"] [data-v2-q-new] { display: none; }

    /* ══ A THREAD IS THE WHOLE PANEL ON A PHONE ══════════════════════════════
       [frame] OrderQueriesMessage/Mobile/Dark 2183:14756. Daniel, 8-Sep: "on
       mobile once i click on a query the panel should become the query in its
       entirety as per the spec."

       And the frame means it literally: there is no "Order queries" title, no
       SO#/patient subtitle and no controls on that screen — the QUERY'S title is
       the heading, with its status pill beside it and a rule under it, and the
       conversation runs from there to the composer at the bottom. Which is right
       for a phone: one screen, one subject, and the room that a second heading
       and a second card border were spending on saying where you are.

       THE PANEL KEEPS ITS ACCESSIBLE NAME. The head is display:none, and its h2
       is what .v2-panel points at with aria-labelledby — that still works, since
       aria-labelledby reads a hidden element's text by design. So the panel is
       still "Order queries" to a screen reader while showing only the thread.

       AND THE WAY BACK IS THE FRAME'S AGAIN, which is a full circle worth
       recording.

       Justin draws "< Queries" inside the card here. It came out on Daniel's
       call — "the < Queries is redundant. we have the nav on desktop and the
       back button on mobile for that?" — and the shell's glass circle took the
       job, stepping to the list first via v2:panel-up.

       On 10-Sep that circle was retired and the up-control moved into the panel
       head so it could name its destination — which broke exactly this screen,
       because the head is display:none here. My first repair was to EMPTY the
       head instead of hiding it, leaving just the control. That fixed the dead
       end and created a worse problem: the control then sat ABOVE the heading
       on this one screen and below it on every other, which is what Daniel
       meant by "the back buttons are a mess, no uniformaty to them".

       So the head is hidden again, wholesale, exactly as the frame wants, and
       the way back lives in the CARD — where Justin drew it. .v2-q-thread-nav
       in order-queries.php is line 2 of this screen's header, and it carries
       the other-queries link with it so that is not a third row either. */
    .v2-panel[data-v2-panel="order-queries"][data-view="thread"] .v2-panel-head {
        display: none;
    }

    /* ── THIS SCREEN'S HEADER IS THE CARD'S OWN TOP ──────────────────
       The panel head is display:none here, so the card's top is the header.
       Daniel, 10-Sep, drawing it:

           ‹ Queries
           Query title (ellipsised)          SO#962489 | Demo Patient
           2 other queries on this order     [Outstanding]

       THREE ROWS, TWO COLUMNS, AND EVERY ITEM PLACED. The way out gets a row
       to itself — "all the back buttons in the upper top left corner of the
       panels not below" — then the subject with the order it belongs to, then
       the two things that qualify it: what else is on this order, and what
       state this one is in.

       The left column is the flexible one, so the title and the blue link take
       the slack and the ref and the pill keep their content width. The title's
       own rule already ellipsises it on one line, which is what stops it
       reaching the ref: "Title ... if too long so no clash".

       display: contents ON BOTH WRAPPERS. .v2-q-thread-nav and
       .v2-q-thread-head exist for the DESKTOP layout, where the head is a flex
       row of title-and-pill and the nav is not shown at all. These three rows
       cut across that nesting, so rather than a second set of markup the
       wrappers dissolve here and their children become this grid's items. The
       DOM keeps the reading order — back, title, ref, pill, link — while the
       grid decides the screen.

       --v2-head-gap for the row rhythm, the same token the panel heads use, so
       back-to-title cannot come out different here from there. That drift is
       what Daniel meant by "some have the back button higher or lower and the
       title from that higher or lower". */
    .v2-panel[data-v2-panel="order-queries"][data-view="thread"] .v2-q-thread-top {
        display: grid;
        grid-template-columns: minmax(0, 1fr) auto;
        column-gap: var(--space-3);
        row-gap: var(--v2-head-gap);
        align-items: center;
    }

    .v2-panel[data-v2-panel="order-queries"][data-view="thread"] .v2-q-thread-nav,
    .v2-panel[data-v2-panel="order-queries"][data-view="thread"] .v2-q-thread-head {
        display: contents;
    }

    .v2-panel[data-v2-panel="order-queries"][data-view="thread"] .v2-q-thread-back {
        grid-column: 1;
        grid-row: 1;
        justify-self: start;
        min-height: var(--v2-head-nav-h);
    }

    /* ── FIVE THINGS, SO THE FIFTH TAKES A ROW OF ITS OWN ────────────
       Daniel's rule, 10-Sep: "if queries is the 5th entry, it should have its
       own row above the grid below. if only 4, then it should just be a grid."

       This screen has five: the way back, the query's title, the order it is
       about, the link to the order's other queries, and this one's status. So
       the back control gets row 1 alone and the remaining FOUR are the 2x2
       under it:

           ‹ Queries
           Query title              SO#962489 | Demo Patient
           2 other queries          [Outstanding]

       A panel HEAD has only four — back, title, ref, actions — so there is no
       fifth to lift out and it is just the grid, with the back control in line
       with the ref. Same rule, different count; see .v2-panel-head--split.

       I packed both columns from the top here for one revision, carrying "bring
       the right side up" over from the head. That was the wrong screen for it:
       with five items, packing pulls the back control into line with the ref
       and strands the blue link on a row by itself — "forcing the blue 'other
       query on this order' button to be alone". The count is what decides,
       which is what Daniel said the first time. */
    .v2-panel[data-v2-panel="order-queries"][data-view="thread"] .v2-q-thread-title {
        grid-column: 1;
        grid-row: 2;
    }

    .v2-panel[data-v2-panel="order-queries"][data-view="thread"] .v2-q-thread-order {
        grid-column: 2;
        grid-row: 2;
        justify-self: end;
    }

    .v2-panel[data-v2-panel="order-queries"][data-view="thread"] .v2-q-others {
        grid-column: 1;
        grid-row: 3;
        justify-self: start;
        align-self: center;
        margin-top: 0;
    }

    .v2-panel[data-v2-panel="order-queries"][data-view="thread"] .v2-q-pill {
        grid-column: 2;
        grid-row: 3;
        justify-self: end;
    }

    /* ── FOUR, SO IT IS JUST THE GRID ─────────────────────────────────────
       Daniel, 2-Oct: "should the status of a query go above the patient
       details on mobile so its in line with the back button? as current we
       have 4 entries spanning 2 rows". The other half of his 10-Sep rule: with
       no "other queries" link there are four things, and the status takes the
       top row beside the way back instead of a third row of its own.

           ‹ Queries                [Outstanding]
           Query title              SO#962489 | Demo Patient */
    .v2-panel[data-v2-panel="order-queries"][data-view="thread"] .v2-q-thread-top:has(.v2-q-others[hidden]) .v2-q-pill {
        grid-row: 1;
    }

    /* The card stops being a card: no fill, no radius, no inset. The frame puts
       the bubbles on the panel itself, and the panel's own padding is already
       holding them off the edge. */
    .v2-panel[data-v2-panel="order-queries"][data-view="thread"] .v2-q-thread {
        padding: 0;
        background-color: transparent;
        border-radius: 0;
    }

    /* THE QUERY'S TITLE IS THE HEADING NOW, so it takes the panel title's size
       rather than a card heading's. Not --type-h1-size: the frame's is smaller
       than "Order queries" was, because it is a sentence somebody typed rather
       than a section name.

       ONE LINE, ELLIPSISED. Daniel, 9-Sep: "the title of the query here shouldnt
       flex either? look t the spec screenshot" — 2183:14756 has it on one line
       with the pill beside it, and a wrapping title pushed the pill down and took
       two lines of conversation with it. The base rule already had the overflow
       and the ellipsis; nowrap is what was missing, so it wrapped instead. */
    .v2-panel[data-v2-panel="order-queries"][data-view="thread"] .v2-q-thread-title {
        /* 18px, not 22. It went to 22 as the screen's heading while it still
           wrapped, so the size was doing the work of a two-line block; on one
           line 22 truncated a real title to "Redesign Templ..." beside the pill.
           Daniel, 9-Sep: "Make the title smaller now it doesnt flex". */
        font-size: 18px;
        white-space: nowrap;
    }

    /* AND THE RULE COMES BACK, on this screen only. It went from the desktop card
       because the frame there does not draw one ("lose the line seperator in chat
       between title and chat. per spec") — 2183:14756 does, and here it is the
       only thing separating a typed title from the conversation, with no card
       edge or panel heading above it doing that job. */
    /* ON THE WRAPPER, NOT THE TITLE ROW. The rule the frame draws goes between
       the header and the conversation, and the title row stopped being the last
       thing in the header when the order reference arrived — left where it was,
       the line cut the header in half, between the query title and the order it
       is about. */
    .v2-panel[data-v2-panel="order-queries"][data-view="thread"] .v2-q-thread-top {
        padding-bottom: var(--space-3);
        border-bottom: 1px solid var(--border-subtle);
        margin-bottom: var(--space-2);
    }

    /* Under the title, reading as its subtitle — which is what it is, and the
       same 16px Medium in Flint 200 that .v2-panel-sub uses on desktop, so the
       two routes to the same information look like the same information. The
       button inside it takes font and colour by inherit (.v2-q-sub-order), so
       it needs nothing said about it here. */
    /* IT SITS IN THE NAV ROW NOW, opposite the back control, which is where
       every other panel puts it (see .v2-panel-head--split's pairs). 12px to
       match what the heads use on touch — it was 16px when it had a line of its
       own to fill. */
    .v2-panel[data-v2-panel="order-queries"][data-view="thread"] .v2-q-thread-order {
        display: block;
        flex: 0 1 auto;
        min-width: 0;
        margin: 0;
        text-align: right;
        font-size: var(--type-caption-size);       /* 12px */
        font-weight: var(--type-heading-weight);
        color: var(--colour-flint-200);
    }

    /* ── The pane is the whole screen here, so the frame's rows apply ────
       2183:14530: a title with its status beside it, on one line, with the
       pill hard right. Nothing is squeezed at this width — the constraint that
       took the pill off the desktop pane does not exist on a phone. */
    .v2-q-row-pill { display: inline-block; }

    .v2-panel[data-view="thread"] .v2-q-others { display: block; }

    /* ── THE ATTRIBUTION COMES OUT, TO BUY THE LINE ──────────────────────
       Daniel: "on mobile take the by Patient Choice away from the text so it
       fits on one line on mobile (keep on desktop)."

       The full sentence is 71 characters and this width fits about 55 at 14px,
       so it wrapped. Without the attribution it is 53 — "This query has been
       resolved. Please raise a new Query" — which fits, and loses nothing a
       reader needs: the resolve line directly above already names who did it and
       when. display: none rather than a second string in the markup, so there is
       one sentence to keep correct rather than two that can drift. */
    .v2-q-reopen-by { display: none; }

    .v2-q-thread-row {
        flex-direction: row;
        align-items: center;
        justify-content: space-between;
        gap: var(--space-3);
        /* 40px, not 44 — Daniel, 9-Sep: "a little thinner". Under the shell's
           own 44px convention, and comfortably over the 24px WCAG 2.5.8 floor:
           the row is the full width of the pane, so the target stays large in
           the direction that is not short of room. */
        min-height: 40px;
        padding: 6px var(--space-3);
    }

    /* ── THE OPEN ROW IS A LIGHTER GREY HERE, NOT HARBOUR ────────────────
       Daniel, 9-Sep: "the spec for dark mode is a lighter grey and a little
       thinner?" — 2183:14530 draws the row a step lighter than the card rather
       than filled with the brand colour, which is also what lets the sea-pink
       and green pills beside it read.

       --surface-inset is that step in BOTH themes (Flint 500 on a Flint 600 card
       in dark, Pebble 200 on Pebble 100 in light), so it is a contrast either
       way rather than "lighter" in one and invisible in the other. And the text
       goes back to --text-strong: --text-on-brand is for a brand fill and there
       is no longer one. */
    .v2-q-thread-row[aria-current="true"] {
        background-color: var(--surface-inset);
        color: var(--text-strong);
    }

    /* One line again, since the pill is beside it rather than under it. */
    .v2-q-thread-name {
        -webkit-line-clamp: 1;
        line-clamp: 1;
    }


    /* The dimming was standing in for the pill on desktop. With the pill drawn,
       a dimmed row would be saying it twice AND making the frame's green pill
       harder to read. */
    .v2-q-thread-row[data-status="resolved"] { opacity: 1; }

    /* The cell again, not the select — and it fills its grid track rather
       than keeping the desktop 180. */
    .v2-q-assign {
        display: grid;
        width: auto;
    }
}


/* ══ NO ZOOM-ON-FOCUS, ANYWHERE ═════════════════════════════════════════════
   Daniel, 9-Sep: "when clicking anything to focus it causes the native zoom, we
   need to cancel that off as a global rule."

   It is global, and it is not the chat: --type-body-size is 15px, so EVERY
   .v2-field in this shell is a pixel under the threshold iOS Safari uses to
   decide a field is too small to type in. Below 16px it zooms the page to the
   field on focus and leaves it zoomed. Fifteen was the frame's number and it
   has been quietly doing this on every form in v2.

   16px IS THE FIX, NOT user-scalable=no. The viewport meta would also stop the
   zoom, and it stops PINCH zoom with it — which fails WCAG 1.4.4 and takes a
   real accommodation away from someone who needs to enlarge a prescription
   detail. Sizing the control correctly costs a pixel of type on a phone.

   pointer: coarse RATHER THAN A WIDTH, because the threshold is about the input
   device and not the screen: an iPad in landscape is over 1024px wide and zooms
   just the same. A touch laptop gets 16px fields too, which is a pixel larger
   than intended and nothing worse.

   16px EXACTLY, not "at least": it is the boundary, and rounding up further
   would drift the type ramp on touch for no gain. */
@media (pointer: coarse) {
    /* The element selectors catch anything un-classed; the class ones are what
       actually beat a component rule, since a bare element loses to any class.
       Both lists, so this is a global rule rather than a set of names somebody
       has to remember to add to. */
    input:not([type="checkbox"]):not([type="radio"]),
    select,
    textarea,
    .v2-field,
    .v2-select,
    .v2-q-body,
    .v2-q-raise-body {
        font-size: 16px;
    }
}


/* ══ NO SCROLLBAR IN THE CONVERSATION ON TOUCH ════════════════════════════════
   Daniel, 9-Sep: "is it not better on phone to remove the scrool bar logic
   completed, just have a focus on the chat window itself or hold and drag?"

   Yes, and it explains a good deal of today. With the thread head hidden for the
   keyboard, .v2-q-messages spans the full width, so its scrollbar is drawn at
   the very edge of the SCREEN — indistinguishable from a page scrollbar. He
   reported "the panel behind moving" and "see the dual scroll?" against a
   conversation that was behaving correctly, because the only visible cue said
   otherwise. I then spent rounds hunting a page scroll that was not there.

   A touch surface does not need the bar: the content moves under the finger,
   which is the affordance. Hiding it leaves the scrolling exactly as it was and
   removes the thing that was lying about what scrolled.

   THE CONVERSATION ONLY, and only on a coarse pointer. Every other scroll
   region keeps its bar, and a mouse keeps it everywhere: there the bar is the
   only affordance and hiding it would be a real loss. */
@media (pointer: coarse) {
    .v2-q-messages { scrollbar-width: none; }
    .v2-q-messages::-webkit-scrollbar { width: 0; height: 0; }
}

/* ══ THE PASSWORD REMINDER ════════════════════════════════════════════════════
   Daniel, 1-Oct: the 90-day expiry warned on classic and not on v2. Classic's
   timings: amber from 14 days out, red from 7 (data-level warning | danger).

   THE BADGE, "a little red or yellow exclamation mark bottom right of the
   account icon slightly overlapping". On the bar's avatar, the phone's menu
   button and the panel's account button; the notice and the Password card use
   the same mark, so the three read as one thing. The days are always in words
   next to it somewhere, so the colour is never the only signal. */
.v2-statusbar-avatar,
.v2-mobile-menu { position: relative; }

.v2-pw-badge {
    position: absolute;
    right: -4px;
    bottom: -4px;
    display: grid;
    place-items: center;
    width: 16px;
    height: 16px;
    border-radius: 50%;
    background: var(--intent-warning);
    color: var(--colour-flint-900);
    font-size: 11px;
    font-weight: 800;
    line-height: 1;
    box-shadow: 0 1px 3px rgb(0 0 0 / 35%);
    pointer-events: none;
}
.v2-pw-badge[data-level="danger"] { background: var(--intent-danger); color: #fff; }

/* In the notice and on the card it sits in the line, not on a corner. */
.v2-pw-notice .v2-pw-badge,
.v2-ac-expiry .v2-pw-badge { position: static; flex: none; box-shadow: none; }
.v2-pw-badge--lg { width: 24px; height: 24px; font-size: 14px; }

/* THE NOTICE: glass, like the pills, under the greeting and as wide as its
   message line. One row on a desktop — mark, words, button, close. */
.v2-pw-notice {
    display: flex;
    align-items: center;
    gap: var(--space-3);
    max-width: 36rem;
    width: fit-content;
    margin-top: var(--space-5);
    padding: 8px 8px 8px var(--space-4);
    border-radius: var(--radius-pill);
    color: #fff;
}
.v2-pw-notice[hidden] { display: none; }

.v2-pw-notice-text {
    margin: 0;
    font-size: var(--type-body-size);
    font-weight: 600;
    line-height: 1.35;
}

.v2-pw-notice-go {
    flex: none;
    display: inline-flex;
    align-items: center;
    min-height: 36px;
    padding: 0 var(--space-4);
    border-radius: var(--radius-pill);
    background: rgb(255 255 255 / 16%);
    color: #fff;
    font-size: var(--type-body-size);
    font-weight: 600;
    text-decoration: none;
    white-space: nowrap;
    transition: background-color 140ms ease;
}
.v2-pw-notice-go:hover { background: rgb(255 255 255 / 26%); }

.v2-pw-notice-close {
    flex: none;
    display: grid;
    place-items: center;
    width: 36px;
    height: 36px;
    padding: 0;
    border: 0;
    border-radius: 50%;
    background: transparent;
    color: rgb(255 255 255 / 78%);
    cursor: pointer;
    transition: background-color 140ms ease, color 140ms ease;
}
.v2-pw-notice-close:hover { background: rgb(255 255 255 / 12%); color: #fff; }
.v2-pw-notice-close svg { display: block; }

.v2-pw-notice-go:focus-visible,
.v2-pw-notice-close:focus-visible {
    outline: 2px solid #fff;
    outline-offset: 2px;
}

/* After the typing, with the message line, on the visit that types. */
.v2-greeting[data-v2-typed] .v2-pw-notice {
    animation: v2-enter 480ms ease-out calc(var(--type-done) + 160ms) backwards;
}

/* PHONES: the words take the row, the button drops under them, the close stays
   top right, and the targets go to 44. It sits in the page, so while it is
   there it moves what is under it down by its own height (about 100px). */
@media (max-width: 640px) {
    .v2-pw-notice {
        display: grid;
        grid-template-columns: auto 1fr auto;
        align-items: start;
        width: auto;
        padding: var(--space-3);
        border-radius: 20px;
        column-gap: var(--space-3);
        row-gap: var(--space-2);
    }
    .v2-pw-notice .v2-pw-badge--lg { margin-top: 2px; }
    .v2-pw-notice-text { align-self: center; }
    .v2-pw-notice-go {
        grid-column: 2 / 4;
        justify-content: center;
        min-height: 44px;
    }
    .v2-pw-notice-close { width: 44px; height: 44px; margin: -6px -6px 0 0; grid-column: 3; grid-row: 1; }
}

@media (prefers-reduced-motion: reduce) {
    .v2-greeting[data-v2-typed] .v2-pw-notice { animation: none; }
}

/* ══ THE DOCK'S BLUR IS A LAYER BEHIND IT, NOT THE DOCK ITSELF ════════════════
   Justin, via Daniel 1-Oct: "the blurry dock icons (they seem blurry on all
   devices)".

   A backdrop-filter makes its element a composited layer, and the dock is
   centred, so at about half of all window widths it lands on a half pixel (346.5
   at 1910, measured). A layer on a half pixel is resampled when it is placed, and
   everything painted INTO it softens with it, the tile art included.

   So the frosted glass moves to ::after, behind the tiles, and the dock itself
   is an ordinary box. The tiles paint on the page's own pixel grid; only the
   glass, which is a blur anyway, can sit on the half pixel. The look is
   unchanged: same fill, same blur, same shadow, the rim (::before) still on top.

   isolation keeps the z-index: -1 inside the dock rather than behind the shell. */
.v2-dock.v2-glass {
    isolation: isolate;
    background-color: transparent;
    -webkit-backdrop-filter: none;
            backdrop-filter: none;
}
.v2-dock.v2-glass::after {
    content: '';
    position: absolute;
    inset: 0;
    z-index: -1;
    border-radius: inherit;
    background-color: var(--glass-fill);
    -webkit-backdrop-filter: blur(var(--glass-blur));
            backdrop-filter: blur(var(--glass-blur));
    pointer-events: none;
}
@supports not ((backdrop-filter: blur(1px)) or (-webkit-backdrop-filter: blur(1px))) {
    .v2-dock.v2-glass::after { background-color: rgb(58 74 80 / 62%); }
}

/* ══ NO PAGE SCROLLBAR ON THE DASHBOARD ═══════════════════════════════════════
   Justin: "the Brease logo moves when you go from a section back to the
   homepage". A section is pinned to the screen and never scrolls the page; the
   home view can outgrow a short laptop window, and on Windows that brings in a
   ~15px scrollbar, narrows the page and moves everything centred by half of it.

   Daniel, 1-Oct: "we dont tend to need scroll bars for pages on v2 anyway. most
   are inter panel". So the PAGE's scrollbar is hidden, not reserved: it still
   scrolls by wheel, keys and touch when a short window needs it, and the width
   never changes. Only the viewport's own bar: scrollbar-width does not inherit,
   and html::-webkit-scrollbar is the document scroller only, so every panel's
   scrollbar is untouched. Scoped to the dashboard (the one page with a shell),
   so the sign-in and registration documents keep theirs. */
html:has(.v2-shell) { scrollbar-width: none; }
html:has(.v2-shell)::-webkit-scrollbar { display: none; }

/* ══ THE CLOSED STATUS BAR IS THE OPEN PANEL'S WIDTH ══════════════════════════
   Justin: "the status bar on desktop grows a small amount to the left when
   clicked (should it just be the bigger size to start with?)". Daniel: "make
   closed bar 404". 404 is the open panel's frame width, so opening only drops
   down.

   THE DATE AND TIME HOLD THE LEFT, AS THEY DO OPEN (Daniel, 1-Oct: "the date and
   time shifts to the left on click only ... it should be where it shifts to from
   the beginning"). The open row pushes everything after the time to the right
   (.v2-sp-when has margin-right: auto), so the closed bar does the same from the
   time; both start at the same 20px inset, so nothing moves on open.
   Phones (<=640) have the ⋯ instead of the bar. */
@media (min-width: 641px) {
    .v2-statusbar { min-width: 404px; }
    .v2-statusbar-time { margin-right: auto; }
}

/* ══ THE DOCK PLATES ARE FIGMA'S PNG RENDERS ══════════════════════════════════
   See ui_dock_plate() in include/ui_version.php. The PNGs carry Justin's own
   glass rim and sheen, so the CSS rim (.v2-dock-plate::before, the shared glass
   stroke) would draw a second one, and the gradient fallback behind the art
   could show in the anti-aliased corners. --raster is set only when the PNG set
   is present; an SVG fallback keeps both, as before.

   And no clip: Justin's exports have transparent corners (1-Oct), so the art
   carries its own smoothed corner and rim, which a CSS circle-radius clip
   would trim at the curve. */
.v2-dock-plate.v2-dock-plate--raster { background-image: none; overflow: visible; }
.v2-dock-plate.v2-dock-plate--raster::before { content: none; }

/* ── QUERIES: MINE OR ALL, A SECOND FILTER CELL (2-Oct) ─────────────────────
   _query-filters.php. Desktop: a second 200px dropdown beside the status one.
   Phone (the 900px block above folds every .v2-q-filter-cell to a 44px icon):
   its own glyph, a person, so the two folded buttons say different things.

   ONE CELL OPENS, NOT BOTH. The open-state rules above are written against
   the whole row (.v2-q-filters:has(... expanded)) because there was only ever
   one cell; with two, opening either would have widened both. These restore
   the folded shape to whichever cell is NOT the one open — heavier selectors,
   after them, so nothing above has to change. */
@media (max-width: 900px) {
    .v2-q-filter-cell--scope .v2-combo-button::after {
        --v2-mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23000' stroke-width='2' stroke-linecap='round'%3E%3Ccircle cx='12' cy='8' r='4'/%3E%3Cpath d='M4.5 20c0-4 3.4-6.5 7.5-6.5s7.5 2.5 7.5 6.5'/%3E%3C/svg%3E");
    }

    .v2-q-filters:has(.v2-combo-button[aria-expanded="true"]) .v2-q-filter-cell:not(:has(.v2-combo-button[aria-expanded="true"])) {
        width: 44px;
        flex: 0 0 44px;
    }
    .v2-q-filters:has(.v2-combo-button[aria-expanded="true"]) .v2-q-filter-cell:not(:has(.v2-combo-button[aria-expanded="true"])) .v2-combo-value {
        display: none;
    }
    .v2-q-filters:has(.v2-combo-button[aria-expanded="true"]) .v2-q-filter-cell:not(:has(.v2-combo-button[aria-expanded="true"])) .v2-combo-button {
        padding-left: 0;
    }
    .v2-q-filters:has(.v2-combo-button[aria-expanded="true"]) .v2-q-filter-cell:not(:has(.v2-combo-button[aria-expanded="true"])) .v2-combo-button::after {
        display: block;
    }
}

/* ── THE VIEW TOGGLE IN DARK (5-Oct) ────────────────────────────────────────
   MyOrders/Desktop/Dark (1362:6605), sampled: Flint 600 track (the
   --surface-card it already used), a Flint 200 thumb and Flint 200 idle glyph,
   the Seafoam glyph on the thumb. The light set stays the ViewSlider
   component's Pebble 400. */
[data-theme="dark"] .v2-view-toggle {
    --v2-vt-thumb: var(--colour-flint-200);
    --v2-vt-idle:  var(--colour-flint-200);
}
