/* -----------------------------------------------------------------------------------
   ONE PRESENTATION LAYER.

   Everything this application draws -- the eleven hand-authored displays, their eleven
   dialogs, the command bar, the wall route, and the pages the Screen's renderer composes
   from the primitive catalog -- reads from the vocabulary declared in this file and from
   nothing else. There are three tiers and a page may only ever reach for the next one up:

     1. TOKENS (this block and the dark block below). Every colour, radius, control
        measure, spacing step and type step in the application is named here. A rule
        elsewhere in this file that writes a colour literal, a bare radius or a bare
        font-size is a bug, and `visual-system.test.ts` fails on it.
     2. PRIMITIVES (`.app-*`, declared once in "The shared primitive vocabulary" below).
        A surface, a control, a row, a list, a tag, a checkbox, a notice, a scroller, a
        dialog. Both the hand-authored displays and `view.js`'s renderer compose these.
     3. PANE RULES. What is genuinely this pane's own: its layout, its semantics, its
        one or two deliberate departures. A pane rule may position a primitive, give it
        a modifier, or zero one of its declarations. It may not restate one.

   The extension rule, for a new display and for a new registered primitive alike: build
   it out of tier 2, and if tier 2 has no word for what you need, ADD ONE THERE -- named,
   commented, and used by at least the two callers that wanted it -- rather than writing
   a lookalike in tier 3. See ARCHITECTURE.md, "One presentation layer".

   THE GOLD-AND-BLUE DIRECTION supersedes the mobile visual-polish round's cream/peach/sage
   palette. It is one wallpaper (a fixed gold-and-teal-on-blue marble, see "THE WALLPAPER"
   below) behind every pane, with equal cards floating on it -- no per-destination tint, no
   featured-surface wash, no "which pane am I on" colour cue on the card itself. The one
   accent is gold, reserved for the thing you press or the thing that is selected; the three
   ICON INKS (--icon-gold/-blue/-teal) are a separate, cooler family used only to tell one
   destination's nav icon from another. The token ROLES are unchanged from the round this
   replaces -- --page, --panel-subtle, --accent and the rest still mean exactly what their
   own comments below say -- only the hues, and a handful of geometry/type/motion steps the
   brief widens, moved. :root is the explicit LIGHT preference: the same wallpaper, with
   light cards and a darker amber accent so both clear contrast on a bright field. The dark
   block below is THE APPROVED, default appearance -- see "Appearance default + migration"
   in chat.js and index.html's boot script for how a device actually lands there. Every new
   or moved value below carries a computed contrast/dE figure in its own comment rather than
   a link to a script this pass did not need to run.
   ----------------------------------------------------------------------------------- */

/* DM Sans (body) and Manrope (display/headings), both Open Font License, self-hosted so the
   app never depends on Google Fonts at runtime. Both files are the Google latin-subset
   variable fonts (weights 400-500, the only two this design uses -- see --w-* below) and
   both declare the same unicode-range Google publishes for that subset, so a glyph outside
   it falls through to the system fallback in --font-body/--font-display instead of showing
   a tofu box. font-display: swap shows the fallback immediately rather than a blank paint. */
@font-face {
  font-family: 'DM Sans';
  src: url('/fonts/dm-sans-latin.woff2') format('woff2');
  font-weight: 400 500;
  font-style: normal;
  font-display: swap;
  unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC,
    U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215,
    U+FEFF, U+FFFD;
}

@font-face {
  font-family: 'Manrope';
  src: url('/fonts/manrope-latin.woff2') format('woff2');
  font-weight: 400 500;
  font-style: normal;
  font-display: swap;
  unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC,
    U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215,
    U+FEFF, U+FFFD;
}

:root {
  color-scheme: light;
  --font-body: 'DM Sans', system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif;
  --font-display: 'Manrope', var(--font-body);
  /* THE WALLPAPER. One fixed backdrop behind every pane in both themes -- see .app-wallpaper
     near the shared primitives below -- a blue marble base with a gold wave entering from the
     upper right, a teal wave from the lower left, and a blue wave anchoring the bottom. These
     are the only colour literals for it anywhere in the file: the layer itself reads nothing
     but these tokens. Ported from the phone-width reference at its own literal geometry (the
     wallpaper's rule comments carry the position math), unchanged by theme -- a light preference
     changes the CARDS floating on it, never the wallpaper underneath them. */
  --wall-base-radial: #367fba;
  --wall-grad-1: #235b98;
  --wall-grad-2: #2c6199;
  --wall-grad-3: #244b80;
  --wave-gold-1: #d7ac4d;
  --wave-gold-2: #c69139;
  --wave-gold-3: #aa7630;
  --wave-teal-1: #238da1;
  --wave-teal-2: #3aa7ac;
  --wave-teal-3: #27879f;
  --wave-blue-1: #254a80;
  --wave-blue-2: #3c65a1;
  /* The launcher tiles sit on the wallpaper, which is the same in both themes, so their
     dark bevelled ground and the three ink families drawn on it are too: --icon-tile-2 is
     the dark theme's own card surface as a literal (not --panel-subtle, which is a pale
     card here) and the inks are the dark block's values. #e5c47c / #9fc7f0 / #9ed4cc measure
     5.2:1 / 5.0:1 / 5.3:1 against #3b4c61, the lighter tile stop. */
  --icon-tile-1: #3b4c61;
  --icon-tile-2: #27313e;
  /* The tile's gradient ground, shared by the launcher's .app-tile-icon and the dock's
     .dock-icon (a smaller sibling of the same tile). */
  --icon-tile-ground: linear-gradient(145deg, var(--icon-tile-1), var(--icon-tile-2) 80%);
  --icon-gold: #e5c47c;
  --icon-blue: #9fc7f0;
  --icon-teal: #9ed4cc;
  /* The wallpaper's own overlay (a top-to-bottom darken so text near the edges keeps some
     legibility margin) and the two waves' inset highlights, exactly the reference's own
     literals converted from #rrggbbaa to rgb()/alpha. */
  --wall-overlay-top: rgb(18 43 71 / 0.08);
  --wall-overlay-bottom: rgb(22 50 85 / 0.52);
  --wave-gold-shadow: rgb(255 241 179 / 0.11);
  --wave-teal-shadow: rgb(185 227 215 / 0.07);
  /* Text set directly on the wallpaper, not on a card: a page heading (--ink-heading, white)
     and a quieter caption (this token) both fall short of 4.5:1 against the wallpaper's own
     lightest patch (--wall-base-radial: 4.28:1 and 3.45:1 respectively) -- the brief's own
     prescribed fix is the soft text-shadow below, not a brighter colour, since a brighter
     muted tone would stop reading as quieter than the heading beside it. */
  --ink-on-wall-muted: #dce8f4;
  --text-shadow-on-wall: 0 1px 3px rgb(12 30 55 / 0.45);
  /* A secondary control set directly on the wallpaper, not on a card -- the reference's own
     gp-top-button. Both states and both themes: the wallpaper is unconditional (see --page
     below), so this ground never has to change with --theme. */
  --top-button-ground: rgb(255 255 255 / 0.11);
  --top-button-ground-hover: rgb(255 255 255 / 0.16);
  /* The Review/Emails switch's own track (.email-review-bar): a darker, bluer translucent
     ground than --top-button-ground, the reference's own gp-view-switch value, so the
     switch's selected pill (--panel-subtle) still reads as the lighter, "chosen" surface
     against it. Unconditional for the same reason --top-button-ground is. */
  --switch-track-ground: rgb(15 35 60 / 0.32);
  /* --page is the flat fallback for wherever the fixed wallpaper layer cannot reach (behind a
     dialog's own scrim maths, print, a canvas too small to keep it in view) -- --wall-grad-3,
     literally, the colour the wallpaper itself settles to at its own lower edge, so a fallback
     never reads as a seam against the real thing. Card headings sit on the wallpaper now, not
     on --panel-subtle, so they read in --ink-heading (white, both themes) rather than --ink. */
  --page: #244b80;
  --ink-heading: #ffffff;
  /* The light preference: the SAME wallpaper, with light cards. #f4f6f9 clears 13.9:1 against
     --ink and 6.4:1 against --muted below (computed with the WCAG relative-luminance formula;
     see the file history for the script). */
  --panel-subtle: #f4f6f9;
  /* The persistent player bar: a cool blue-grey pulled from the wallpaper's own blue, so the bar
     reads as a distinct control rather than a near-white slab beside the composer pill. Its
     edge and the unplayed part of its progress line step a little darker. */
  --player-surface: color-mix(in srgb, var(--page) 16%, var(--panel-subtle));
  --player-line: color-mix(in srgb, var(--page) 30%, var(--line-strong));
  --player-progress-track: color-mix(in srgb, var(--page) 22%, var(--panel-subtle));
  /* Sunken and hover both read as one step down from --panel-subtle, same role as always. */
  --panel: #e7eaef;
  --surface-raised: var(--panel-subtle);
  --surface-high: var(--panel);
  --ink: #1e2733;
  --muted: #4f5b6a;
  --faint: color-mix(in srgb, var(--muted) 68%, var(--panel-subtle));
  --line: #dfe3e8;
  --line-strong: #cbd1d9;
  /* The one action/selection colour: a darker amber than the dark theme's gold, chosen so it
     clears 4.5:1 as TEXT on the light card (measured 5.5:1 on #f4f6f9) as well as working as a
     fill -- white ink on this fill measures 5.9:1. Hover darkens further, toward black, since
     it is already the darker of the pair (the dark theme's own hover brightens instead, for the
     same reason its own comment below gives). */
  --accent: #8a5a00;
  --accent-hover: color-mix(in srgb, var(--accent) 85%, black);
  --on-accent: #ffffff;
  --accent-soft: color-mix(in srgb, var(--accent) 14%, var(--panel-subtle));
  --error: #b42318;
  --code: #eef1f5;
  --accent-ink: color-mix(in srgb, var(--accent) 55%, var(--ink));
  /* The dock's own current-item ink (see .dock-button[aria-current]): the plain accent
     reads fine as text on the light card, so this aliases straight to it rather than
     inventing a second amber for a theme the reference itself never drew. */
  --dock-ink-current: var(--ink-heading);
  /* The dock and the Calendar's Now marker sit on the wallpaper, not on a card, so the light
     theme's dark amber --accent is invisible on them. The Now marker takes the on-wall
     white heading ink (4.28:1 on the wallpaper's lightest patch, the same figure and the same
     text-shadow fix the page headings carry). */
  --now-ink: var(--ink-heading);
  /* THE DESTINATION IDENTITY TOKENS. The gold-and-blue direction gives every destination the
     SAME accent as everything else -- cards are equal, and there is one accent per region (rule
     01) -- so unlike the three-ink family the dark block below uses for its nav icons, every
     one of these aliases straight to --accent here, and every wash is the plain card surface:
     no cream/peach/sage tint survives in either theme. */
  --accent-meals: var(--accent);
  --wash-meals: var(--panel-subtle);
  --accent-grocery: var(--accent);
  --wash-grocery: var(--panel-subtle);
  --accent-household: var(--accent);
  --wash-household: var(--panel-subtle);
  --accent-podcasts: var(--accent);
  --wash-podcasts: var(--panel-subtle);
  --accent-music: var(--accent);
  --wash-music: var(--panel-subtle);
  --accent-calendar: var(--accent);
  --wash-calendar: var(--panel-subtle);
  --accent-email: var(--accent);
  --wash-email: var(--panel-subtle);
  --accent-library: var(--accent);
  --accent-stocks: var(--accent);
  --accent-settings: var(--accent);
  --accent-photos: var(--accent);
  --accent-outcomes: var(--accent);
  /* The stock chart panel: a dark trading-chart surface in both themes, unrelated to either
     theme's own card. Unchanged by this round. */
  --chart-surface: #191814;
  --chart-ink: #8a8578;
  --chart-line: color-mix(in srgb, var(--chart-ink) 30%, var(--chart-surface));
  /* The Library's own type badges, unchanged by this round. */
  --library-image-ink: #0a6a70;
  --library-note-ink: #93221f;
  --library-link-ink: #4f5348;
  --library-remove-border: #d9a39d;
  --shadow-float: 0 -4px 10px rgb(41 38 30 / 0.16);
  --shadow-sheet: 0 -6px 20px rgb(41 38 30 / 0.16);
  --scrim: rgb(35 33 26 / 0.34);
  /* The reference's own card shadow (see .gp-panel in the source mock): a soft drop plus a
     hairline inner highlight, so a card reads as gently raised off the wallpaper rather than
     flat-pasted onto it. Colour literals only, no radius/size token involved, so it needs no
     entry in the radius vocabulary below. */
  --shadow-card: 0 5px 14px rgb(20 32 50 / 0.11), inset 0 1px rgb(255 255 255 / 0.04);
  /* The composer pill's own hairline highlight (see .composer-row): the reference's
     #ffffff0b, converted to rgb()/alpha the same way the wallpaper's own literals above
     were. Lighter than --shadow-card's inset alone, and with no drop shadow of its own --
     the pill sits inside the command bar's already-elevated surface, not on the wallpaper
     directly. */
  --shadow-inset-highlight: inset 0 1px rgb(255 255 255 / 0.043);
  /* The launcher tile icon's own three-layer shadow (see .app-tile-icon): two insets that
     read as a bevel plus a soft drop, the reference's own #b5d6ef40/#101f3355/#14203233
     converted the same way. */
  --shadow-tile-bevel:
    inset 0 1px 1px rgb(181 214 239 / 0.25),
    inset 0 -1px 1px rgb(16 31 51 / 0.33);
  --shadow-tile-icon: var(--shadow-tile-bevel), 0 6px 12px rgb(20 32 50 / 0.2);
  /* The dock's smaller tile: the same bevel under a shorter drop. */
  --shadow-tile-icon-dock: var(--shadow-tile-bevel), 0 3px 6px rgb(20 32 50 / 0.22);
  /* The same tile pressed (see .app-tile:active .app-tile-icon): the drop shortens and
     the lower inset drops out, the reference's own reduced pair for :active. */
  --shadow-tile-icon-active: inset 0 1px 1px rgb(181 214 239 / 0.25), 0 2px 5px rgb(20 32 50 / 0.2);
  /* Status colours, unchanged in role: what they mean, they mean everywhere, never a category
     or an accent. --warn (amber) and the new amber --accent sit closer in hue than either
     theme's blue accent ever did -- an inherent tension in a gold-accent system this pass does
     not resolve, since both individually still clear their own 4.5:1 floors (light --warn on
     --panel-subtle measures 4.9:1; see the dark block for its own figure) and neither is a
     status site --accent ever appears beside. */
  --ok: #0d7a4a;
  --warn: #96600a;
  /* Person-slot category hues. Slot 4 moves off its old brown/tan (#8f5f2b) for the same
     reason slot 5 has twice moved off the app's own accent hue before it (see the dark block):
     an amber accent and a brown person tag measured dE76 13.9 apart, well under the ~20 floor
     the other four slots clear against it, so a person's row could misread as the selected
     one. #5b6b83 (a cool slate) measures dE76 67.1 from --accent and 34.5 from the nearest
     other slot (teal, slot 1). */
  --cat-1: #0f8f8a;
  --cat-2: #7a5ad0;
  --cat-3: #c2567f;
  --cat-4: #5b6b83;
  --cat-5: #2a5e3c;
  --cat-1-wash: color-mix(in srgb, var(--cat-1) 12%, var(--panel-subtle));
  --cat-2-wash: color-mix(in srgb, var(--cat-2) 12%, var(--panel-subtle));
  --cat-3-wash: color-mix(in srgb, var(--cat-3) 12%, var(--panel-subtle));
  --cat-4-wash: color-mix(in srgb, var(--cat-4) 12%, var(--panel-subtle));
  --cat-5-wash: color-mix(in srgb, var(--cat-5) 12%, var(--panel-subtle));
  --cat-border-mix: 35%;
  --wash-shared: #eeeee6;
  /* The card-list idiom's visible border, unchanged in role. */
  --card-line: var(--line-strong);
  /* An input/select/textarea's own ground: the light theme's already-sunken --panel reads
     fine as a field, so it just aliases; the dark block overrides this with the reference's
     own translucent-white field fill instead of repeating --panel there (see the dark
     block's own comment on this token for why a real fill and a translucent one are not
     interchangeable). */
  --field-fill: var(--panel);
  /* THE CARD GEOMETRY. The reference's own panel: a noticeably rounder corner and roomier
     padding than the old 10px/12px card, so a card reads as a deliberate object floating on
     the wallpaper rather than a bounded list row. --card-pad stays the single property most
     rules read (.app-card's own signature is `padding: var(--card-pad)`); the two halves below
     exist for the handful of rules that pad an axis on its own. */
  --card-radius: var(--radius-surface);
  --card-pad-y: 15px;
  --card-pad-x: 17px;
  --card-pad: var(--card-pad-y) var(--card-pad-x);
  --card-gap: 12px;
  /* THE RADIUS VOCABULARY. Six named roles, none of which scales with text size -- see
     visual-system.test.ts, which fails on a bare radius outside this list.
       --radius-nested   a small element inside another: a compact badge or status label, a small
                         thumbnail, a selected inner segment, an icon inset inside a tile
       --radius-control  buttons, inputs, selects, menu-item highlights
       --radius-tile     app tiles: dock icons, All Apps icons, app-selection tiles, device-type
                         icon containers. Equal to --radius-control today, but a separate role
       --radius-surface  cards, grouped lists, the composer, the mini-player, bubbles
       --radius-overlay  dialogs, sheets, the app's large floating frame panels
       --radius-pill     an explicit exception only: switch tracks, meters, progress bars, count
                         chips, round icon buttons
       --card-radius     alias of --radius-surface, kept because many rules read this name
       50%               a circle, written literally, because it is a shape not a size */
  --radius-nested: 6px;
  --radius-control: 10px;
  --radius-tile: 10px;
  --radius-surface: 16px;
  --radius-overlay: 20px;
  --radius-pill: 999px;
  /* A QR's quiet zone is white in every theme. */
  --qr-ground: #ffffff;
  /* THE CONTROL MEASURES, unchanged in role. */
  --control-min: 32px;
  --touch-min: 44px;
  --control-pad-y: 0.25rem;
  --control-pad-x: 0.7rem;
  /* The ringed-circle checkbox (.app-check), sized to the reference's own 22px ring (from its
     24px predecessor); 19px is still the Screen's own smaller decorative step. */
  --check-size: 22px;
  --check-size-small: 19px;
  /* --control-line is given directly by the reference for the dark/approved theme (see the
     dark block); the light value keeps the established derivation -- a mix of --muted toward
     --line-strong -- so it re-centres on the new light --muted/--line-strong automatically. */
  --control-line: color-mix(in srgb, var(--muted) 64%, var(--line-strong));
  /* THE STOCKS ROW TINTS, unchanged by this round. */
  --stock-up: #34d399;
  --stock-down: #f87171;
  --stock-yellow: #eab308;
  --stock-stop-line: #6e6a5c;
  --stock-up-bg: #dcefdf;
  --stock-up-border: #a3d0ad;
  --stock-up-status: #0d6b3d;
  --stock-down-bg: #f7d0ca;
  --stock-down-border: #e0a299;
  --stock-down-status: #93221f;
  --stock-row-meta-ink: #4e5347;
  --stock-flat-bg: #e6e5db;
  --stock-flat-border: #c0bfb2;
  --stock-flat-status: #4f5148;
  /* THE WEIGHT LADDER. The gold-and-blue direction uses only two weights -- large Manrope
     display type reads at 400, everything that used to step up to 600/700 (a title, a control
     label, a section marker) now steps to 500. --w-display and --w-meta collapse onto --w-body
     and --w-title's own new values respectively; all four tokens stay, since every existing
     font-weight in the file points at one of them and nothing here restates a number. */
  --w-display: 400;
  --w-title: 500;
  --w-meta: 500;
  --w-body: 400;
  --keyline: 2.5px;
  /* List geometry: the phone's own side gutter widens to the reference's 17px; --page-gutter
     is the new, slightly roomier measure a page HEADER reads (21px, the reference's own
     `.gp-header` padding) now that a header sits on the wallpaper rather than in a bounded
     card. --list-lead/-gap are unchanged: the rail still has to hold `10:00 PM` whole. */
  --list-gutter: 17px;
  --page-gutter: 21px;
  --list-lead: 76px;
  --list-lead-gap: 12px;
  /* Half the difference between a checkbox's box and its 44px touch target -- 11px now that
     the box itself is 22px (was 10px against a 24px box); the formula is what checkbox-shape
     .test.ts's own touch-target test recomputes from --check-size, not a literal 44. */
  --touch-slack: 11px;
  /* The semantic text-size tokens, set per preset by data-text-size (text-size.js); these
     are the Medium values. Inputs never drop below 16px, which avoids iPhone focus zoom. */
  --text-detail-meta: 1rem;
  --text-detail-body: 1.125rem;
  --text-detail-title: 1.375rem;
  --leading-detail-tight: 1.3;
  --leading-detail-body: 1.5;
  --text-page-title: 2rem;
  --text-section: 1.25rem;
  --text-item: var(--text-detail-body);
  --text-support: var(--text-detail-meta);
  --text-caption: 0.875rem;
  --text-control: 1.125rem;
  --text-dock-label: 1rem;
  --text-launcher-label: 0.875rem;
  --text-input: max(1rem, var(--text-detail-body));
  /* Fixed: transport controls do not scale with the text preset. */
  --podcast-control-size: 1.6875rem;
  /* Icon geometry: fixed per viewport width tier (the media queries after the preset
     blocks), never by the text preset or the viewport's height. */
  --launcher-tile: 60px;
  --launcher-glyph: 30px;
  /* An installed Android app's own icon sits inset in the tile ground at this share of the
     tile, the same room a page glyph has, so no tile is ever a bare square bitmap. */
  --launcher-native-inset: 0.72;
  --dock-tile: 52px;
  --dock-glyph: 26px;
  /* The composer's own 67px resting height (50px row + the reference's own 17px top /
     0 bottom footer padding, .composer below) plus the dock's own ~88px resting height
     (10px top padding + 52px tile + 6px gap + one 20px label line) below it -- ~155px. Only the pre-measure fallback: chat.js measures the real
     dock, which grows with a wrapped or larger label. */
  --command-bar-space: 9.6875rem;
  /* Where .chat-lower's scrollers end: --command-bar-space less the composer's 17px top
     padding (transparent, so not part of the opaque stack) less --dock-overlap. The overlap
     is how far a scroller reaches BEHIND the opaque stack; it is negative here, so the
     scroller stops 4px ABOVE the pill and a clear band of wallpaper separates them. chat.js
     overrides the clearance with measured values. */
  --dock-content-clearance: calc(var(--command-bar-space) - 17px - var(--dock-overlap));
  --dock-overlap: -4px;
  --dock-fade: 12px;
  --dock-rest-gap: 16px;
  /* The one gap between stacked bottom-shell surfaces (the mini player above the composer pill). */
  --stack-gap: 8px;
  /* .chat's own bottom padding (without the safe-area inset it also adds); the phone
     breakpoint below lowers it. .chat-lower's reservation subtracts it. */
  --shell-pad-bottom: 1rem;
  /* The flex gap between .chat's rows (.chat-upper, .chat-lower); a row that renders
     nothing cancels it (.chat-upper below). */
  --shell-gap: 0.75rem;
  --shell-max: 45rem;
  /* The reading column every page's header and content share on a wide screen (Today, Inbox and the lists all centre on it). */
  --content-max: 60rem;
  --shell-gutter: 1rem;
  /* Press feedback (scale(.98) on :active, the reference's own button feel) and a page
     transition's own easing, named so both are the one thing prefers-reduced-motion turns
     off across every consumer rather than a per-component transition duration. */
  --motion-press: 140ms;
  --motion-page: 260ms;
  --ease-page: cubic-bezier(.2, .8, .2, 1);
}

/* Text size presets (text-size.js sets data-text-size on <html>). Only the base steps are
   redefined; --text-item, --text-support and --text-input follow them. */
:root[data-text-size="small"] {
  --text-detail-body: 1rem;
  --text-detail-meta: 0.875rem;
  --text-detail-title: 1.125rem;
  --text-section: 1.0625rem;
  --text-page-title: 1.875rem;
  --text-caption: 0.75rem;
  --text-control: 1rem;
  --text-dock-label: 0.875rem;
  --text-launcher-label: 0.8125rem;
}

:root[data-text-size="large"] {
  --text-detail-body: 1.375rem;
  --text-detail-meta: 1.125rem;
  --text-detail-title: 1.625rem;
  --text-section: 1.5rem;
  --text-page-title: 2.25rem;
  --text-caption: 1rem;
  --text-control: 1.375rem;
  --text-dock-label: 1.125rem;
  --text-launcher-label: 1.125rem;
}

/* Icon geometry by viewport width: 320-359 uses the :root values; text never changes it. */
@media (min-width: 360px) {
  :root {
    --launcher-tile: 64px;
    --launcher-glyph: 32px;
  }
}

@media (min-width: 375px) {
  :root {
    --launcher-tile: 68px;
    --launcher-glyph: 34px;
    --dock-tile: 56px;
    --dock-glyph: 28px;
  }
}

@media (min-width: 412px) {
  :root {
    --launcher-tile: 72px;
    --launcher-glyph: 36px;
  }
}

/* The dock table above is for phones; tablets, desktops and the TV keep the compact dock. */
@media (min-width: 48rem) {
  :root {
    --dock-tile: 32px;
    --dock-glyph: 20px;
  }
}

/* Phone: touch targets are 48px (never below 44px) and the gutters tighten. Density comes
   from dropping redundant inset, not from shrinking type or targets. --card-pad is restated
   because it is computed from the two it follows where it is declared. */
@media (max-width: 47.99rem) {
  :root {
    --control-min: 44px;
    --touch-min: 48px;
    --list-gutter: 16px;
    --page-gutter: 16px;
    --card-pad-x: 14px;
    --card-pad: var(--card-pad-y) var(--card-pad-x);
  }

  /* A header with no subtitle needs less vertical room; safe-area padding is separate. */
  .app-page-header:where(:not(:has(.app-page-header-subtitle))) {
    padding-block: 12px;
  }
}

@media (max-width: 359px) {
  :root {
    --list-gutter: 12px;
    --page-gutter: 12px;
    --card-pad-x: 12px;
    --card-pad: var(--card-pad-y) var(--card-pad-x);
  }
}

/* Dark theme. Same roles as the light :root above -- most of what changes is the base
   surface/ink/status/category values; anything defined as a color-mix() of those (
   --accent-ink, --cat-N-wash) follows them automatically and needs no restating here.
   --accent-hover and the destination accent/wash pairs are formulas over their OWN literal
   (not over --accent or the light block's value), so each gets its own line below rather
   than silently keeping its light-theme shade.

   This is its own neutral-charcoal composition, not the light block darkened: a cool
   near-black canvas, two lighter elevation steps for raised surfaces (below), and the
   richer blue lifted to a paler, still-legible sky tone that reads as the one action
   colour. It stays a genuinely separate direction rather than collapsing to one theme
   because it is a contract this application argued for and shipped -- a wall-mounted
   kitchen panel that is on all day in a room whose light changes, where the OS answer is
   routinely wrong (see DESIGN.md, "Appearance"). The Screen reads in this vocabulary too,
   so it is light exactly when the app is, which on the default `system` preference is
   almost always.

   Elevation runs the OPPOSITE direction from light mode here: light's --panel is a step
   DARKER than --panel-subtle (a sunken field, a hovered row), but stacking dark surfaces
   the same way reads as murkier rather than raised. --panel is redefined below to
   --surface-raised -- a step LIGHTER than --panel-subtle -- and --surface-high is a
   second, lighter step again for a card that must read as one tier above a raised field
   (see .app-card's dark override and the Podcasts/agenda-tint fixes near the shared
   primitives below). */
:root[data-theme="dark"] {
  color-scheme: dark;
  /* THE APPROVED APPEARANCE. --page is --wall-grad-3 above, literally: the flat colour the
     wallpaper itself settles to, so wherever the fixed layer cannot reach reads as a
     continuation of it rather than a seam. */
  --page: #244b80;
  /* The reference's own card surface, opaque -- not the near-black --panel-subtle this token
     held before. Raised/high step a little lighter again, the same "lighter, not darker"
     elevation direction this block has always used. */
  --panel-subtle: #27313e;
  /* The player bar keeps the dark look it always had; the tokens exist so the light block can
     differ. */
  --player-surface: var(--panel-subtle);
  --player-line: var(--line-strong);
  --player-progress-track: rgb(255 255 255 / 0.14);
  --surface-raised: #303c4b;
  --surface-high: #3a4859;
  --panel: var(--surface-raised);
  --line: rgb(255 255 255 / 0.06);
  --line-strong: rgb(255 255 255 / 0.12);
  --ink: #f1f3f5;
  /* Page headings now sit on the wallpaper, not on --panel-subtle -- see --page-header
     below -- so they read in white rather than --ink: 4.28:1 measured against the
     wallpaper's own lightest patch (--wall-base-radial, #367fba), just under the 4.5:1 text
     floor, which is why .app-page-header-title also carries a soft text-shadow (below) --
     the brief's own prescribed fix, not a colour change. */
  --ink-heading: #ffffff;
  --muted: #b3becb;
  --faint: color-mix(in srgb, var(--muted) 68%, var(--panel-subtle));
  /* THE one action/selection colour, gold, given directly by the reference (its --gp-gold).
     Nothing else in this theme is accent-coloured by default; a destination's nav icon uses
     one of the three ICON INKS below instead (--icon-gold/-blue/-teal), a cooler, more
     numerous family reserved for "which destination is this", never for "press this". */
  --accent: #e3bd68;
  --accent-hover: color-mix(in srgb, var(--accent) 85%, white);
  /* Ink on a gold fill: 7.1:1 against --accent itself. */
  --on-accent: #293441;
  /* ~16% gold over the card, the reference's own selected-nav-ground value (#e3bd6828). */
  --accent-soft: color-mix(in srgb, var(--accent) 16%, var(--panel-subtle));
  --error: #ef6f66;
  --code: #202b33;
  --accent-ink: color-mix(in srgb, var(--accent) 55%, var(--ink));
  /* The dock's own current-item ink (see .dock-button[aria-current]): the reference's own
     literal (#ffdfa2), a paler, more washed-out gold than --accent itself -- legible small
     text reads better slightly lighter than a fill colour does. */
  --dock-ink-current: #ffdfa2;
  --now-ink: var(--accent);
  /* THE THREE ICON INKS, the reference's own app-icon palette (appColors in the source mock).
     A destination's nav icon takes one of the three rather than a hue of its own -- thirteen
     destinations collapse onto three families instead of the nine-plus hand-picked hues the
     mobile visual-polish round carried. */
  --icon-gold: #e5c47c;
  --icon-blue: #9fc7f0;
  --icon-teal: #9ed4cc;
  /* THE DESTINATION IDENTITY TOKENS. today/meals/podcasts/music -> gold; calendar/household/
     library/stocks/settings -> blue; email/grocery/photos/outcomes -> teal (five of these --
     library, stocks, settings, photos, outcomes -- are new: the mobile visual-polish round
     gave them no destination accent at all, and the reference's own three-way grouping covers
     every destination this app has). Every wash is the plain card surface -- cards are equal,
     no tinted destination ground survives; Today has no tokens of its own here because its
     cards carry no destination wash at all now (see .today-card in the Today section below)
     -- only its card headings' icons pick up the plain --accent gold, the same accent a card
     heading in any other gold-family pane would. */
  --accent-meals: var(--icon-gold);
  --wash-meals: var(--panel-subtle);
  --accent-podcasts: var(--icon-gold);
  --wash-podcasts: var(--panel-subtle);
  --accent-music: var(--icon-gold);
  --wash-music: var(--panel-subtle);
  --accent-calendar: var(--icon-blue);
  --wash-calendar: var(--panel-subtle);
  --accent-household: var(--icon-blue);
  --wash-household: var(--panel-subtle);
  --accent-library: var(--icon-blue);
  --accent-stocks: var(--icon-blue);
  --accent-settings: var(--icon-blue);
  --accent-email: var(--icon-teal);
  --wash-email: var(--panel-subtle);
  --accent-grocery: var(--icon-teal);
  --wash-grocery: var(--panel-subtle);
  --accent-photos: var(--icon-teal);
  --accent-outcomes: var(--icon-teal);
  /* Person-slot category hues. Slot 4 moves off its old brown/tan (#c08b55) for the same
     reason the light block's own comment gives: it sat too close to the new gold accent
     (dE76 22.4) to trust beside it. #8ba3c2 (a cool slate, the dark twin of the light
     block's own replacement) measures dE76 67.5 from --accent and 32.4 from the nearest
     other slot (teal, slot 1). */
  --ok: #4ec2a0;
  --warn: #d9a23e;
  --cat-1: #5cc9c9;
  --cat-2: #a48bea;
  --cat-3: #e184a6;
  --cat-4: #8ba3c2;
  --cat-5: #3e8f63;
  --cat-1-wash: color-mix(in srgb, var(--cat-1) 26%, var(--panel-subtle));
  --cat-2-wash: color-mix(in srgb, var(--cat-2) 26%, var(--panel-subtle));
  --cat-3-wash: color-mix(in srgb, var(--cat-3) 26%, var(--panel-subtle));
  --cat-4-wash: color-mix(in srgb, var(--cat-4) 26%, var(--panel-subtle));
  --cat-5-wash: color-mix(in srgb, var(--cat-5) 26%, var(--panel-subtle));
  --wash-shared: #212a30;
  /* The reference's own field ground, given directly (its .gp-composer input's implicit
     transparency plus the sheet's own inset highlight reads as a faint lift, not a real
     surface) -- --panel is opaque here (a real card/scroller ground, --surface-raised), so
     a field needs a token of its own rather than reusing it translucent-by-coincidence. */
  --field-fill: rgb(255 255 255 / 0.06);
  /* Sits WITH the surrounding dark surfaces instead of fighting them: reuses this theme's own
     panel and muted ink rather than a colour of its own. --chart-line needs no entry here --
     its formula in the light block already reads whichever of these two is active. */
  --chart-surface: var(--panel);
  --chart-ink: var(--muted);
  --library-image-ink: color-mix(in srgb, #0a6a70 70%, white);
  --library-note-ink: color-mix(in srgb, #93221f 70%, white);
  --library-link-ink: color-mix(in srgb, #4f5348 60%, white);
  --library-remove-border: color-mix(in srgb, var(--library-note-ink) 45%, var(--panel-subtle));
  --stock-up-bg: color-mix(in srgb, var(--stock-up) 18%, var(--panel-subtle));
  --stock-up-border: color-mix(in srgb, var(--stock-up) 45%, var(--panel-subtle));
  --stock-up-status: color-mix(in srgb, var(--stock-up) 85%, white);
  --stock-down-bg: color-mix(in srgb, var(--stock-down) 18%, var(--panel-subtle));
  --stock-down-border: color-mix(in srgb, var(--stock-down) 45%, var(--panel-subtle));
  --stock-down-status: color-mix(in srgb, var(--stock-down) 85%, white);
  --stock-row-meta-ink: var(--muted);
  --stock-flat-bg: color-mix(in srgb, var(--muted) 18%, var(--panel-subtle));
  --stock-flat-border: color-mix(in srgb, var(--muted) 45%, var(--panel-subtle));
  --stock-flat-status: color-mix(in srgb, var(--muted) 85%, white);
  /* Given directly by the reference (its own .gp-check ring): 5.1:1 against --panel-subtle. */
  --control-line: #93a3b3;
  --shadow-float: 0 -3px 8px rgb(0 0 0 / 0.35);
  --shadow-sheet: 0 -4px 14px rgb(0 0 0 / 0.5);
  /* A darker navy than the old near-black scrim, so a dialog still reads as dimming AWAY from
     the wallpaper's own blues rather than toward a colour indistinguishable from them. */
  --scrim: rgb(10 20 35 / 0.6);
}

/* ===================================================================================
   THE WALLPAPER: one fixed layer behind every pane, in both themes -- see index.html,
   which places it as body's own first child, never inside <main class="chat">. It belongs
   to the viewport, not to content: html, body, .chat and .chat-upper all read var(--page)
   or transparent rather than painting their own opaque ground (below and near .chat), so
   the wallpaper shows through everywhere a real surface (.app-card, .app-surface, an
   .app-scroller's own field/page ground) does not cover it.

   Ported from the reference's own phone-width composition -- a 390x854 frame, each wave an
   absolutely-positioned circle (width/height/left/top, then rotated) -- by keeping its exact
   pixel geometry as a ratio of that frame rather than copying the pixels themselves: width,
   height, left and top are each the reference's own literal divided by the frame dimension
   it is measured against (390 for anything horizontal, 854 for anything vertical) and
   multiplied back out over 100vw/100dvh, so the composition holds -- gold entering upper-
   right, teal lower-left, blue anchoring the bottom, at the same crossing points the
   reference itself renders -- from a 320px phone to a 430px one, and from 640px tall to
   932px, rather than stretching a fixed shape or drifting off a hand-picked oversized guess.
   dvh, not vh, for every vertical figure: the dynamic viewport height, so an iOS address bar
   showing or hiding never reflows the composition the way vh's larger, static value would.
   The desktop block below (48rem) is a deliberately calmer, wider composition -- gold to the
   right third, teal to the left third, blue along the bottom -- rather than the phone shape
   stretched wide.

   No animation, no blur filter, no background-attachment trick: three flat gradients and one
   overlay, all colour literals living in the token blocks above (visual-system.test.ts's
   colour scan is the enforcement; the position/size figures below are geometry, which the
   same test does not restrict). The wall route keeps its own dedicated look (see
   ARCHITECTURE.md, "The wall route") and may show this same layer underneath it. */
.app-wallpaper {
  position: fixed;
  inset: 0;
  z-index: -1;
  overflow: hidden;
  pointer-events: none;
  background:
    radial-gradient(ellipse at 8% 0, var(--wall-base-radial) 0, transparent 62%),
    linear-gradient(155deg, var(--wall-grad-1), var(--wall-grad-2) 48%, var(--wall-grad-3) 100%);
}

.app-wallpaper::after {
  content: '';
  position: absolute;
  inset: 0;
  background: linear-gradient(180deg, var(--wall-overlay-top), transparent 29%, transparent 72%, var(--wall-overlay-bottom) 100%);
}

.app-wallpaper-wave {
  position: absolute;
  border-radius: 50%;
}

.app-wallpaper-wave-gold {
  width: calc(610 / 390 * 100vw);
  height: calc(950 / 854 * 100dvh);
  left: calc(105 / 390 * 100vw);
  top: calc(100 / 854 * 100dvh);
  transform: rotate(28deg);
  background: linear-gradient(145deg, var(--wave-gold-1), var(--wave-gold-2) 45%, var(--wave-gold-3));
  box-shadow: inset 20px 8px 48px var(--wave-gold-shadow);
}

.app-wallpaper-wave-teal {
  width: calc(550 / 390 * 100vw);
  height: calc(810 / 854 * 100dvh);
  left: calc(-320 / 390 * 100vw);
  top: calc(280 / 854 * 100dvh);
  transform: rotate(-26deg);
  background: linear-gradient(140deg, var(--wave-teal-1), var(--wave-teal-2) 49%, var(--wave-teal-3));
  box-shadow: inset -16px 0 42px var(--wave-teal-shadow);
}

.app-wallpaper-wave-blue {
  width: calc(670 / 390 * 100vw);
  height: calc(500 / 854 * 100dvh);
  left: calc(25 / 390 * 100vw);
  bottom: calc(-200 / 854 * 100dvh);
  transform: rotate(-26deg);
  background: linear-gradient(30deg, var(--wave-blue-1), var(--wave-blue-2));
}

/* Tablet/desktop: a calmer, wider composition rather than the phone shape stretched across
   a much wider frame -- gold anchored to the right third, teal to the left third, blue along
   the bottom, each far shallower than its phone counterpart since a wide viewport needs
   reach, not height. */
@media (min-width: 48rem) {
  .app-wallpaper-wave-gold {
    width: 55vw;
    height: 140vh;
    top: -25vh;
    right: -8vw;
  }

  .app-wallpaper-wave-teal {
    width: 50vw;
    height: 130vh;
    top: 5vh;
    left: -8vw;
  }

  .app-wallpaper-wave-blue {
    width: 65vw;
    height: 55vh;
    left: 18vw;
    bottom: -22vh;
  }
}

/* ===================================================================================
   THE SHARED PRIMITIVE VOCABULARY (tier 2).

   Every display, every dialog, the command bar and the pages view.js composes are built
   from these and nothing else. The measures began as the Calendar agenda's, which is why
   several of them used to be declared as a joined selector with their agenda originals --
   .app-row and .agenda-event-toggle on one rule, and so on. That join is gone: agenda.js
   puts the shared class on the node instead, so Calendar now READS this vocabulary rather
   than being named inside it, exactly like every other pane. Its computed style is
   unchanged either way; what changed is which direction the dependency runs.

   A pane rule below may position one of these, add a modifier, or zero one of its
   declarations. It may not restate one. See the token block at the top of this file for
   the extension rule.
   =================================================================================== */

/* --panel-subtle, not --panel: several panes hang a hoverable control off this bar
   (Podcasts' back and Now Playing buttons, .app-quiet-button generally), and that
   control's own hover is --panel. A toolbar sitting on --panel would make that hover
   invisible against its own bar -- the retired --header-tint used to dodge this by being
   its own colour; the plain card surface dodges it by being visibly lighter than --panel. */
.app-toolbar {
  display: flex;
  align-items: center;
  flex: none;
  gap: 0.5rem;
  padding: 0.6rem 0.75rem 0.3rem;
  background: var(--panel-subtle);
  border-bottom: 1px solid var(--line);
}

/* A toolbar inside a pane that already carries its own horizontal padding, so the bar
   keeps its row shape instead of stacking a second inset on top of it. Five panes --
   Meals, Today, Settings, Groceries and the Screen -- had written this same override
   out, with five near-identical comments explaining it. */
.app-toolbar-flush {
  padding-inline: 0;
}

/* --text-detail-body, not --text-detail-title, and it is the same 16px this row has always
   rendered at: every pane that uses this toolbar (Meals, Groceries, Today) is a list whose
   own row titles are --text-detail-body, so the heading sits at their step and is told apart
   by weight. Raising it a step clipped `Dinner plan` against the count beside it, and bought
   nothing: the rule the request set was a 16px floor, and this was never below it. */
.app-toolbar-title {
  flex: 1 1 auto;
  min-width: 0;
  margin: 0;
  color: var(--ink);
  font-size: var(--text-detail-body);
  font-weight: var(--w-title);
  line-height: 1.3;
  overflow-wrap: break-word;
  white-space: normal;
}

/* The count beside a toolbar title is the part that gives way, the same rule .calendar-note
   follows in the bar above: a shrink factor of 100 makes it absorb the whole deficit before
   the pane's own heading loses a character. Without it `Dinner plan` ellipsised against
   `2 meals in the current plan (up to 7).` at 390px. */
.app-toolbar > .app-row-meta {
  flex: 0 100 auto;
  min-width: 0;
}

/* THE SUB-VIEW BAR. Every view one level below a pane (a show, a queue, a playlist, a
   library entry, a photo, a settings page) opens with a back control and its own title, set
   directly on the wallpaper rather than in the shared toolbar's card-ground band: a 44px
   back button in the on-wall heading ink (chevron glyph + the parent's name), then the title
   on its own line in the section step, which may wrap rather than lose its tail. Composed
   onto .app-toolbar so an element that needs the flex row keeps it; this zeroes the band's
   ground, rule and inset. .app-toolbar-flush (a pane whose own scroller already pads the
   sides) is declared after and keeps winning the inline padding. */
.app-back-bar {
  flex-wrap: wrap;
  align-items: center;
  column-gap: 0.5rem;
  row-gap: 0;
  padding: 4px var(--list-gutter) 6px;
  background: transparent;
  border-bottom: 0;
}

.app-back-bar.app-toolbar-flush {
  padding-inline: 0;
}

.app-back-bar > button:first-child {
  flex: none;
  min-height: var(--touch-min);
  padding: 0 0.75rem 0 0;
  color: var(--ink-heading);
  background: transparent;
  border: 0;
  font-size: var(--text-section);
  text-shadow: var(--text-shadow-on-wall);
}

.app-back-bar > button:first-child:hover,
.app-back-bar > button:first-child:focus-visible {
  background: var(--top-button-ground);
}

.app-back-bar > .app-toolbar-title {
  flex: 1 1 100%;
  overflow: visible;
  color: var(--ink-heading);
  font-size: var(--text-section);
  text-shadow: var(--text-shadow-on-wall);
  white-space: normal;
  overflow-wrap: anywhere;
}

/* An empty title (a view whose heading has not loaded yet) must not leave a blank line. */
.app-back-bar > .app-toolbar-title:empty {
  display: none;
}

/* The count beside a title and the trailing text of a bar read as on-wall captions too. */
.app-back-bar > .app-row-meta {
  color: var(--ink-on-wall-muted);
  text-shadow: var(--text-shadow-on-wall);
}

/* A card with nothing in it is a dark empty bar on the wallpaper: a list container that has
   no rows (a failed feed, a queue not yet loaded) hides the card around it. Only a list or
   text container with nothing in it (ul, ol, p, div) or a [hidden] child counts as empty; a
   bare :empty would also match an img, canvas or input, which are content. */
.app-card:empty,
.app-card:has(> :empty:only-child),
.app-card:has(> *):not(:has(> :not([hidden], ul:empty, ol:empty, p:empty, div:empty))) {
  display: none;
}

/* Softer than the all-caps tracked caption this used to be: the audit's "reduce
   uppercase/letter-spacing" note, answered once here because every section heading in the
   app already goes through this rule. The label still reads as a label through the system
   face, the --muted ink and the title weight -- a word the eye can find without being
   shouted at. Household-facing labels this rule draws (Food preferences, Saved recipes,
   grocery category names, Playlists) read in the platform font now, not monospace -- the
   mobile-polish audit's finding that monospace was leaking onto ordinary words rather than
   staying reserved for tickers/ids/numeric data. The Screen keeps its own stronger override
   (.view-section > .app-section-label) for the one surface that reads as a page rather than
   a list. */
.app-section-label {
  margin: 0;
  color: var(--muted);
  font-size: var(--text-detail-meta);
  font-weight: var(--w-title);
  letter-spacing: 0.05em;
}

/* A list row that is also a control: no chrome of its own beyond a hover/focus wash and the
   same brief press feedback every other pressable control gives (.app-quiet-button and
   .app-primary-button's own scale(0.98), --motion-press). One rule here, rather than one per
   caller, covers every row this primitive composes onto -- Today's own openers, a podcast
   episode or group, a meal's opener, an agenda event or task's own toggle. */
.app-row {
  display: flex;
  width: 100%;
  min-height: 0;
  align-items: flex-start;
  gap: 0.6rem;
  padding: 0.35rem 0.3rem;
  color: var(--ink);
  text-align: left;
  background: transparent;
  border: 0;
  border-radius: var(--radius-control);
  font: inherit;
  transition: transform var(--motion-press) ease, background var(--motion-press) ease;
}

.app-row:hover,
.app-row:focus-visible {
  background: var(--panel);
}

.app-row:active {
  transform: scale(0.98);
}

@media (prefers-reduced-motion: reduce) {
  .app-row:active {
    transform: none;
  }
}

/* Every row title wraps rather than overflowing, stated here once: the two agenda overrides
   below used to carry this separately, one apiece, before what a card row needed became what
   every row needs. */
.app-row-title {
  margin: 0;
  font-size: var(--text-detail-body);
  font-weight: var(--w-title);
  line-height: var(--leading-detail-tight);
  overflow-wrap: anywhere;
}

.app-row-meta {
  margin: 0.1rem 0 0;
  color: var(--muted);
  font-size: var(--text-detail-meta);
  line-height: var(--leading-detail-tight);
}

/* Calendar's day heading, event title and task title read as one restrained hierarchy
   instead of three same-looking titles: the day heading alone still steps up to
   --text-detail-title (see .agenda-day-heading, unchanged, 18px/700), and an event title
   reads at the shared row rule above (16px/600) with no escalation of its own and no rule of
   its own at all: the wrap it used to carry is stated once in that shared rule now, and an
   override left standing with nothing in it is only somewhere for a declaration to creep back
   in. A task is a plan, not a claim on the day the way an event is, so it is the one title
   here that steps down from the shared 600 -- to 500 rather than 400, the same weight Today's
   task row gives it (see the .today-row :has() rule), so a task reads alike on both
   displays it appears on. */
/* One step below its row title, because the row title does the naming. A task's own title
   is the second thing read in that row, not the first. */
.agenda-task-title {
  font-weight: var(--w-meta);
}

.agenda-event-meta,
.agenda-task-planned,
.agenda-task-repeats,
.agenda-task-time-meta {
  margin-top: 4px;
  font-size: var(--text-detail-body);
  line-height: var(--leading-detail-tight);
}

/* A planned-for-another-day task's own date label reads one step quieter than the row
   around it, through ink alone: the muted caption colour, applied evenly to every such
   label and never a state colour. A plan that has passed is still a plan -- the words
   stay exactly what agendaTaskPlannedLabel builds, and nothing here says otherwise. A timed
   task's own time meta (.agenda-task-time-meta, the reference's own plain "5:00 PM") reads
   the same quiet way -- a time is a datum, not a state, the same rule .agenda-event-time
   gives the event rail beside it. */
.agenda-task-planned,
.agenda-task-time-meta {
  color: var(--muted);
}

.app-tag {
  display: inline-block;
  padding: 0.02rem 0.35rem;
  color: var(--muted);
  border: 1px solid var(--line);
  border-radius: var(--radius-nested);
  font-size: var(--text-detail-meta);
  font-weight: var(--w-meta);
  vertical-align: middle;
}

/* The same marker with a ground under it, for a mark that has to be found in a dense
   list rather than read beside a title -- a calendar cell's chips and its "+N more",
   a library type badge. Written four times before this pass at three different radii,
   which is the difference between a marker you recognise everywhere and four things
   that happen to be small. */
.app-tag-solid {
  background: var(--panel);
  border-color: transparent;
}

/* The agenda's tags sit directly after a title with no gap of their own, so they carry
   the gap; a tag that follows its own row's gap (Email Review's kinds) does not. */
.agenda-event-overlap-tag,
.agenda-event-repeats-tag {
  margin-left: 0.4rem;
}

/* Repeats reads as a tracked marker, not a second tag competing with the category wash on
   the same card -- the outline .app-tag and .agenda-event-overlap-tag still draw is dropped
   here only. Colour is --muted, not --faint: this is read content at a glance, not
   incidental text (see the --faint token comment). */
.agenda-event-repeats-tag {
  padding: 0;
  color: var(--muted);
  background: none;
  border: none;
  border-radius: 0;
  text-transform: uppercase;
  letter-spacing: 0.08em;
}

/* A quiet nested block: panel surface at the small reading size. */
.app-inset {
  margin: 0 0 0.4rem;
  padding: 0.45rem 0.55rem;
  color: var(--muted);
  background: var(--panel);
  border-radius: var(--radius-control);
  font-size: var(--text-detail-meta);
}

.app-quiet-button,
#calendar-reminder-enable,
.calendar-notifications {
  min-height: var(--control-min);
  padding: var(--control-pad-y) var(--control-pad-x);
  color: var(--ink);
  background: transparent;
  border: 0;
  border-radius: var(--radius-control);
  font: inherit;
  font-size: var(--text-detail-body);
  font-weight: var(--w-meta);
  transition: transform var(--motion-press) ease, background var(--motion-press) ease;
}

.app-quiet-button:hover,
.app-quiet-button:focus-visible,
#calendar-reminder-enable:hover,
#calendar-reminder-enable:focus-visible,
.calendar-notifications:hover,
.calendar-notifications:focus-visible {
  background: var(--panel);
}

.app-quiet-button:active,
#calendar-reminder-enable:active,
.calendar-notifications:active {
  transform: scale(0.98);
}

/* The filled treatment the brief's control vocabulary asks for beside the quiet button
   above and the icon/overflow button below: the pane's ONE clearly primary action --
   never a row's routine action (Dismiss, Later, Save), which stays quiet, and never
   every button on a pane, which is exactly the "outlined text button everywhere" the
   brief's audit found. Same box as .app-quiet-button (min-height, radius, padding) so
   the two read as one family at two weights, not two different controls. */
.app-primary-button {
  /* --touch-min (44px), not --control-min: this is the one control the brief calls out
     for an "intentional action" floor of its own, matching the reference's own gold
     button exactly rather than a control-min literal near it. */
  min-height: var(--touch-min);
  padding: var(--control-pad-y) var(--control-pad-x);
  color: var(--on-accent);
  background: var(--accent);
  border: 0;
  border-radius: var(--radius-control);
  font: inherit;
  font-size: var(--text-detail-body);
  font-weight: var(--w-title);
  transition: transform var(--motion-press) ease, background var(--motion-press) ease;
}

.app-primary-button:hover,
.app-primary-button:focus-visible {
  background: var(--accent-hover);
}

.app-primary-button:active {
  transform: scale(0.98);
}

.app-primary-button:disabled {
  color: var(--muted);
  background: var(--panel);
}

/* An icon plus one word, in the same pill as any other quiet button. Selected is the
   shared .app-selected and not a colour of its own -- the bell inside stays in the
   button's own ink (see .calendar-notifications svg below), never a colour it owns. */
.calendar-notifications {
  display: inline-flex;
  align-items: center;
  gap: 0.35rem;
  flex: none;
  /* Sized with the Calendar bar it lives in rather than with the detail scale, for the reason
     spelled out on .email-review-toggle strong: this row is the app's tightest at 390px, and
     the width this control takes comes straight out of the note beside it. */
  font-size: var(--text-detail-meta);
  margin-right: 0.5rem;
}

.calendar-notifications svg {
  width: 16px;
  height: 16px;
  flex: none;
  stroke: currentColor;
  fill: none;
}

/* The pill: the quiet button's box, round. Its 500 weight is deliberate -- this is a
   navigation action rather than a quiet secondary one, so it keeps the heading weight the
   quiet button steps down from. Stocks' own selector chip is the one caller now: Calendar's
   Today control, this primitive's original reason for existing, moved onto the wallpaper
   itself when the header did (see .app-top-button below) and no longer sits on a card
   ground a bordered pill would read against. */
.app-pill {
  flex: none;
  min-height: var(--control-min);
  padding: var(--control-pad-y) var(--control-pad-x);
  color: var(--ink);
  background: transparent;
  border: 1px solid var(--line);
  border-radius: var(--radius-control);
  font: inherit;
  font-size: var(--text-detail-body);
  font-weight: var(--w-title);
}

.app-pill:hover,
.app-pill:focus-visible {
  background: var(--panel);
}

/* The reference's own gp-top-button: a secondary action set directly on the wallpaper,
   beside a page's own title, on a translucent ground rather than a card. Calendar's Today
   control is the first caller. */
.app-top-button {
  flex: none;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  gap: 7px;
  min-height: var(--touch-min);
  min-width: var(--touch-min);
  padding: 10px 13px;
  color: var(--ink-heading);
  background: var(--top-button-ground);
  border: 0;
  border-radius: var(--radius-control);
  font: inherit;
  font-size: var(--text-caption);
  font-weight: var(--w-title);
  box-shadow: var(--shadow-inset-highlight);
}

.app-top-button:hover,
.app-top-button:focus-visible {
  background: var(--top-button-ground-hover);
}

.app-top-button:disabled {
  opacity: 0.6;
}

.app-top-button[hidden] {
  display: none;
}

/* Check now's own refresh icon (prepended by chat.js, see appIconSvg) is this primitive's
   first caller to carry one. */
.app-top-button svg {
  width: 15px;
  height: 15px;
  stroke-width: 2;
}

.app-status {
  margin: 0;
  color: var(--muted);
  font-size: var(--text-detail-body);
}

.app-failure {
  margin: 0;
  color: var(--error);
  font-size: var(--text-detail-body);
}

.app-empty {
  margin: 0;
  color: var(--muted);
  font-size: var(--text-detail-body);
}

/* A loading placeholder, for a surface whose fetch is already tracked in JS and would
   otherwise show nothing (or a bare "Loading…" line) while it waits. It stands on the
   wallpaper drawn in the card's own surface (--panel-subtle, --card-radius, --shadow-card),
   so it never reads brighter or more transparent than the card it stands in for. Composed
   by the caller onto whatever box shape the real content will take (a grid tile, a row).
   The reduced-motion twin follows the checkbox control's own pattern: the animation drops
   to the static tint, never a motionless flash. */
.app-skeleton {
  min-height: 1rem;
  background: var(--panel-subtle);
  border-radius: var(--card-radius);
  box-shadow: var(--shadow-card);
  animation: app-skeleton-pulse 1.6s ease-in-out infinite;
}

@keyframes app-skeleton-pulse {
  50% {
    background: color-mix(in srgb, var(--panel) 55%, var(--panel-subtle));
  }
}

/* A skeleton shaped like a list of rows: one .app-card (it supplies the surface) holding
   .app-skeleton-row entries, each an optional thumb beside two lines. A list of them takes
   .app-card-list's gap, and the card composes .app-divider-list for the hairline between
   rows. The pane's own "Loading…" line is what is announced, so the card is aria-hidden. */
.app-skeleton-card {
  display: flex;
  flex-direction: column;
}

.app-skeleton-row {
  display: flex;
  align-items: center;
  gap: 12px;
  min-height: 44px;
  padding-block: 6px;
}

.app-skeleton-thumb {
  flex: none;
  width: 40px;
  height: 40px;
  border-radius: var(--radius-control);
  background: var(--panel);
  animation: app-skeleton-line-pulse 1.6s ease-in-out infinite;
}

.app-skeleton-lines {
  flex: 1 1 auto;
  min-width: 0;
  display: flex;
  flex-direction: column;
  gap: 8px;
}

.app-skeleton-line {
  width: 85%;
  /* Follows the text step (10.8px at Medium) so a skeleton holds the rough height of the
     line it stands in for at Large too. */
  height: calc(var(--text-detail-body) * 0.6);
  border-radius: var(--radius-pill);
  background: var(--panel);
  animation: app-skeleton-line-pulse 1.6s ease-in-out infinite;
}

.app-skeleton-line-short {
  width: 45%;
}

@keyframes app-skeleton-line-pulse {
  50% {
    background: color-mix(in srgb, var(--line) 60%, var(--panel));
  }
}

@media (prefers-reduced-motion: reduce) {
  .app-skeleton,
  .app-skeleton-thumb,
  .app-skeleton-line { animation: none; }
}

/* The one accented datum in a row (a date, a time), or a row whose leading title is that datum. */
.app-key {
  color: var(--accent-ink);
  font-weight: var(--w-title);
}

/* Rhythm, not cards: one faint divider between entries and none around them. */
.app-divider-list > * + * {
  border-top: 1px solid var(--line);
}

/* The app's second list idiom. .app-divider-list above is rhythm, not cards: it suits a list
   of lines inside one document. This one suits a list whose entries are things in their own
   right -- a calendar row, an email -- where the eye has to find where one ends and the next
   begins. The two are siblings; neither replaces the other. */
.app-card-list {
  display: flex;
  flex-direction: column;
  gap: var(--card-gap);
}

.app-card {
  padding: var(--card-pad);
  background: var(--panel-subtle);
  border: 0;
  border-radius: var(--card-radius);
  box-shadow: var(--shadow-card);
}

/* No dark override here on purpose. This rule used to promote every card to --surface-high,
   reasoning that a card had to read one step above a --panel field it sat inside -- but no
   pane paints that field any more (.app-scroller-field and .app-scroller-page are both
   `background: transparent` below, and the wallpaper is the only ground behind them). A
   card is the ONLY opaque surface between its content and the wallpaper now, so the base
   rule's own --panel-subtle -- #27313e in the approved theme, the reference's own opaque
   .gp-panel -- is already the right, single step; promoting it further just made the card
   read close enough to the wallpaper's own blue to look translucent. An inset or field
   nested INSIDE a card (Email Review's own overflow menu, an .app-inset) may still take
   --panel/--surface-raised as its own lighter step above the card -- that pairing is
   unaffected, since it was never about the card's own background. */

/* A grouped surface: several rows a page reads as ONE block, rather than a list of
   bounded entries (.app-card-list) or lines in one document (.app-divider-list). It is
   the largest of the three radii because it is the largest of the three shapes -- an
   18px corner on a 40px card looks like a mistake, and a 10px corner on a whole block
   looks like a card that grew.

   This is the Screen's own 18px surface, promoted. It was written four times there as a
   literal (.view-body's page, the five live blocks, and two container overrides) and
   once more on the composer's `+` sheet, and it is the shape that made a generated page
   read as a published document rather than app chrome -- which is exactly why the rest
   of the app should have the word too. */
.app-surface {
  padding: 0.7rem 0.85rem;
  background: var(--panel-subtle);
  border: 1px solid var(--line);
  border-radius: var(--radius-surface);
}

/* THE SCROLLER. Every display in this app is one: the band shows exactly one pane, and
   that pane owns the vertical scroll. Thirteen rules wrote this out before this round --
   eight whole panes plus five inner scrollers -- and overscroll-behavior is part of the
   primitive rather than an afterthought, because a scroller that bubbles its overscroll
   drags the whole app on a phone.

   The mechanics are here; the GROUND is a separate decision and gets its own name,
   because it is not arbitrary -- it follows from which of the app's two list idioms the
   pane holds. See "Which list idiom a new list should use": entries that are things in
   their own right sit as bounded cards on a pale field, and lines inside one document sit
   on the plain card surface. Eight panes were making that choice correctly and writing it
   out by hand; now they name it. */
.app-scroller {
  display: flex;
  flex-direction: column;
  flex: 1 1 0;
  min-height: 0;
  overflow-y: auto;
  overscroll-behavior: contain;
}

/* `hidden` IS ONLY A UA `display: none`, which any author rule naming `display`
   outranks -- so every primitive that sets one has to hand the attribute back, or a
   consumer that relied on the UA rule silently stops hiding. This is stated once, over
   the whole set, rather than per primitive: the failure is invisible in a unit test
   (jsdom lays out nothing) and the next primitive to name `display` would have to
   remember. It was not hypothetical -- the Calendar's day list had no `display` of its
   own, so `hidden` worked, and it kept a full-height empty field above the four-week
   grid the moment .app-scroller gave it one. Found in a browser screenshot. */
.app-toolbar[hidden],
.app-row[hidden],
.app-tag[hidden],
.app-card-list[hidden],
.app-skeleton-card[hidden],
.app-skeleton-row[hidden],
.app-skeleton-lines[hidden],
.app-scroller[hidden],
.app-check[hidden],
.app-check-target[hidden],
.app-section[hidden],
.app-stack[hidden],
.app-row-group[hidden],
.app-grid[hidden],
.app-media[hidden],
.app-dialog-frame[hidden],
.app-dialog-header[hidden],
.app-dialog-footer[hidden] {
  display: none;
}

/* Bounded cards on a pale field: Today, Podcasts, the Library, the Calendar's day list,
   the Email Review list, the Stocks list and its chart, Meals and Groceries. The Library
   was the one that MOVED here in an earlier round -- it held cards on the plain surface,
   which is the mismatch the two names exist to make visible; Meals and Groceries moved
   here in the gold-and-blue card round, for the same reason -- each recipe and each
   category is now its own bounded card rather than a line inside one document. */
/* Transparent, both idioms: the gold-and-blue direction's own wallpaper (.app-wallpaper,
   fixed behind everything) is what a pane's own ground used to hide, one dark column per
   destination -- see index.html's boot comment and ARCHITECTURE.md's "One presentation
   layer" for the fixed layer this reveals. A card floating on it still draws its own
   surface (.app-card, .app-surface); it is only the FIELD or PAGE around those cards that
   stopped drawing one of its own. */
.app-scroller-field {
  padding: 10px var(--list-gutter) 1rem;
  background: transparent;
}

/* Lines inside one document: Household, Settings, the Screen and the All launcher. The
   roomier inset is this idiom's own -- a document wants a margin, a field of cards wants
   a gutter; vertically only -- sideways its cards start at the same --list-gutter as a
   field's, at every width. */
.app-scroller-page {
  padding: 0.75rem var(--list-gutter) 1rem;
  background: transparent;
}

/* A page header that lives INSIDE a padded scroller (Meals, Groceries, Music, Library, Photos,
   Outcomes, Household, Settings, All apps) stacked its own 21px --page-gutter on the
   scroller's 17px, so those titles sat 38px from the edge while Inbox, Calendar, Stocks and
   Podcasts, whose headers sit outside a padded scroller, sit at 21. The header hands the
   scroller's inset back at every width, so a phone and a tablet both put every title at
   --page-gutter from the pane edge; cards stay at --list-gutter. The wide-screen
   content-column rule in the 68.75rem block still recentres headers and content together.
   Podcasts zeroes its scroller's padding, Today's header is its clock, and the All apps
   launcher keeps its title on the tile grid's first column (the approved reference), so
   none of them take it. */
:where(.app-scroller-field, .app-scroller-page):not(.podcasts, .today, .apps) .app-page-header {
  margin-inline: calc(var(--list-gutter) * -1);
}

/* A page header owns its own 18px padding-top (.app-page-header above); a field or page
   container that ALSO wraps it as the pane's first content stacks a second gap on top of
   that one -- the exact ~10px Meals/Groceries/Music (both classes on the outer section, or
   one level in through a bare wrapper div like #music-overview/#settings-root) used to read
   lower than Calendar/Inbox/Stocks/Podcasts, whose header sits outside any field/page
   padding. Podcasts solved this itself by zeroing its whole field padding and pushing the
   gutter into each sub-view instead (see .podcasts above); this generalises just the
   top-padding half of that fix to every pane whose header is its scroller's first child, so
   every destination's title lands at the same offset without touching the side/bottom
   gutters its card list still needs. */
.app-scroller-field:has(.app-page-header:first-child),
.app-scroller-page:has(.app-page-header:first-child) {
  padding-top: 0;
}

/* THE CHECKBOX, once. This ringed circle was written out seven times before this pass
   -- .agenda-task-check, .calendar-candidate-choice, .meals-proposal-choice's input,
   .grocery-check-input, .grocery-ingredient-check, .view-task-checkbox and
   .view-grocery-checkbox -- each with its own prefers-reduced-motion twin, and the last
   two byte-for-byte identical to each other. Seven copies is seven chances for one
   display's checkbox to drift, which is the most literal possible way a set of pages
   stops feeling like one product.

   It works on a <span> as well as an <input>: the Screen draws a decorative,
   always-open ring for a task list it has no done-state for, and a span simply never
   matches :checked. The ring reads --control-line rather than --line-strong, which is
   a real fix and not a restyle: a card's outline measures 1.3:1 and a control's
   boundary has to clear 3:1, so every one of those seven copies was drawing a ring a
   low-vision reader could not find. */
/* Transparent, not --panel-subtle: an unchecked box is a ring, not a filled disc -- the
   reference's own .gp-check input draws no fill at all, and a solid panel-coloured circle
   read as a dark disc rather than an open ring in a screenshot review. :checked below still
   fills it, which is what tells the two states apart beyond colour alone. */
.app-check {
  appearance: none;
  -webkit-appearance: none;
  display: grid;
  flex: none;
  place-content: center;
  width: var(--check-size);
  height: var(--check-size);
  margin: 0;
  background: transparent;
  border: 1.4px solid var(--control-line);
  border-radius: 50%;
  cursor: pointer;
}

.app-check::before {
  content: '';
  width: calc(var(--check-size) / 2);
  height: calc(var(--check-size) / 2);
  border-radius: 50%;
  background: var(--on-accent);
  transform: scale(0);
  transition: transform 120ms ease-in-out;
}

.app-check:checked {
  background: var(--accent);
  border-color: var(--accent);
}

.app-check:checked::before {
  transform: scale(1);
}

.app-check:focus-visible {
  outline: 2px solid var(--accent);
  outline-offset: 2px;
}

/* The smaller ring, for a mark inside a list the pane already owns the rhythm of --
   the Screen's own task and grocery rows. A size modifier only: it re-points the one
   variable every measure above is derived from, so nothing is restated. */
.app-check-small {
  --check-size: var(--check-size-small);
  border-width: 1.5px;
}

@media (prefers-reduced-motion: reduce) {
  .app-check::before { transition: none; }
  .app-quiet-button,
  #calendar-reminder-enable,
  .calendar-notifications,
  .app-primary-button {
    transition: none;
  }
}

/* The same reach, for a checkbox with no wrapper to put it on. A <label> around the box
   is the better answer wherever the row has one, because it also labels; this is for the
   case where the control IS the whole row's leading element and there is nothing to wrap
   -- the Screen's own task and grocery ticks, which are 19px by design and were the one
   tappable thing in the app a finger could miss. An overlay rather than padding, because
   a checkbox's width and height are its box: padding would eat the ring, not grow it.

   Only put this on a control with nothing tappable immediately beside it. It extends
   past its own edges, so on a row that also has a button the overlay would take clicks
   near that button's edge -- which is exactly why the rows that have a wrapper use the
   wrapper instead. */
.app-check-reach {
  position: relative;
}

.app-check-reach::after {
  content: '';
  position: absolute;
  inset: calc((var(--touch-min) - var(--check-size)) / -2);
}

/* The touch target around a checkbox, and it costs no layout space: --touch-slack of
   padding grows the hit area to --touch-min, and the same amount of negative margin
   hands the space straight back, so the label occupies exactly the box's own size.
   Growing the box instead would add 20px to the height of every single-line row that
   carries one. Written three times before this (Today's card, the grocery row, the
   grocery meal row) with the same four values each time. */
.app-check-target {
  display: flex;
  flex: none;
  align-items: center;
  justify-content: center;
  padding: var(--touch-slack);
  margin: calc(var(--touch-slack) * -1);
  cursor: pointer;
}

/* THE SELECTED STATE, once: the accent's own quiet ground, the accent as ink, and a
   step up in weight -- never a solid fill, because a fill is what a primary action looks
   like and a chosen segment that looks like a Submit button is telling you the wrong
   thing. Weight carries the state as well as colour, so selection is never colour alone.

   Five controls said "chosen" before this pass and they said it three different ways:
   the notifications toggle and the two calendar option rows drew an accent ring on the
   hover ground, the person options drew an --ink ring, and the theme options changed the
   ground to --accent-soft. Three answers to one question in three settings surfaces is
   the most direct way a set of pages stops feeling like one product, and each of the
   three had an argument for itself that was really an argument about the OTHER two.

   The ground is what carries it, because the ground is the one form that degrades
   correctly in both places this appears. A standalone control also takes the accent on
   its own border; a segment inside a joined bar has no border of its own to take it
   (the bar owns the border), so that declaration simply does nothing there rather than
   drawing a ring around one segment of a group. Applied by whichever attribute the
   control already uses to say it is chosen, so no controller has to learn a class. */
.app-selected[aria-pressed="true"],
.app-selected[aria-checked="true"],
.app-selected[aria-current="true"],
.app-selected:has(> input:checked),
.app-selected.selected {
  color: var(--accent);
  font-weight: var(--w-title);
  background: var(--accent-soft);
  border-color: var(--accent);
}

/* THE LAYOUT CONTAINERS. These are the four the Screen's primitive catalog names --
   section, stack, row, grid -- and they are shared rather than the renderer's own,
   which is the whole point of this pass: a generated page and a hand-authored one
   should be laying out with the same words. A `section` is the only one of the four
   that sets anything apart visually; the other three are pure arrangement, because a
   document that could draw a box anywhere is a document that has been given styling. */
.app-section {
  display: flex;
  flex-direction: column;
  gap: 0.6rem;
}

.app-stack {
  display: flex;
  flex-direction: column;
  gap: 0.5rem;
}

/* The one container that runs side by side. It wraps rather than shrinking, and each
   child grows from a basis wide enough to read, so two entries share a line only when
   both fit whole; on a phone that is one at a time, with no breakpoint spent to say
   so. */
.app-row-group {
  display: flex;
  flex-wrap: wrap;
  align-items: flex-start;
  gap: 0.5rem;
}

.app-row-group > * {
  flex: 1 1 12rem;
  min-width: 0;
}

/* Two or three columns that collapse to one on a phone -- the collapse is in the
   30rem media query, stated once there for every caller. */
.app-grid {
  display: grid;
  grid-template-columns: repeat(2, minmax(0, 1fr));
  gap: 0.6rem;
}

.app-grid[data-columns="3"] {
  grid-template-columns: repeat(3, minmax(0, 1fr));
}

/* The two heading steps under a page or pane title, one apart and both at title
   weight: level 2 leads at the title step, level 3 steps down to the body size and
   keeps the weight. Sized by element rather than by a modifier class, so a document
   that says "level 3" and a pane that writes <h3> cannot disagree about what that is.
   Declared after .app-inset so a heading inside a quiet block keeps its own size. */
h2.app-heading,
h3.app-heading {
  margin: 0;
  color: var(--ink);
  font-weight: var(--w-title);
  line-height: var(--leading-detail-tight);
  overflow-wrap: anywhere;
}

h2.app-heading { font-size: var(--text-detail-title); }
h3.app-heading { font-size: var(--text-detail-body); }

/* A line of prose. Not .app-row-meta (a caption under a title) and not .app-status (a
   pane telling you what it is doing) -- this is body copy a person reads. */
.app-text {
  margin: 0;
  color: var(--ink);
  font-size: var(--text-detail-body);
  line-height: var(--leading-detail-body);
  overflow-wrap: anywhere;
}

/* One emphasised line, for the thing a person should read first on a surface: the
   Screen's `notice` primitive, the calendar's reminder note, the notifications note,
   the theme note. It brings only type and ink; the surface under it is whichever of
   .app-inset or .app-card the caller wants, exactly the way the Screen's own notice
   variants already worked. Declared after both of those so it wins the size -- a
   notice sits at the body step even inside a quiet block, because a sentence a person
   should read first is not a caption. That is a correction: the Screen drew its own
   notice muted, at the smallest step in the scale, which is the opposite of what the
   primitive is for. */
.app-notice {
  margin: 0;
  color: var(--ink);
  font-size: var(--text-detail-body);
  line-height: var(--leading-detail-body);
  overflow-wrap: anywhere;
}

/* A page's own title, as opposed to a pane's heading (.app-toolbar-title, 16px). The
   one deliberate exception to the fixed three-step detail scale, because a surface
   meant to read as a published page rather than a list needs a size the list steps do
   not have. It is a clamp, so it is responsive with no breakpoint of its own. */
.app-page-title {
  margin: 0;
  color: var(--ink);
  font-size: clamp(28px, 4vw, 42px);
  font-weight: var(--w-display);
  line-height: 1.15;
  letter-spacing: -0.015em;
}

/* THE PAGE HEADER: the pane's own visible title band now, on the wallpaper rather than a
   card -- the gold-and-blue direction's own reversal of the compact-pane-identity round
   this replaces (see ARCHITECTURE.md's "the page header is a compact meta line, not a
   banner", which this header no longer is). The nav's own selected state is still part of
   a pane's identity, but the title is real, visible text again everywhere except Today
   (.today .app-page-header-title below): Today's own clock header is a later slice's
   subject, so its h1 stays the visually hidden focus target it already was. */
.app-page-header {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  column-gap: 0.5rem;
  row-gap: 0.1rem;
  margin: 0;
  padding: 18px var(--page-gutter);
}

.app-page-header[hidden] {
  display: none;
}

/* The pane's canonical name: announced and focusable (chat.js focuses it when the pane
   opens), and visible now -- large, light Manrope, tight tracking, white with the on-wall
   text-shadow the subtitle beside it already used. */
.app-page-header-title {
  margin: 0;
  min-width: 0;
  flex: 1 1 auto;
  font: var(--w-body) var(--text-page-title)/1.15 var(--font-display);
  letter-spacing: -0.035em;
  color: var(--ink-heading);
  text-shadow: var(--text-shadow-on-wall);
}

/* Header actions are compact and never stretch; a gold primary action in a header matches
   the 42px .app-top-button beside it rather than being a bigger slab. Actions may overhang
   the title line by a few px so a page with a 42px action keeps exactly the same title
   position as a page without one. */
.app-page-header > :is(button, select) {
  flex: none;
  margin-block: -4px;
}

.app-page-header > .app-primary-button {
  min-height: var(--touch-min);
  padding: 10px 13px;
  font-size: var(--text-support);
  line-height: 1.2;
}

/* Today alone: its own clock header is its visible identity, so its h1 stays exactly the
   visually hidden heading every pane's title used to be -- the same .visually-hidden
   declarations, restated here because the h1 wears this class rather than that one (see
   visual-system.test.ts's own "except Today" case, which pins the markup this answers). */
/* Its header wrapper collapses to nothing as well: with the h1 out of flow, the wrapper's
   18px top and bottom padding would be 36px of empty band above the clock. */
.today .app-page-header {
  padding: 0;
}

.today .app-page-header-title {
  position: absolute;
  width: 1px;
  height: 1px;
  overflow: hidden;
  clip-path: inset(50%);
  white-space: nowrap;
}

.app-page-header-title:focus {
  outline: none;
}

/* A programmatic focus target (chat.js focuses it on open), not a control. */
.app-back-bar > .app-toolbar-title:focus {
  outline: none;
}

/* One optional slot, generalised from the one existing status pill in the app -- the
   Screen's draft/published #view-state, previously a one-off rule keyed to its old
   position inside .app-toolbar (see the Screen's own visual-language section for the
   draft/published colour switch, still keyed off the same #view-unpublish:not(:disabled)
   state chat.js already maintains). No other destination has pre-existing pill content to
   promote, so this is the only caller. The pill sits in the pane's toolbar now, beside
   the actions it describes; none of these declarations cares where it lives. */
.app-page-header-status {
  display: inline-flex;
  flex: none;
  align-items: center;
  gap: 0.35rem;
  margin-left: auto;
  padding: 0.15rem 0.6rem;
  color: var(--warn);
  background: transparent;
  border: 1px solid color-mix(in srgb, var(--warn) 35%, transparent);
  border-radius: var(--radius-nested);
  font-weight: var(--w-display);
  /* A <p>: its default 1em block margins would make the toolbar row 32px taller. */
  margin-block: 0;
}

.app-page-header-status[hidden] {
  display: none;
}

.app-page-header-status::before {
  content: '';
  width: 6px;
  height: 6px;
  border-radius: 50%;
  background: var(--warn);
}

/* One optional slot, wrapping onto its own line (flex-basis 100%) -- the pane's own short
   piece of glanceable context that used to repeat as a second, separate row in the pane's
   own body: Today's date, Calendar's month, a saved count, a last-checked time. A pane
   with more than one such fragment (Stocks' count and as-of line) keeps each as its own
   child rather than concatenating them into one string, so each keeps writing to the id
   or class it already had.

   The breathing room is a taller line box, not padding, so it exists only where a line
   does: a header whose meta is empty or [hidden] -- most panes in the first moments after
   load, Meals and Settings always -- lays out at 0px, and so does Stocks' before any
   as-of date, when #stocks-source holds nothing but its visually-hidden (absolutely
   positioned, so line-less) detail span. A padded slot kept 6.4px of nothing there. One
   line is 25.6px, measured in a real browser at 390x844. */
.app-page-header-subtitle {
  flex: 1 1 100%;
  display: flex;
  flex-wrap: wrap;
  align-items: baseline;
  column-gap: 0.5rem;
  row-gap: 0.1rem;
  min-width: 0;
  margin: 0;
  /* This line sits directly on the wallpaper, not on a card (.app-page-header draws no
     background of its own -- see the test above this rule in visual-system.test.ts) --
     never --muted, which is tuned for a card. --ink-on-wall-muted plus the shared soft
     shadow are the on-wall pair every caption between cards now reads through instead. */
  color: var(--ink-on-wall-muted);
  text-shadow: var(--text-shadow-on-wall);
  font-size: var(--text-detail-meta);
  font-weight: var(--w-meta);
  line-height: 2;
}

.app-page-header-subtitle[hidden],
.app-page-header-subtitle:empty {
  display: none;
}

/* Stocks' fragments are <p>s: their default 1em block margins would double the line's
   height. */
.app-page-header-subtitle > * {
  margin: 0;
}

/* An unwritten fragment beside a written one (Stocks' count, before any ticker is saved)
   would otherwise still take its column-gap and start the line 8px in from the content
   below it. */
.app-page-header-subtitle > .app-page-header-status {
  margin-left: 0;
}

.app-page-header-subtitle > :empty {
  display: none;
}

/* A lone fragment (Today's date, Calendar's month, a plain saved count) is the common
   case and should ellipsise rather than wrap; a container with more than one child
   (Stocks) leaves each child free to wrap onto its own line instead, since ellipsising
   two independent fragments in one box has no correct single-line answer. */
.app-page-header-subtitle > *:only-child {
  min-width: 0;
  overflow-wrap: break-word;
  white-space: normal;
}

/* A decorative, purely visual progress meter -- aria-hidden, since the text beside it
   ("partly played") already carries the same information for a screen reader. The only
   caller is Podcasts' own episode row, the one place in the app that already tracks
   position/duration data; no other destination has anything to meter. */
.app-meter {
  height: 3px;
  margin-top: 4px;
  background: var(--line-strong);
  border-radius: var(--radius-pill);
  overflow: hidden;
}

/* A field you type in or choose from: a text input, a search box, a select. It is NOT
   .app-quiet-button, and the difference is the ground. A quiet button is transparent
   and washes on hover because it is something you press; a field is a place to put
   something, so it carries the sunken surface at rest and does not react to a pointer
   passing over it. Library's search and type filter, and the Stocks sort, had each
   written out the quiet button's box while meaning this. */
.app-field {
  min-height: var(--control-min);
  padding: var(--control-pad-y) var(--control-pad-x);
  color: var(--ink);
  background: var(--field-fill);
  border: 1px solid var(--line-strong);
  border-radius: var(--radius-control);
  font: inherit;
  /* 16px, the scale's own --text-detail-body -- never smaller on a phone, since anything
     under 16px makes iOS Safari zoom the page on focus. */
  font-size: var(--text-input);
}

.app-field:focus-visible {
  outline: 2px solid var(--accent);
  outline-offset: 2px;
}

/* Artwork: a podcast's cover, a library image, a candidate row's thumbnail. object-fit
   and a ground are the whole point -- without them a non-square source either distorts
   or shows the page through its own transparency. */
.app-media {
  display: block;
  flex: none;
  background: var(--panel);
  border: 1px solid var(--line);
  border-radius: var(--radius-control);
  object-fit: cover;
}

* {
  box-sizing: border-box;
}

body {
  margin: 0;
  overflow: hidden;
  /* Transparent, so .app-wallpaper (its own first child, position: fixed, behind
     everything) is what a person actually sees -- --page (var, above) is only the flat
     fallback for a canvas that layer itself cannot reach. */
  background: transparent;
  color: var(--ink);
  font: var(--text-detail-body)/var(--leading-detail-body) var(--font-body);
}

/* The public pages (pair, install) are plain documents, not the app shell: body's
   overflow: hidden above would clip a long page at Large text or with the keyboard up. */
html:has(body.pair-page) {
  overflow-y: auto;
}

body.pair-page {
  overflow-y: auto;
  min-height: 100dvh;
}

.chat {
  width: 100%;
  max-width: var(--shell-max);
  height: 100vh;
  margin: 0 auto;
  padding: calc(1rem + env(safe-area-inset-top, 0px)) var(--shell-gutter) calc(var(--shell-pad-bottom) + env(safe-area-inset-bottom));
  display: flex;
  flex-direction: column;
  min-height: 0;
  gap: var(--shell-gap);
  /* Transparent, not --panel-subtle or --page: this is the pane frame, not a pane itself,
     and the wallpaper (body's own first child) is what shows through it and through the
     0.75rem gaps between the panes it holds. The border below stays: on desktop it is what
     still marks .chat's own column against the wallpaper running the full viewport width
     around it. */
  background: transparent;
  border: 1px solid var(--line);
  border-radius: var(--radius-overlay);
  transition: box-shadow 140ms ease, background-color 140ms ease;
}

/* At phone widths the shell column IS the screen, so its own 16px inset plus 1px border
   would stack under every pane's own gutter (--list-gutter on a field, --page-gutter on a
   header) and put a card ~34px from the edge against the reference's 17px. Every pane
   already owns its side inset, so the frame gives its side back, up to just under
   --shell-max (a media query cannot read the token, and shell-responsive.test.ts allows
   only the token itself to spell that width). The command bar and dock carry their own
   20px and are position: fixed, so they are unaffected. */
@media (max-width: 44.99rem) {
  .chat {
    padding-inline: 0;
    border-inline-width: 0;
  }
}

.chat.file-drag-active {
  background-color: color-mix(in srgb, var(--ink) 4%, var(--page));
  box-shadow: inset 0 0 0 2px var(--muted);
}

.chat-upper,
.chat-lower {
  display: flex;
  flex-direction: column;
  min-width: 0;
  min-height: 0;
  gap: 0.75rem;
  /* The page-enter slide (below) pushes a section 14px past the right edge; in a mobile
     browser that transient overflow inflates the layout viewport, and the resulting resize
     (syncChatOverlayGeometry() in chat.js) strands --chat-overlay-bottom at a stale value.
     clip, not hidden: hidden would make these scroll containers and break the nested
     scrollers and sticky elements. overflow-y stays visible. */
  overflow-x: clip;
}

.chat-upper {
  flex: none;
}

/* With no visible pane in it (every display but Inbox; .chat-header is display: contents and
   its h1 is out of flow) .chat-upper is a 0-height flex item, and .chat's row gap would still
   sit above .chat-lower as phantom space at the top of the page. It takes that gap back. The
   wall route keeps its own layout untouched. */
body:not(.wall) .chat-upper:not(:has(> section:not([hidden]))) {
  margin-bottom: calc(-1 * var(--shell-gap));
}

.chat-lower {
  flex: 1 1 0;
}

/* Page change: whichever pane chat.js's own markDisplayEntering() just marked fades and
   slides in from the reference's own offset. `.display-enter` is a class chat.js adds to the
   one destination section syncAgentView() just made visible and removes from whichever one
   it replaces -- not the bare `:not([hidden])` selector a from a pure-CSS version of this
   used to key on, which restarts on its own the moment an element's `display` goes from none
   back to something real, but leaves no signal a DOM test can see (jsdom runs no animations
   and lays out nothing). `> section` catches every destination uniformly regardless of which
   of them additionally carries the inner .app-scroller some (not all -- Calendar and Email
   Review scroll a nested div instead) compose. */
@keyframes app-page-enter {
  from {
    transform: translateX(14px);
    opacity: 0.75;
  }
  to {
    transform: translateX(0);
    opacity: 1;
  }
}

.chat-upper > section.display-enter,
.chat-lower > section.display-enter {
  animation: app-page-enter var(--motion-page) var(--ease-page);
}

@media (prefers-reduced-motion: reduce) {
  .chat-upper > section.display-enter,
  .chat-lower > section.display-enter {
    animation: none;
  }
}

/* Clearance under the command bar, so the last row of the agenda, the grocery list, or
   the Stocks chart's own bottom ticker strip is never underneath the composer. It is on
   .chat-lower rather than on each pane because every display surface this app has sits
   inside it, including the near-zero-height one an expanded Email Review panel leaves
   behind. Unconditional: every display has the bar, including Today and Groceries. The
   status rail is attached to the top of the composer's pill and lives inside form#composer,
   so the composer's own measured height already includes it and this reservation grows with
   it -- a chip pushes the pane above up, never under the bar, while the pill and the dock
   stay anchored. The thread zone is the one part that still overlays app content instead of
   resizing it (INV-1), so an open thread never moves this padding or the pane above it.
   env(safe-area-inset-bottom) is added here explicitly -- the bar's own `bottom`
   (`.command-bar`, below) is at least that inset, so a device with a home indicator sits
   that much lower than this reservation would otherwise assume. */
.chat .chat-lower {
  /* The 1px is .chat's own bottom border, which the phone breakpoint keeps. */
  padding-bottom: max(0px, calc(var(--dock-content-clearance) - var(--shell-pad-bottom) - 1px));
}

/* The soft landing above the dock, once, for every scroller in .chat-lower (the shared
   .app-scroller: every phone destination, Calendar's day list and Email Review's detail
   included). The scroller's bottom edge sits --dock-overlap (-4px, so ABOVE) the top of the
   opaque dock stack (the mini player when shown, else the composer pill -- see
   syncCommandBarSpace() in chat.js, which measures the composer's padding-top and the bar
   so this is right with and without a player, at any text size). What sits in the last
   --dock-fade (12px) of the box fades out through a mask, so content slides into the
   wallpaper above the pill instead of running into it. A mask lives on the scroller's own
   box: it stays put while content scrolls beneath it, leaves the wallpaper alone, and takes
   no pointer events or focus, unlike an overlay slab. The end padding is overlap plus the
   resting gap (12px, no less than the fade), so at maximum scroll the last row sits fully
   opaque, 16px clear of the stack; scroll-padding-bottom keeps focus and scrollIntoView targets above the fade too.
   Email Review's detail is the one scroller in .chat-upper. The selector outranks route
   rules that reset a scroller's padding (.podcasts, .agenda-days). */
body:not(.wall) .chat .app-scroller {
  padding-bottom: calc(var(--dock-overlap) + var(--dock-rest-gap));
  scroll-padding-bottom: calc(var(--dock-fade) + 12px);
  -webkit-mask-image: linear-gradient(to bottom, #000 calc(100% - var(--dock-fade)), transparent 100%);
  mask-image: linear-gradient(to bottom, #000 calc(100% - var(--dock-fade)), transparent 100%);
}

/* The one command bar: two zones (composer, dock) stacked bottom-up in ONE
   fixed positioning container, sharing one width (INV-6), but -- unlike the old fixed chat
   sheet this replaced -- drawing no ground of its own: the reference's own gp-footer paints
   nothing either, and the dock below it and the composer pill inside it sit directly on the
   wallpaper, not on a card stacked over it. Each zone keeps whatever surface it needs for
   its own legibility (the composer pill's --panel-subtle fill, a chip row's, the thread
   zone's own opaque --panel-subtle section), but the bar itself is background: transparent,
   border: none, no radius, no shadow -- flat all the way down (INV-6 restated for a
   chromeless container: one *absence* of surface, not one shared surface). The thread zone
   and the status rail are not extra zones here but part of the composer itself: both sit
   inside form#composer, attached to the top of the pill (see .thread-zone and .status-rail
   below), so their heights are the composer's height.

   Bottom-anchored so the bar's last row -- the dock, below the composer in DOM order --
   always meets the viewport's bottom edge; the thread zone and the status rail grow the
   composer from inside, which is what makes all growth go upward
   (INV-2) while the pill and the dock never move for anything but the keyboard (INV-1).
   --chat-overlay-bottom is the one thing kept from the old overlay's geometry sync: chat.js
   still has to ride the bar up over the iOS software keyboard, and this is the same custom
   property it already writes for that. The bar's bottom is the larger of that offset and
   env(safe-area-inset-bottom), not their sum: the keyboard covers the home-indicator area,
   so adding the inset on top of the keyboard offset left a gap and a double offset.

   Centred with auto inline margins against left: 0/right: 0 rather than a translateX,
   reusing exactly the reasoning the old fixed chat sheet and .chat-dock both solved
   already: a fixed element cannot inherit the centred shell column's width, and a
   transform would make this bar the containing block for its own fixed-position
   descendants, which nothing here needs. --shell-max is .chat's own max-width, repeated
   here for the same reason. */
.command-bar {
  position: fixed;
  z-index: 40;
  left: 0;
  right: 0;
  margin-inline: auto;
  width: min(var(--shell-max), 100%);
  bottom: max(var(--chat-overlay-bottom, 0px), env(safe-area-inset-bottom));
  display: flex;
  flex-direction: column;
  min-width: 0;
  background: transparent;
  border: none;
  border-radius: 0;
  box-shadow: none;
}

/* The bar's pass-through boxes (the bar itself, the composer's 17px top padding, the dock's
   padding and gaps) let pointer events through, so a tap on the scroller's fade band above
   the pill reaches the page content that is visibly there. The thread zone and the status
   rail are not among them: each paints an opaque part of the connected surface, so each
   takes its own taps -- a pass-through rail would let a tap on its 1px seam above the pill
   (or in the gap between two of its rows) land on the page underneath it. Empty, the rail
   is display: none, so it never blocks anything. Every box that takes a tap -- a thread
   card, a chip row, a status line, the player surface, the pill, the dock buttons -- takes
   them back, so nothing in the bar stops being clickable. */
.command-bar,
.composer,
.composer-status,
.dock-nav {
  pointer-events: none;
}

.thread-zone > *,
.status-rail > *,
.composer-status > *,
.composer-row,
.podcast-mini-surface,
.dock-nav > * {
  pointer-events: auto;
}

/* Empty -> zero height, the normal state for both zones: neither reserves space or shows a
   surface of its own when it has nothing to say -- the rail attached to the pill included,
   which is what lets the pill keep its own fully rounded shape whenever no chip is
   showing. */
.thread-zone:empty,
.status-rail:empty {
  display: none;
}

/* Collapsed or expanded, the thread zone is the TOP of the one attached surface the rail
   and the input pill finish (the same pattern .status-rail below already states): it sits
   inside form#composer directly above the rail, on the --panel-subtle fill with the inset
   highlight (not the card shadow -- no lift at the seam), owning the surface's top
   --radius-overlay corners while the sections below it take square joins. Like the rail it
   pads nothing horizontally and sets no margin of its own: the composer's own side padding
   wraps zone, rail and pill alike, so all three sections' edges line up exactly. Collapsed
   it is a short stack of one-line summary rows (up to three, then "+N more"); expanded it
   grows upward, never resizing .chat-lower's reservation above (INV-1), its long body
   scrolling inside the panel's own viewport-bounded scroller (below). The zone's own inner
   padding is the small 0.25rem, so the fill runs unbroken around every row it holds. The
   "expanded" class name matches this file's existing convention for a state word added
   alongside a component's own class (compare .chat-open, .review-expanded). */
.thread-zone {
  display: flex;
  flex-direction: column;
  gap: 0.4rem;
  margin: 0;
  padding: 0.25rem;
  background: var(--panel-subtle);
  border-radius: var(--radius-overlay) var(--radius-overlay) 0 0;
  box-shadow: var(--shadow-inset-highlight);
  pointer-events: auto;
}

/* The first max-height is the fallback for engines without dvh; the second bounds the panel
   to the viewport actually left above it once the keyboard has ridden the bar up
   (--chat-overlay-bottom, which chat.js writes) and the composer and dock have taken their
   share (--command-bar-base: the composer+dock height WITHOUT the thread zone, which
   chat.js writes alongside --command-bar-space precisely so this bound does not include the
   panel it is bounding -- with the space token here the panel's own height would eat its
   own allowance), so a long answer scrolls inside the panel instead of running off the top
   of the screen. overscroll-behavior keeps that scroll from chaining into the page behind. */
.thread-zone.expanded {
  max-height: 45vh;
  max-height: min(45dvh, calc(100dvh - var(--chat-overlay-bottom, 0px) - var(--command-bar-base, var(--command-bar-space)) - env(safe-area-inset-top, 0px) - 2rem));
  overflow-y: auto;
  overscroll-behavior: contain;
}

/* The rail is attached to the composer, not floating over the pane: it sits inside
   form#composer directly above .composer-row and draws the top half of the ONE dark
   surface the pill is the bottom half of -- same --panel-subtle fill, same inset
   highlight, the pill's own top corners squared off underneath it (the seam rules below)
   -- so a receipt reads as part of the thing it is a receipt for, not a pill hovering
   above it. Side padding is the composer's own (it wraps pill and rail alike), so the rail
   pads nothing horizontally; overflow: hidden keeps a collapsing row (chat.js retires with
   an animation) inside the surface's rounded top. And because it is inside the composer,
   its height is the composer's height: syncCommandBarSpace() measures them together, so
   the page's bottom reservation grows with the rail and the pane above is pushed up,
   never overlaid. Like the thread zone, the rail is an opaque part of the connected surface
   and takes its own taps (pointer-events: auto), its seam hairline included; empty, it is
   display: none, so it never blocks anything. */
.status-rail {
  display: flex;
  flex-direction: column;
  padding: 0;
  background: var(--panel-subtle);
  border-radius: var(--radius-overlay) var(--radius-overlay) 0 0;
  box-shadow: var(--shadow-inset-highlight);
  overflow: hidden;
  pointer-events: auto;
}

/* With content, the surface ends in a hairline seam above the pill -- --line cut mostly
   transparent, because the full-strength token reads as a hard edge on this fill -- and
   the pill gives up its top corners and inset highlight so nothing cuts across the join.
   A row collapsing out (chat.js's retire animation) keeps the rail :not(:empty) until it
   finishes, so the surface holds its shape for the whole 180ms rather than flashing
   between attached and rounded. */
.status-rail:not(:empty) {
  border-bottom: 1px solid color-mix(in srgb, var(--line) 60%, transparent);
}

.status-rail:not(:empty) + .composer-row {
  border-top-left-radius: 0;
  border-top-right-radius: 0;
  box-shadow: none;
}

/* The same joins one section up: a non-empty thread zone draws the top of the surface and
   ends in the same hairline, a rail below it becomes a square middle section, and the pill
   loses its top corners whenever either section sits above it. When both are empty
   (display: none via the :empty rule above) the pill keeps its full rounding. */
.thread-zone:not(:empty) {
  border-bottom: 1px solid color-mix(in srgb, var(--line) 60%, transparent);
}

.thread-zone:not(:empty) + .status-rail {
  border-top-left-radius: 0;
  border-top-right-radius: 0;
  box-shadow: none;
}

.thread-zone:not(:empty) ~ .composer-row {
  border-top-left-radius: 0;
  border-top-right-radius: 0;
  box-shadow: none;
}

/* The rail's one row component, two states replacing IN PLACE (pending -> receipt).
   Newest nearest the composer: chat.js appends after existing rows, and the rail's column
   flow puts a later sibling closer to the composer below it. data-state follows this
   file's existing convention for a control's own variant (compare
   .dictate[data-state="listening"]) rather than a second class.

   A row IN the attached surface, not a pill of its own: transparent over the rail's own
   fill, no border, radius or shadow (INV-6: the bar's elevation is the only one, and the
   connected surface is the point), left padding that lines the label up beside the pill's
   own contents, right room for the dismiss control's 44px target (.thread-dismiss, the
   one dismiss every dismissible thing in the bar shares), and a min-height that keeps
   every row a comfortable touch height. Always a <div> (chat.js's renderRail), never a
   <button>: a receipt state hosts its own nested .thread-dismiss, and one button cannot
   contain another. border: none is written explicitly rather than omitted, purely for
   clarity alongside the other resets here, not to defeat the generic `button` rule
   (below) the way it would if this were still a bare button. Growth is upward and
   unbounded here on purpose -- a wrapped multi-line .chip-label (see below) grows the row,
   which grows .status-rail, which grows the composer it is measured with, so the
   reservation above follows while the pill and dock stay put. */
.chip {
  display: flex;
  align-items: center;
  gap: 0.5rem;
  min-height: 44px;
  max-width: 100%;
  padding: 0.35rem 0.25rem 0.35rem 1rem;
  color: var(--ink);
  background: transparent;
  border: none;
  border-radius: 0;
  box-shadow: none;
  font-size: var(--text-detail-body);
}

/* Consecutive rows share one surface, so a hairline -- the same weakened --line the seam
   above the pill uses -- is all that tells two chips apart. */
.chip + .chip {
  border-top: 1px solid color-mix(in srgb, var(--line) 60%, transparent);
}

/* Wraps IN FULL -- never truncated, never hides text behind an ellipsis. The rail's one
   length boundary (chat.js's enterRail(), bounded by RAIL_CHIP_MAX_CHARS -- the receipt
   agent's own output.maxChars) keeps anything that would need more than a short line or two
   out of a chip altogether and into a reply thread instead, so nothing that reaches this
   label ever has content with nowhere else to be read. flex: 1 1 auto takes the row's width
   and pushes the dismiss control to the right edge; overflow-wrap keeps one unbroken long
   token (a number, a run-on word) from forcing the row wider than the rail rather than
   wrapping it. */
.chip-label {
  flex: 1 1 auto;
  min-width: 0;
  overflow-wrap: anywhere;
}

.chip-spinner {
  flex: none;
  width: 14px;
  height: 14px;
  border: 2px solid var(--line);
  border-top-color: var(--accent);
  border-radius: 50%;
  animation: chip-spin 0.8s linear infinite;
}

/* A receipt is a navigation target (tap it -> the affected object); a pending chip is not.
   Not a real <button> (see .chip's own comment above), so both affordances a real button
   gives for free are restated here: the pointer cursor, and (below) a focus ring. */
.chip[data-state="receipt"] {
  cursor: pointer;
}

/* Inset, not the outset ring `button:focus-visible` uses: the rail clips overflow (a
   collapsing row has to stay inside it), so anything drawn outside the row's own box
   would be cut off at the surface's edge. */
.chip[data-state="receipt"]:focus-visible {
  outline: none;
  box-shadow: inset 0 0 0 2px color-mix(in srgb, var(--accent) 45%, transparent);
}

@keyframes chip-spin {
  to {
    transform: rotate(360deg);
  }
}

/* chat.js adds .chip-enter to a row as it is created and removes it on animationend. The
   enter animation is only safe at all because renderRail reconciles by key: an existing
   row is updated in place, never rebuilt, so the slide happens exactly once per chip. */
.chip-enter {
  animation: chip-enter 180ms var(--ease-page);
}

@keyframes chip-enter {
  from {
    opacity: 0;
    transform: translateY(6px);
  }
  to {
    opacity: 1;
    transform: none;
  }
}

/* A row chat.js is animating out is already gone for input purposes: no tap can land on
   something that is about to disappear. */
.chip-leaving {
  pointer-events: none;
}

@media (prefers-reduced-motion: reduce) {
  .chip-enter {
    animation: none;
  }
}

/* The thread zone's collapsed one-line row, up to three shown then "+N more" (.thread-more,
   below). Tapping it (or its own chevron) expands that thread into the full .thread body.

   Flat (INV-6): no background, no border, no shadow of its own -- it reads as a row IN
   the thread zone's one opaque panel, not a card floating on it. Separation from a
   sibling row comes from .thread-zone's own gap, not a second elevation. background/border
   are written explicitly rather than omitted because .thread-summary is a <button>, and
   the generic `button` rule (below) would otherwise fill it with --accent and an accent
   border. */
.thread-summary {
  display: flex;
  align-items: center;
  width: 100%;
  gap: 0.5rem;
  padding: 0.55rem 0.75rem;
  color: var(--ink);
  text-align: left;
  background: transparent;
  border: none;
  border-radius: var(--radius-control);
  font-size: var(--text-detail-body);
}

.thread-summary:hover,
.thread-summary:focus-visible {
  background: var(--code);
}

.thread-summary-text {
  flex: 1 1 auto;
  min-width: 0;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}

/* At Large the one-line summary would drop most of a long thread title, so it gets two lines. */
:root[data-text-size="large"] .thread-summary-text {
  display: -webkit-box;
  -webkit-box-orient: vertical;
  -webkit-line-clamp: 2;
  line-clamp: 2;
  line-height: 1.35;
  overflow-wrap: break-word;
  white-space: normal;
}

/* The zone-level "+N more" control, distinct from a needs-input thread's OWN "more"
   control (.thread-options-more, below), which expands rows inside one already-expanded
   thread rather than revealing another collapsed thread. */
.thread-more {
  align-self: flex-start;
  padding: 0.3rem 0.6rem;
  color: var(--muted);
  background: transparent;
  border-color: transparent;
  font-size: var(--text-detail-meta);
}

/* Background browser tasks: one compact row per job, above the thread summaries. Part of
   the zone's one opaque surface, so the list draws no background, border or shadow. */
.browser-jobs {
  display: flex;
  flex-direction: column;
  gap: 0.15rem;
  max-height: 11.5rem;
  max-height: min(11.5rem, 32dvh);
  overflow-y: auto;
  overscroll-behavior: contain;
}

/* While the Browser page shows, the task list keeps to about two rows so the page stays
   usable; the rest is a scroll away. */
.browser-page-showing .browser-jobs {
  max-height: 6rem;
}

.browser-job {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: 0.25rem;
  border-radius: var(--radius-surface);
}

.browser-job-open {
  flex: 1 1 12rem;
  min-width: 0;
  min-height: 48px;
  display: flex;
  align-items: center;
  gap: 0.5rem;
  padding: 0.4rem 0.6rem;
  background: transparent;
  border: none;
  color: var(--ink);
  text-align: left;
  font-size: var(--text-detail-body);
  border-radius: var(--radius-control);
}

.browser-job-open:hover,
.browser-job-open:focus-visible {
  background: var(--code);
}

.browser-job-status {
  flex: none;
  font-weight: var(--w-title);
}

.browser-job-status::before {
  content: '';
  display: inline-block;
  width: 0.5rem;
  height: 0.5rem;
  margin-right: 0.35rem;
  vertical-align: middle;
  border-radius: 50%;
  background: var(--muted);
}

.browser-job[data-status="working"] .browser-job-status::before { background: var(--accent); }
.browser-job[data-status="needs_you"] .browser-job-status::before {
  background: var(--icon-gold);
  box-shadow: 0 0 0 1px var(--accent);
}
.browser-job[data-status="ready"] .browser-job-status::before { background: var(--ok); }

.browser-job-sep {
  flex: none;
  margin: 0 -0.15rem;
  color: var(--muted);
}

.browser-job-text {
  flex: 1 1 auto;
  min-width: 0;
  display: -webkit-box;
  -webkit-box-orient: vertical;
  -webkit-line-clamp: 2;
  line-clamp: 2;
  overflow: hidden;
  text-align: left;
  overflow-wrap: anywhere;
  word-break: normal;
  white-space: normal;
}

/* "Show sign-in" takes its own full-width row so it never squeezes the open button. */
.browser-job-signin {
  flex: 1 1 100%;
  min-height: 44px;
}

/* A working row is one compact line: spinner, one short step, Stop. */
.browser-job-step { flex-wrap: nowrap; }

.browser-job-step .browser-job-text {
  flex: 1 1 auto;
  min-width: 0;
  display: block;
  -webkit-line-clamp: unset;
  line-clamp: unset;
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
}

.browser-job-stop,
.browser-job-dismiss {
  flex: none;
  min-width: 44px;
  min-height: 44px;
  padding: 0 0.7rem;
  margin-left: 0;
  color: var(--muted);
  background: transparent;
  border-color: transparent;
  border-radius: var(--radius-pill);
}

.browser-job-stop {
  min-width: 48px;
  min-height: 48px;
}

@media (prefers-reduced-motion: reduce) {
  .browser-job .chip-spinner { animation-duration: 2.4s; }
}

.browser-job-stop:hover,
.browser-job-stop:focus-visible,
.browser-job-dismiss:hover,
.browser-job-dismiss:focus-visible {
  color: var(--ink);
  background: var(--code);
}

/* One line per remembered change under the reply: "Saved: ... · Undo". */
.memory-note {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: 0.25rem;
  padding: 0 0.6rem;
  font-size: var(--text-detail-body);
  color: var(--ink);
}

.memory-note-text {
  min-width: 0;
  overflow-wrap: break-word;
}

.memory-note-sep {
  flex: none;
  color: var(--muted);
}

.memory-note-undo {
  flex: none;
  min-width: 44px;
  min-height: 44px;
  padding: 0 0.7rem;
  color: var(--muted);
  background: transparent;
  border-color: transparent;
  border-radius: var(--radius-pill);
}

.memory-note-undo:hover,
.memory-note-undo:focus-visible {
  color: var(--ink);
  background: var(--code);
}

.memory-note-error {
  flex-basis: 100%;
  color: var(--muted);
}

.browser-job-answer {
  flex-basis: 100%;
  min-width: 0;
  display: flex;
  gap: 0.4rem;
  padding: 0 0.6rem 0.4rem;
}

.browser-job-answer input {
  flex: 1;
  min-width: 0;
}

/* A question with a finite set of answers: one button per answer plus None of these, in
   the job row's own base-button look, wrapping when a label is long. */
.browser-job-choices {
  flex-basis: 100%;
  min-width: 0;
  display: flex;
  flex-wrap: wrap;
  gap: 0.4rem;
  padding: 0 0.6rem 0.4rem;
}

.browser-job-option,
.browser-job-none {
  min-height: 44px;
  max-width: 100%;
  white-space: normal;
  overflow-wrap: anywhere;
  text-align: left;
}

/* One thread component, three variants (data-variant="reply" | "needs-input" | "failed")
   differing only in which controls the .thread-body carries -- this structure (header,
   dismiss, body) is shared by all three.

   Flat (INV-6): the expanded thread is a section of the thread zone's one opaque panel,
   not its own card, so it draws no background, border or shadow of its own -- .thread-zone
   already supplies the one surface for the whole object. Padding alone separates it from
   the zone's edges and from a sibling thread. */
.thread {
  display: flex;
  flex-direction: column;
  gap: 0.5rem;
  padding: 0.75rem;
  color: var(--ink);
  background: transparent;
  border: none;
  border-radius: var(--radius-overlay);
}

/* Pinned to the top of the zone's own scroller when it is expanded: the collapse chevron
   and the dismiss x stay reachable while the body scrolls, and the body's text passes under
   this header's opaque fill rather than showing through it. The negative top takes back
   .thread-zone's own 0.25rem padding, so the header pins flush to the panel's top edge;
   keep the two values in step. */
.thread-header {
  position: sticky;
  top: -0.25rem;
  z-index: 1;
  background: var(--panel-subtle);
  display: flex;
  align-items: flex-start;
  gap: 0.5rem;
}

/* The chevron that collapses an expanded thread back into its .thread-summary row. */
.thread-toggle {
  flex: none;
  width: var(--touch-min);
  min-width: var(--touch-min);
  min-height: var(--touch-min);
  padding: 0;
  color: var(--muted);
  background: transparent;
  border-color: transparent;
  border-radius: var(--radius-pill);
}

/* The one dismiss x shared by all three variants (reply, needs-input, failed). */
.thread-dismiss {
  flex: none;
  width: var(--touch-min);
  min-width: var(--touch-min);
  min-height: var(--touch-min);
  padding: 0;
  margin-left: auto;
  color: var(--muted);
  background: transparent;
  border-color: transparent;
  border-radius: var(--radius-pill);
}

.thread-toggle:hover,
.thread-toggle:focus-visible,
.thread-dismiss:hover,
.thread-dismiss:focus-visible {
  color: var(--ink);
  background: var(--code);
}

.thread-body {
  display: flex;
  flex-direction: column;
  gap: 0.5rem;
  min-width: 0;
  font-size: var(--text-detail-body);
  overflow-wrap: anywhere;
}

/* reply: a prose answer, or a cancelled proposal's Review control. */
.thread-reply-text {
  overflow-wrap: anywhere;
}

.thread-review {
  align-self: flex-start;
}

/* reply: a research turn's own numbered source list, matching the [n] markers already in
   .thread-reply-text above -- a compact, muted list, never styled as prose. */
.thread-sources {
  display: flex;
  flex-direction: column;
  gap: 0.25rem;
  margin: 0;
  padding: 0;
  list-style: none;
  font-size: var(--text-detail-meta);
  color: var(--muted);
}

.thread-source {
  overflow-wrap: anywhere;
}

/* The one sources row that is a button, not a link (see renderReplyBody in chat.js): it opens
   a Library item inside the app rather than a new tab. It must still read as one of the list's
   own rows, so the global button pill -- which carries padding, an accent background and a
   44px min-height -- is overridden away declaration by declaration. */
.thread-source-open {
  padding: 0;
  min-height: 0;
  border: 0;
  background: none;
  font: inherit;
  color: inherit;
  text-decoration: underline;
  cursor: pointer;
  text-align: left;
}

/* needs-input: a question, up to 3 tap-to-answer rows inline, the rest behind
   .thread-options-more, and an option to answer in the composer instead. */
.thread-question {
  font-weight: var(--w-title);
  overflow-wrap: anywhere;
}

.thread-options {
  display: flex;
  flex-direction: column;
  gap: 0.4rem;
}

.thread-option {
  display: flex;
  align-items: center;
  gap: 0.6rem;
  width: 100%;
  padding: 0.5rem 0.7rem;
  color: var(--ink);
  text-align: left;
  background: var(--panel-subtle);
  border: 1px solid var(--card-line);
  border-radius: var(--card-radius);
  overflow-wrap: anywhere;
}

/* Artwork beside the row's text, bounded -- podcast art is routinely 600px square and
   an unbounded <img> sets the row's height to its own intrinsic size. */
.thread-option-art {
  width: 40px;
  height: 40px;
  flex: none;
  object-fit: cover;
  border-radius: var(--radius-control);
}

.thread-option:hover,
.thread-option:focus-visible {
  background: var(--panel);
}

.thread-options-more,
.thread-answer-composer {
  align-self: flex-start;
  color: var(--muted);
  background: transparent;
  border-color: transparent;
  font-size: var(--text-detail-meta);
}

/* failed: the original utterance, what was understood, and Retry. */
.thread-original {
  color: var(--muted);
}

.thread-original,
.thread-understood {
  overflow-wrap: anywhere;
}

.thread-retry {
  align-self: flex-start;
}

/* The expanded review panel owns the whole display band, whether or not a thread already
   exists in the thread zone -- there is one content scroller at a time. */
.chat.email-review.review-expanded .chat-upper {
  flex: 1 1 0;
}

.chat.email-review.review-expanded .chat-lower {
  flex: none;
  /* Takes back .chat's 0.75rem flex gap between the panel and this (now empty) reservation,
     so the panel's scroller ends where a .chat-lower scroller does: --dock-overlap from
     the opaque dock stack (above it, while the token is negative), not a gap's worth
     further. */
  margin-top: calc(-1 * var(--shell-gap));
}

@supports (height: 100dvh) {
  .chat {
    height: 100dvh;
  }
}

h1 {
  margin: 0;
  font-size: var(--text-caption);
  font-weight: var(--w-title);
  color: var(--muted);
}

/* Carries only the visually hidden <h1> now -- navigation moved to the dock and the All
   launcher (see .dock-nav and .apps below). Kept, rather than deleted along with the old
   ribbon, because WALL_MODE still hides it as one element (chat.js) and a page needs an
   h1 somewhere regardless of what else is on screen. display: contents because that h1 is
   .visually-hidden (position: absolute): a box of its own would be an in-flow 0px flex item
   with .chat-upper's gap beside it. The h1 stays in the accessibility tree. */
.chat-header {
  display: contents;
}

/* THE DOCK. Four destinations, always one tap away, whatever pane is showing: Today,
   Calendar, Inbox and All. Same size selected or not -- no growing button here, unlike
   the ribbon this replaces -- so switching destinations never reflows the bar around it.
   It sits on the wallpaper, not on a card ground, the same way the reference's own
   .gp-nav does: #command-bar's surface belongs to the composer above it, and the dock
   reads as chrome floating past that surface's own bottom edge, not a fifth zone inside
   it.

   #command-bar's own `bottom` (below) is the one owner of env(safe-area-inset-bottom): it
   lifts the whole bar clear of the home indicator, so this padding-bottom is a flat 10px
   and adds no inset of its own. */
.dock-nav {
  display: grid;
  grid-template-columns: none;
  grid-auto-flow: column;
  grid-auto-columns: minmax(0, 1fr);
  gap: 2px;
  padding: 10px 20px;
}

/* All four slots show while arranging; the Favorites label spans the row. */
#dock-nav[data-editing] {
  grid-auto-flow: row;
  grid-template-columns: repeat(4, minmax(0, 1fr));
}

/* While the software keyboard is up for the composer, the dock's four destinations give
   their height to the input and any reply above it; they return the moment the keyboard
   goes or focus moves to another text field. Blurring the composer alone does not bring
   them back: the visual viewport still reports the keyboard until it finishes leaving, and
   the dock would flash above it. syncCommandBarSpace measures the dock's offsetHeight,
   which becomes 0. */
.chat.keyboard-open .dock-nav {
  display: none;
}

/* The reference's own gp-footer breakpoint, ported here and to .composer above since
   neither sits inside a shared footer element (see index.html) the way the reference's
   own composer and nav both do -- each carries the same 20px/16px side inset instead. */
@media (max-width: 370px) {
  .composer,
  .dock-nav {
    padding-left: 16px;
    padding-right: 16px;
  }
}

.dock-button {
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 6px;
  min-height: 48px;
  padding: 2px 0;
  color: var(--ink-on-wall-muted);
  background: transparent;
  border: 0;
  border-radius: 0;
  font-size: var(--text-dock-label);
  line-height: 1.25;
  text-align: center;
  text-shadow: var(--text-shadow-on-wall);
  -webkit-tap-highlight-color: transparent;
}

/* An empty Favorites slot is hidden outside editing; display: flex above would beat the UA
   [hidden] rule. */
.dock-button[hidden] {
  display: none;
}

/* The icon's own ground: a smaller sibling of the launcher's .app-tile-icon -- the same
   gradient and bevel (--icon-tile-ground, --shadow-tile-bevel), a shorter drop, --app-ink
   set inline per button (index.html) in the destination's own launcher colour. The ring
   that marks the current item is a box-shadow layer, so the ground is the same size
   selected or not. */
.dock-icon {
  display: grid;
  place-items: center;
  width: var(--dock-tile);
  height: var(--dock-tile);
  border-radius: var(--radius-tile);
  background: var(--icon-tile-ground);
  box-shadow: var(--shadow-tile-icon-dock);
  color: var(--app-ink);
  overflow: hidden;
}

/* A native app cloned into a Favorites slot: the same inset rounded icon as its launcher tile. */
.dock-icon .app-tile-native-img {
  width: calc(var(--dock-tile) * var(--launcher-native-inset));
  height: calc(var(--dock-tile) * var(--launcher-native-inset));
  border-radius: var(--radius-nested);
}

.dock-icon svg {
  width: var(--dock-glyph);
  height: var(--dock-glyph);
  stroke-width: 1.85;
}

/* Up to two lines, wrapping between words; the dock's height comes from its content. */
.dock-label {
  max-width: 100%;
  white-space: normal;
  overflow-wrap: normal;
  text-align: center;
}

/* Current is never colour alone on its own label -- the icon ground carries a gold ring
   too -- but the dock is a four-item destination list beside a
   fourteen-tile launcher, not a place a colour-blind reader has to disambiguate past
   that; the launcher's own tiles are never "current" in this sense at all. `[aria-current]`
   rather than `[aria-current="page"]` alone: the All button also reads current, as
   aria-current="true", whenever the chosen display is one the dock itself has no button
   for (see chat.js's syncNav()) -- "you are inside All" is exactly as much a current
   state as "you are on Today". */
.dock-button[aria-current] {
  color: var(--dock-ink-current);
}

.dock-button[aria-current] .dock-icon {
  box-shadow: var(--shadow-tile-icon-dock), 0 0 0 1.5px var(--icon-gold);
}

/* THE ALL LAUNCHER. #apps composes .app-scroller-page like every document-shaped pane;
   what follows is its own grid, tile and pager -- the reference's own .gp-app-grid/.gp-
   app-icon/.gp-app-pager, translated onto this app's shared tokens rather than its own
   private literals. Paging itself (which tiles are on screen, the dots, the swipe) is
   launcher.js's job; this is only how a tile and the pager controls are drawn. */
#app-grid {
  display: grid;
  grid-template-columns: repeat(4, minmax(0, 1fr));
  gap: calc(14px * var(--launcher-scale, 1)) 6px;
  padding: 9px var(--page-gutter) 0;
  touch-action: pan-y;
}

/* --launcher-scale is written onto #app-grid by launcher.js's own syncScale(), the one place
   in this pane where JS decides layout rather than CSS: the grid is always four columns, and
   whether a label's longest word fits one column, or the edit mode's four rows fit above the
   edit bar, are real text-metrics and layout questions a media query cannot answer. Every
   tile dimension below multiplies by it (1 when unset), so a tile shrinks uniformly. */

#nav-settings {
  margin-left: auto;
  align-self: center;
}

.apps-settings-button svg {
  width: 18px;
  height: 18px;
}

.app-tile {
  position: relative;
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: flex-start;
  gap: calc(7px * var(--launcher-scale, 1));
  min-width: 0;
  min-height: 0;
  padding: 0;
  color: var(--ink-heading);
  background: transparent;
  border: 0;
  border-radius: 0;
  font-size: calc(var(--text-launcher-label) * var(--launcher-scale, 1));
  line-height: 1.3;
  text-align: center;
  text-shadow: var(--text-shadow-on-wall);
}

.app-tile:focus-visible {
  outline: none;
}

.app-tile:focus-visible .app-tile-icon {
  box-shadow: var(--shadow-tile-icon), 0 0 0 2px var(--icon-gold);
}

.app-tile[hidden] {
  display: none;
}

/* Pagination's own visibility, distinct from [hidden] (which means "not a destination
   yet", the Screen tile's own state): a page change hides every tile outside the current
   page without touching whether that tile is a destination at all. */
.app-tile.launcher-tile-hidden {
  display: none;
}

/* A tile that lives in the dock (launcher-editor.js): it is not a grid position. */
.app-tile.app-tile-docked {
  display: none;
}

/* A page taken off the selector (launcher-editor.js): kept in the DOM, shown nowhere. */
.app-tile.app-tile-removed {
  display: none;
}

.app-tile:active .app-tile-icon {
  transform: scale(0.93);
  box-shadow: var(--shadow-tile-icon-active);
}

/* The icon tile: a beveled square in the destination's own gradient ground, --app-ink set
   inline per tile (index.html) so one rule draws all fourteen colours. The glow behind it
   reads as the icon casting a little of its own colour onto the tile -- decorative, so it
   lives on ::before rather than adding a second real element. */
.app-tile-icon {
  position: relative;
  isolation: isolate;
  display: grid;
  place-items: center;
  overflow: hidden;
  width: calc(var(--launcher-tile) * var(--launcher-scale, 1));
  height: calc(var(--launcher-tile) * var(--launcher-scale, 1));
  border-radius: var(--radius-tile);
  background: var(--icon-tile-ground);
  box-shadow: var(--shadow-tile-icon);
  color: var(--app-ink);
  transition: transform var(--motion-press) ease, box-shadow var(--motion-press) ease;
}

.app-tile-icon[hidden] {
  display: none;
}

.app-tile-icon::before {
  content: '';
  position: absolute;
  z-index: -1;
  left: 4px;
  top: 1px;
  width: calc(var(--launcher-tile) * var(--launcher-scale, 1) * 0.66);
  height: calc(var(--launcher-tile) * var(--launcher-scale, 1) * 0.66);
  border-radius: 50%;
  background: var(--app-ink);
  opacity: 0.13;
  filter: blur(8px);
}

.app-tile-icon svg {
  width: calc(var(--launcher-glyph) * var(--launcher-scale, 1));
  height: calc(var(--launcher-glyph) * var(--launcher-scale, 1));
  stroke-width: 1.85;
}

/* An installed Android app's tile: the same icon ground as a page tile (size, radius,
   shadow, position), with the app's own icon inset inside it like a page glyph. --app-ink
   is deliberately unset, so the glow behind the box draws nothing. The letter shows until
   the icon has loaded. */
.app-tile-native-img {
  width: calc(var(--launcher-tile) * var(--launcher-scale, 1) * var(--launcher-native-inset));
  height: calc(var(--launcher-tile) * var(--launcher-scale, 1) * var(--launcher-native-inset));
  object-fit: contain;
  border-radius: var(--radius-nested);
}

.app-tile-native-letter {
  color: var(--ink-heading);
  font-size: calc(var(--text-page-title) * var(--launcher-scale, 1));
  font-weight: 600;
  line-height: 1;
  text-transform: uppercase;
}

.app-tile-unavailable .app-tile-icon {
  opacity: 0.55;
}

/* Arranging the All selector (native-apps.js). Normal taps are untouched; the editing rules
   only apply while #apps has .apps-editing. Tiles never select text or pop the long-press
   menu. */
#app-grid .app-tile {
  -webkit-user-drag: none;
  -webkit-user-select: none;
  user-select: none;
  -webkit-touch-callout: none;
  -webkit-tap-highlight-color: transparent;
}

/* Tap-tap arranging (launcher-editor.js): select one app, then tap a destination. Selection
   is a slight scale + gold ring on the icon, never a jiggle; every other filled tile fades but stays
   readable, and empty positions stay fully visible as the places a selection can go. */
.apps-editing .app-tile {
  transition: opacity var(--motion-press) ease;
}

#apps[data-has-selection] .app-tile:not(.app-edit-selected):not(.app-tile-slot) {
  opacity: 0.55;
}

.app-tile.app-edit-selected .app-tile-icon {
  transform: scale(1.05);
  box-shadow: var(--shadow-card), 0 0 0 3px var(--icon-gold);
}

.app-tile.app-edit-selected {
  font-weight: 600;
}

.app-tile-slot .app-tile-icon {
  background: transparent;
  box-shadow: none;
  border: 2px dashed color-mix(in srgb, var(--ink-on-wall-muted) 70%, transparent);
  box-sizing: border-box;
}

.app-tile-slot .app-tile-icon::before {
  content: none;
}

#apps[data-has-selection] .app-tile-slot .app-tile-icon {
  border-color: var(--icon-gold);
}

.app-grid-stage {
  position: relative;
}

.app-edit-arrow {
  position: absolute;
  top: 50%;
  transform: translateY(-50%);
  z-index: 2;
  display: grid;
  place-items: center;
  width: 44px;
  height: 64px;
  padding: 0;
  color: var(--ink-heading);
  background: color-mix(in srgb, var(--panel) 72%, transparent);
  border: 0;
  border-radius: var(--radius-control);
  box-shadow: var(--shadow-tile-icon-dock);
  transition: opacity var(--motion-press) ease;
}

.app-edit-arrow-prev {
  left: max(2px, env(safe-area-inset-left));
}

.app-edit-arrow-next {
  right: max(2px, env(safe-area-inset-right));
}

.app-edit-arrow[hidden] {
  display: none;
}

.app-edit-arrow:disabled {
  opacity: 0.3;
  cursor: default;
}

.app-edit-arrow svg {
  width: 28px;
  height: 28px;
  stroke-width: 2;
}

/* The arrows sit beside the grid, never over an icon. At the wide breakpoint every tile is
   on one page, the arrows are hidden, and the grid needs no side room. */
@media (min-width: 375px) and (max-width: 47.99rem) {
  :root:not([data-text-size="large"]) .apps-editing .app-grid-stage:has(> .app-edit-arrow:not([hidden])) #app-grid {
    padding-inline: calc(max(2px, env(safe-area-inset-left)) + 46px) calc(max(2px, env(safe-area-inset-right)) + 46px);
  }
}

/* On a narrow phone or with Large text the side arrows would steal grid width, so the same
   two buttons sit beneath the grid instead. */
@media (max-width: 374px) {
  .apps-editing .app-grid-stage {
    display: flex;
    flex-wrap: wrap;
    justify-content: center;
    gap: 12px;
  }

  .apps-editing #app-grid {
    flex: 1 0 100%;
    order: 0;
  }

  .apps-editing .app-edit-arrow {
    position: static;
    transform: none;
    width: 64px;
    height: 48px;
    order: 1;
  }
}

@media (max-width: 47.99rem) {
  :root[data-text-size="large"] .apps-editing .app-grid-stage {
    display: flex;
    flex-wrap: wrap;
    justify-content: center;
    gap: 12px;
  }

  :root[data-text-size="large"] .apps-editing #app-grid {
    flex: 1 0 100%;
    order: 0;
  }

  :root[data-text-size="large"] .apps-editing .app-edit-arrow {
    position: static;
    transform: none;
    width: 64px;
    height: 48px;
    order: 1;
  }
}

/* The pager's own small arrows duplicate the big ones while editing; hidden, not removed,
   so the dots stay centred. */
.apps-editing .app-pager button:not(.app-pager-dot) {
  visibility: hidden;
}

/* The edit bar sits at the bottom of the pane (sticky inside the scroller, never fixed over
   the dock). Compact at every text size: the hint, the optional Remove button and the reset
   confirm each take a row of their own, while Reset layout, Cancel and Done share one (zero
   flex-basis, so they shrink to fit rather than wrapping). */
.app-edit-bar {
  position: sticky;
  bottom: 0;
  z-index: 3;
  display: flex;
  flex-wrap: wrap;
  gap: 0.375rem;
  margin-top: auto;
  padding: 0.375rem 0.5rem;
  background: var(--panel);
  border-top: 1px solid var(--line-strong);
}

.app-edit-bar[hidden],
.app-edit-bar button[hidden] {
  display: none;
}

.apps-editing {
  padding-bottom: var(--app-edit-bar-space, 0);
}

/* The composer has no role while arranging: its room goes to the grid. The dock stays, its
   Favorites slots are edit targets. launcher-editor.js sets the class on <body> for exactly
   the length of an edit, so the composer returns whole on Done, Cancel or an interruption. */
body.launcher-editing .composer-row {
  display: none;
}

.app-edit-hint {
  flex: 1 0 100%;
  margin: 0;
  color: var(--ink-heading);
  font-size: var(--text-detail-meta);
  font-weight: 600;
}

.app-edit-keep {
  display: flex;
  flex: 1 0 100%;
  gap: 0.5rem;
  align-items: center;
  font-size: var(--text-detail-meta);
}

.app-edit-keep[hidden] {
  display: none;
}

.app-edit-keep-hint {
  color: var(--muted);
}

.app-edit-bar button {
  flex: 1 1 0;
  min-width: 0;
  min-height: 44px;
  padding-inline: 0.375rem;
  color: var(--ink);
  background: var(--panel-subtle);
  border: 1px solid var(--line-strong);
  border-radius: var(--radius-control);
  font: inherit;
  font-size: var(--text-control);
  line-height: 1.2;
}

/* Only shown with a selection: its own row, so it never squeezes the three above. */
#app-remove-shortcut {
  flex-basis: 100%;
}

#app-edit-done,
#app-edit-reset-yes {
  color: var(--on-accent);
  background: var(--accent);
  border-color: transparent;
  font-weight: var(--w-title);
}

#app-edit-done:hover,
#app-edit-done:focus-visible,
#app-edit-reset-yes:hover,
#app-edit-reset-yes:focus-visible {
  background: var(--accent-hover);
}

.app-edit-reset-confirm {
  display: flex;
  flex: 1 0 100%;
  flex-wrap: wrap;
  gap: 0.5rem;
}

.app-edit-reset-confirm[hidden] {
  display: none;
}

.app-edit-reset-confirm p {
  flex: 1 0 100%;
  margin: 0;
  color: var(--ink);
  font-weight: 400;
}

/* The dock while arranging: a Favorites heading and a visible frame per slot. Outside
   editing the dock is exactly as compact as before. */
#dock-nav[data-editing]::before {
  content: 'Favorites';
  grid-column: 1 / -1;
  padding-bottom: 4px;
  color: var(--ink-on-wall-muted);
  font-size: var(--text-caption);
  letter-spacing: 0.04em;
  text-align: center;
  text-shadow: var(--text-shadow-on-wall);
}

#dock-nav[data-editing] .dock-button {
  transition: opacity var(--motion-press) ease;
}

#dock-nav[data-editing] .dock-icon {
  transition: transform var(--motion-press) ease, box-shadow var(--motion-press) ease;
}

#dock-nav[data-editing] .dock-button[data-dock-slot] {
  background: color-mix(in srgb, var(--panel) 30%, transparent);
  border-radius: var(--radius-control);
  outline: 1.5px solid color-mix(in srgb, var(--line-strong) 80%, transparent);
  outline-offset: -2px;
}

#dock-nav[data-editing] #dock-all {
  opacity: 0.45;
}

#dock-nav[data-has-selection] .dock-button[data-dock-slot]:not(.app-edit-selected):not([data-dock-empty]) {
  opacity: 0.6;
}

#dock-nav[data-editing] .dock-button.app-edit-selected {
  font-weight: 600;
}

#dock-nav[data-editing] .dock-button.app-edit-selected .dock-icon {
  transform: scale(1.05);
  box-shadow: var(--shadow-tile-icon-dock), 0 0 0 2.5px var(--icon-gold);
}

.dock-button[data-dock-empty] .dock-icon {
  background: transparent;
  box-shadow: none;
  border: 1.5px dashed color-mix(in srgb, var(--ink-on-wall-muted) 70%, transparent);
}

#dock-nav[data-has-selection] .dock-button[data-dock-empty] .dock-icon {
  border-color: var(--icon-gold);
}

.dock-button[data-dock-empty] .dock-label::before {
  content: 'Empty';
}

@media (prefers-reduced-motion: reduce) {
  .apps-editing .app-tile,
  .app-edit-arrow,
  #dock-nav[data-editing] .dock-button,
  #dock-nav[data-editing] .dock-icon,
  .app-tile.app-edit-selected .app-tile-icon {
    transition: none;
  }
}

/* Rendered on every native tile but empty (display: none) unless the app is missing. The
   "Not installed" text hangs absolutely just under the label, outside the tile's box, so no
   tile is ever taller than its siblings and nothing moves under a finger. */
.app-tile-sub {
  position: absolute;
  top: 100%;
  left: 0;
  right: 0;
  margin-top: calc(-4px * var(--launcher-scale, 1));
  color: var(--muted);
  font-size: calc(var(--text-caption) * var(--launcher-scale, 1));
  line-height: 1.3;
  text-align: center;
  pointer-events: none;
}

.app-tile-sub:empty {
  display: none;
}

.native-app-choices {
  display: flex;
  flex-direction: column;
  gap: 10px;
}

.native-app-choice {
  min-height: 48px;
  width: 100%;
}

/* The All page's own actions (Add app; an Edit button follows): a row under the title that
   takes the header's full width. The buttons wear the secondary look the dialogs' Cancel
   buttons use. */
.app-launcher-actions {
  display: flex;
  flex: 1 0 100%;
  flex-wrap: wrap;
  gap: 0.5rem;
  margin-top: 0.4rem;
}

.app-launcher-actions[hidden] {
  display: none;
}

.app-launcher-action {
  min-height: 44px;
  padding-inline: 1rem;
  background: var(--panel-subtle);
  color: var(--ink);
  border-color: var(--line-strong);
}

/* The Add app dialog. The list scrolls inside the dialog body (itself bounded by
   .app-dialog's max-height), so the footer stays on screen on a phone. */
.add-app-search-label {
  display: block;
  margin-bottom: 0.3rem;
  color: var(--muted);
}

.add-app-search {
  box-sizing: border-box;
  width: 100%;
  min-height: 44px;
  margin-bottom: 0.6rem;
}

.add-app-list {
  max-height: min(45vh, 24rem);
  max-height: min(45dvh, 24rem);
  margin: 0;
  padding: 0;
  overflow-y: auto;
  overscroll-behavior: contain;
  list-style: none;
}

.add-app-list > li[hidden] {
  display: none;
}

.add-app-empty {
  margin: 0.6rem 0 0;
  color: var(--muted);
}

.add-app-footer {
  flex-wrap: wrap;
  align-items: center;
}

.add-app-footer #add-app-get-more {
  margin-right: auto;
  background: var(--panel-subtle);
  color: var(--ink);
  border-color: var(--line-strong);
}

.add-app-error {
  flex: 1 0 100%;
  order: -1;
  margin: 0;
}

.add-app-row {
  display: flex;
  align-items: center;
  gap: 0.75rem;
  width: 100%;
  min-height: 56px;
  padding: 0.4rem 0.5rem;
  background: transparent;
  color: var(--ink);
  border: 0;
  border-radius: var(--card-radius);
  text-align: left;
}

.add-app-row-icon {
  display: flex;
  flex: none;
  align-items: center;
  justify-content: center;
  width: 40px;
  height: 40px;
  border-radius: var(--radius-tile);
  background: var(--icon-tile-ground);
  box-shadow: var(--shadow-tile-icon-dock);
  overflow: hidden;
}

/* A Rundell page's row: its tile's own svg, drawn in the tile's --app-ink. */
.add-app-row-page .add-app-row-icon {
  color: var(--app-ink, var(--ink-heading));
}

.add-app-row-icon svg {
  width: 20px;
  height: 20px;
  stroke-width: 1.85;
}

.add-app-row-img {
  width: calc(40px * var(--launcher-native-inset));
  height: calc(40px * var(--launcher-native-inset));
  object-fit: contain;
  border-radius: var(--radius-nested);
}

.add-app-row-letter {
  color: var(--ink-heading);
  font-weight: 600;
  line-height: 1;
  text-transform: uppercase;
}

.add-app-row-label {
  display: -webkit-box;
  flex: 1 1 auto;
  min-width: 0;
  line-height: 1.3;
  overflow-wrap: break-word;
}

.add-app-row-state {
  flex: none;
  color: var(--muted);
}

.add-app-row-added {
  cursor: default;
  opacity: 0.75;
}

/* Full column width (wider than the 68px icon above it), not the icon's own fit-content
   measure, and never a mid-word break: overflow-wrap/word-break stay at their normal
   default, stated explicitly so a label may wrap only between words -- a tile that still
   cannot fit a whole word at this width is --launcher-scale's to fix, on #app-grid,
   never this rule's to paper over by slicing the word. */
.app-tile-label {
  align-self: stretch;
  overflow-wrap: normal;
  word-break: normal;
}

/* An installed app's name is the user's, not ours to shorten: two lines at most so one long
   name cannot make its row taller than the rest. The full label stays in aria-label/title. */
.app-tile-native .app-tile-label {
  display: -webkit-box;
  -webkit-line-clamp: 2;
  -webkit-box-orient: vertical;
  overflow: hidden;
}

@media (prefers-reduced-motion: reduce) {
  .app-tile:active .app-tile-icon {
    transform: none;
  }
}

/* The pager: Previous, a dot per page, Next, then the live "N of M" status line below the
   grid. Hidden outright with one page (launcher.js sets [hidden] on both) -- there is
   nothing to page between, so nothing here should announce or occupy space. */
.app-pager {
  display: flex;
  justify-content: center;
  align-items: center;
  gap: 12px;
  margin: 9px 22px 3px;
}

.app-pager[hidden] {
  display: none;
}

.app-pager button {
  display: grid;
  place-items: center;
  width: 44px;
  height: 44px;
  padding: 0;
  color: var(--ink-on-wall-muted);
  background: transparent;
  border: 0;
  border-radius: var(--radius-control);
}

.app-pager button:disabled {
  opacity: 0.25;
  cursor: default;
}

.app-pager button svg {
  width: 20px;
  height: 20px;
}

.app-pager-dots {
  display: flex;
  gap: 0;
}

.app-pager-dots[hidden] {
  display: none;
}

.app-pager-dot {
  display: grid;
  place-items: center;
  width: 28px;
  min-height: 28px;
  padding: 0;
  background: transparent;
  border: 0;
}

.app-pager-dot[hidden] {
  display: none;
}

.app-pager-dot::after {
  content: '';
  width: 6px;
  height: 6px;
  background: color-mix(in srgb, var(--ink-on-wall-muted) 60%, transparent);
  border-radius: var(--radius-pill);
  transition: width var(--motion-press) ease, background var(--motion-press) ease;
}

.app-pager-dot[aria-pressed="true"]::after {
  width: 20px;
  background: var(--accent);
}

.app-pager-status {
  margin: 0 0 20px;
  color: var(--ink-on-wall-muted);
  text-align: center;
  font-size: var(--text-detail-meta);
  text-shadow: var(--text-shadow-on-wall);
}

/* The page-change slide, launcher.js's own animate() -- a plain CSS animation the same
   way app-page-enter above is, restarted each time the class is removed and re-added
   rather than relying on a display change to restart it (the grid never actually leaves
   the render tree between pages). --launcher-slide-from carries the direction: +22px
   arriving from the right (Next), -22px from the left (Previous). */
@keyframes launcher-slide {
  from {
    transform: translateX(var(--launcher-slide-from, 22px));
    opacity: 0.55;
  }
  to {
    transform: translateX(0);
    opacity: 1;
  }
}

.launcher-slide {
  animation: launcher-slide var(--motion-page) var(--ease-page);
}

@media (prefers-reduced-motion: reduce) {
  .launcher-slide {
    animation: none;
  }
}

/* Tablet/desktop: portrait tablets keep the phone's 4 x 4 at the larger icon size,
   landscape screens lay the same order out 8 x 2 so both rows sit above the command bar;
   launcher.js shows every tile on one page at this width. */
@media (min-width: 48rem) {
  #app-grid {
    grid-template-columns: repeat(4, minmax(0, 1fr));
    max-width: 34rem;
    margin-inline: auto;
    width: 100%;
    box-sizing: border-box;
  }

  #apps .app-page-header {
    width: 100%;
    max-width: 34rem;
    margin-inline: auto;
    box-sizing: border-box;
  }

}

@media (min-width: 48rem) and (orientation: landscape) {
  #app-grid {
    grid-template-columns: repeat(8, minmax(0, 1fr));
    max-width: 60rem;
  }

  #apps .app-page-header {
    max-width: 60rem;
  }
}

/* A short phone screen (320x640, 360x640): only the header padding and the grid gap give
   way. The icons and labels never shrink with the viewport's height; the pane scrolls if
   the rows no longer fit above the command bar. */
@media (max-height: 700px) {
  #apps .app-page-header {
    padding-top: 10px;
    padding-bottom: 12px;
  }

  #app-grid {
    gap: calc(10px * var(--launcher-scale, 1)) 6px;
  }

}

/* Transparent, not the old page-sized panel: the wallpaper (.app-wallpaper, fixed behind
   everything) shows straight through, the same idiom Calendar's own .agenda already states.
   A card still draws its own surface (.app-card, .review-item); it is only the FIELD around
   them, and the header/switch text directly on the wallpaper, that stopped drawing one of
   its own. */
.email-review {
  display: flex;
  flex-direction: column;
  background: transparent;
}

.chat.email-review.review-expanded .email-review {
  flex: 1 1 0;
  min-height: 0;
  overflow: hidden;
}

.email-review[hidden],
.email-review-detail[hidden] {
  display: none;
}

/* The Review/Emails switch's own track: a translucent dark ground directly on the
   wallpaper, the reference's own gp-view-switch, rather than the old opaque panel-coloured
   toolbar band this used to be capped by (see the removed comment history for that idiom).
   .app-toolbar-title's empty <strong> still lives here for chat.dom-integration.test.ts's
   own sake (it reads '.email-review-bar strong' and expects it empty); zero width and
   height keep it out of the track's own layout. */
.email-review-bar {
  flex: 0 0 auto;
  flex-wrap: wrap;
  gap: 4px;
  padding: 4px;
  background: var(--switch-track-ground);
  border-radius: var(--radius-control);
}

.email-review-bar > .app-toolbar-title {
  display: none;
}

/* The track sat edge to edge (0px from the screen) while every card below it is 17px in, so
   its rounded ends touched the bezel; it takes the cards' own gutter. (At the reading measure
   the 62rem rule further down still centres it.) */
.email-review-bar {
  margin-inline: var(--list-gutter);
}

/* The switch does not overlap the list -- the field starts right under it -- but the field's
   clip edge is flush against the track, so a card scrolled up to it shows as a hard-cut sliver
   between the switch and the next card, which reads as the card showing through the track's
   translucent half. The field's top 10px (its own padding-top, so nothing at scrollTop 0 is
   touched) fades out instead, the same fade the dock end already draws. */
body:not(.wall) .chat .email-review-detail {
  -webkit-mask-image: linear-gradient(to bottom, transparent 0, #000 10px, #000 calc(100% - var(--dock-fade)), transparent 100%);
  mask-image: linear-gradient(to bottom, transparent 0, #000 10px, #000 calc(100% - var(--dock-fade)), transparent 100%);
}

/* Review and #email-view-switch are both real buttons in the track now, read as matched
   segments: centred icon-plus-label, each at least 44px tall. .review-view-switch keeps its
   own historical class for the Emails button (renderEmailViewSwitch never changes it), so
   both selectors are named here rather than sharing a new one. */
.review-view-switch-review,
.review-view-switch {
  display: flex;
  flex: 1 1 0;
  min-width: max-content;
  align-items: center;
  justify-content: center;
  gap: 6px;
  min-height: 44px;
  padding: 8px;
  color: var(--ink-on-wall-muted);
  background: transparent;
  border-color: transparent;
  border-radius: var(--radius-nested);
  font-size: var(--text-control);
  font-weight: var(--w-title);
  white-space: nowrap;
}

.review-view-switch-review svg,
.review-view-switch svg {
  width: 16px;
  height: 16px;
  flex: none;
}

.review-view-switch-review:hover,
.review-view-switch:hover,
.review-view-switch-review:focus-visible,
.review-view-switch:focus-visible {
  color: var(--ink-heading);
}

/* The selected segment: --panel-subtle plus gold ink, the same "selected pill on a
   translucent track" pairing the reference itself draws. aria-pressed is the single source of
   truth for which button this is (renderEmailViewSwitch sets both at once, so exactly one of
   the two ever matches) -- placed after the hover/focus rule above so the selected pill's own
   colour still wins when that button is also hovered or focused. */
.review-view-switch-review[aria-pressed="true"],
.review-view-switch[aria-pressed="true"] {
  background: var(--panel-subtle);
  color: var(--accent-ink);
  box-shadow: var(--shadow-inset-highlight);
}

.review-arrow {
  flex: none;
  color: var(--muted);
  transition: transform 140ms ease;
}

.meals-proposal-toggle[aria-expanded="false"] .review-arrow,
.outcome-detail-toggle[aria-expanded="false"] .review-arrow {
  transform: rotate(-90deg);
}

/* The reference's own gp-inbox-count: a small rounded translucent badge riding beside the
   title, inside the h1 (see index.html), rather than the plain muted text this used to be
   in the toolbar. --top-button-ground is the same wallpaper-ground token Check now uses. */
.review-count {
  display: inline-flex;
  flex: none;
  align-items: center;
  margin-left: 0.45rem;
  padding: 0.15rem 0.55rem;
  vertical-align: middle;
  color: var(--ink-heading);
  background: var(--top-button-ground);
  border-radius: var(--radius-pill);
  font-size: var(--text-caption);
  font-weight: var(--w-meta);
  white-space: nowrap;
  text-shadow: none;
}

.review-count:empty {
  display: none;
}

/* The last-checked line, directly on the wallpaper under the title -- .app-page-header-subtitle
   already supplies the on-wall ink, shadow and ellipsis rules; this only adds what the
   review-count/toolbar comment above no longer applies here. */
.review-check-state {
  overflow-wrap: break-word;
  white-space: normal;
}

/* Shrink factor 100, not 1: this note is the one thing in the bar that can afford to lose
   characters, so it must absorb the whole deficit before the pane's own name beside it gives up
   any. Flexbox shares a deficit in proportion to the shrink factors, so "first" is spelled as a
   large number here rather than by source order. */
.calendar-note {
  flex: 0 100 auto;
  min-width: 0;
  color: var(--muted);
  font-size: var(--text-detail-meta);
  font-weight: var(--w-meta);
  line-height: 1.35;
  overflow-wrap: break-word;
  white-space: normal;
}

.calendar-note {
  margin-right: 0.8rem;
}

/* One word, and it must keep both letters: `Off` ellipsised to `O.` says nothing. It
   shares .calendar-note's look but not its willingness to shrink, so at a phone width the
   deficit falls on the sentence beside it, which can afford to lose its tail. It also
   gives up the note's trailing margin, because it is the last thing in the bar.

   The unconfigured case is the one this has to survive. A server with no VAPID key puts a
   whole sentence here instead of a word, and an unshrinkable non-wrapping sentence measured
   436px inside a 390px viewport; Chromium answers a horizontal overrun by inflating the
   layout viewport, which pushes every position: fixed control off the screen. The cap and
   the wrap below bound that sentence and nothing else: `On` and `Off` are a fraction of the
   cap, so they still never wrap, never ellipsise and never give up a character, and
   flex: none is unchanged. */
#notifications-status {
  flex: none;
  max-width: 45%;
  margin-right: 0;
  white-space: normal;
  overflow-wrap: anywhere;
}

/* #daily-agenda-status carries the same possible full-sentence status #notifications-status
   does -- both read "not configured on the server" when the push key pair is missing -- but,
   unlike that id, it inherits only .calendar-note's single-line ellipsis treatment above.
   Wrapping it the same way keeps the sentence readable in the Settings row it lives in
   instead of quietly truncating it. */
#daily-agenda-status {
  max-width: 100%;
  white-space: normal;
  overflow-wrap: anywhere;
}

.calendar-add-failure {
  color: var(--error);
}

.calendar-proposal-event {
  padding: 0.65rem 0;
  border-bottom: 1px solid var(--line);
}

.calendar-proposal-event h4,
.calendar-proposal-event p {
  margin: 0 0 0.25rem;
}

.calendar-proposal-event h4 {
  font-size: var(--text-detail-title);
}

.calendar-event-schedule,
.calendar-event-field {
  color: var(--muted);
}

.event-people {
  display: inline-flex;
  flex-wrap: wrap;
  gap: 0.35rem;
}

.person-chip {
  display: inline-flex;
  align-items: center;
  gap: 0.25rem;
  color: var(--ink);
}

/* This is the single place a person's colour is decided anywhere in the app. Do not add a
   second source -- a JS lookup, a server field -- or the two will drift. The calendar store
   still holds a `color` per person; nothing reads it for display any more, and it cannot be
   read for display, because a stored hex is one colour and this app now has two themes.

   The one permitted mirror of this table is PERSON_KEY_CAT_VAR in agenda.js, which exists
   only because a hard-banded multi-person gradient needs several colours inside one string.
   Its own comment says the same thing in the other direction; change both together.

   The key is derived from the display name (personKey: normalise, lowercase), so a rename
   reads as a new person. That is the documented identity model, not an oversight: this app
   has no person registry by design (see calendar-people.ts's header), and freezing a key at
   creation would be one. If someone's events lose their wash, they were renamed, and the fix
   is to add the new key here.

   A name with no entry gets no wash at all -- the plain surface and the standard border --
   rather than the shared-household neutral. "Shared" means everyone in this house; an
   unrecognised name is the opposite, and saying the two with one colour would be a small lie
   in the one place someone is looking carefully. */
[data-person-key="nate"]     { --person-color: var(--cat-1); }
[data-person-key="brigitte"] { --person-color: var(--cat-2); }
[data-person-key="quinn"]    { --person-color: var(--cat-3); }
[data-person-key="lily"]     { --person-color: var(--cat-4); }
[data-person-key="scott"]    { --person-color: var(--cat-5); }

/* The dot carries only the key; the colour arrives through --person-color above. An
   unmapped key leaves --person-color unset, and `transparent` is then the honest result:
   the name still renders, dotless, exactly as an unsaved person's chip always has. */
.person-dot {
  flex: none;
  width: 0.55rem;
  height: 0.55rem;
  border-radius: 50%;
  background: var(--person-color, transparent);
}

.calendar-scope-summary {
  color: var(--ink);
  font-weight: var(--w-display);
}

.email-review-status,
.email-review-failure,
.household-status,
.household-failure,
.meals-status,
.meals-failure,
.meals-action {
  margin: 0;
  color: var(--muted);
  font-size: var(--text-detail-body);
}

/* The panel's own footnote about what it covers, not a message from it, so it keeps the
   caption step while the status and failure lines beside it read as sentences. Its own
   margin still comes from the group below. */
.email-review-bound {
  color: var(--ink-on-wall-muted);
  font-size: var(--text-detail-meta);
  text-shadow: var(--text-shadow-on-wall);
}

/* The pale field the message cards sit on is this whole scroller, not the list inside it: a
   field that stopped at the last card left the panel half tinted and half white, and a list
   that padded itself inside this padding compounded two gutters into one 26px inset. The
   field itself is the shared .app-scroller; only the basis differs, because this pane's
   scroller must not grow past its panel. */
.email-review-detail {
  flex: 1 1 0;
  /* .app-scroller sets overflow-y:auto and leaves overflow-x at its visible default, but
     the CSS spec turns that visible into auto the moment the other axis is non-visible --
     so any child even a pixel wider than this pane becomes horizontally pannable. Setting
     this explicitly removes that surface once nothing inside actually needs it (see the
     overflow-wrap: anywhere rules below); touch-action matches .agenda-days' own
     vertical-only idiom. */
  overflow-x: hidden;
  touch-action: pan-y;
  /* The field's own side gutter, restated where it cannot be lost: the intro line and the
     status text sit on the wallpaper here, not in a card, so a missing inset ran them to
     the screen edge. */
  min-width: 0;
  padding-inline: var(--list-gutter);
}

/* Bare lines in this field (loading/empty status, the failure, the scope note) are on the
   wallpaper, not on a card, so they read in the on-wall ink with its shadow. */
.email-review-status,
.email-review-failure,
.email-review-bound {
  overflow-wrap: anywhere;
}

.email-review-status {
  color: var(--ink-on-wall-muted);
  text-shadow: var(--text-shadow-on-wall);
}

/* The status line and the Connect/Reconnect control share one row: a schedule pause
   names the problem exactly where its remedy sits (renderReviewStatus decides both in
   the same render). The row carries the bottom margin this field's lines otherwise own
   individually, so neither child brings its own into the row. */
.review-status-line {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: 0.5rem;
  margin: 0 0 0.65rem;
}

.review-status-line .email-review-status,
.review-status-line .review-action {
  margin: 0;
}

.email-review-status,
.email-review-failure,
.email-review-bound,
.household-status,
.household-failure,
.meals-status,
.meals-failure,
.meals-action,
.today-status,
.today-failure {
  margin: 0 0 0.65rem;
}

.email-review-failure,
.household-failure,
.meals-failure {
  color: var(--error);
}

.review-items[hidden],
.email-list[hidden] {
  display: none;
}

/* Deliberately NOT overflow: hidden, which would clip the header wash to the card's radius for
   free. The header rounds its own top corners instead.

   padding is the card's own --card-pad now, not 0: the header and footer used to sit flush
   against the card's edges, reading as glued to the surface's border rather than inside it, with
   every OTHER child inset by a hand-rolled 12px margin instead. One padding value insets
   everything -- header, subject, summary, highlights, footer -- alike, the reference's own
   .gp-panel shape. */
.review-item {
  position: relative;
  padding: var(--card-pad);
}

/* Dismiss lives in the card's top-right corner, out of the footer: icon-only, a 44px hit
   area, with the header and subject padded clear of it below. */
.review-item > .dismiss-item {
  position: absolute;
  top: 4px;
  right: 4px;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  min-width: 44px;
  min-height: 44px;
}

.review-item p,
.review-item li,
.review-empty,
.review-counts,
.review-coverage {
  margin: 0 0 0.3rem;
  font-size: var(--text-detail-body);
  overflow-wrap: anywhere;
}

.review-coverage {
  color: var(--muted);
}

/* No border stripe of its own: every card shares the one .app-card surface (rule 01 --
   see the reference, which never colours a failed message's whole panel either), and
   p.review-item-failure below already reads in --error, which is enough. */
p.review-item-failure {
  color: var(--error);
  font-weight: var(--w-display);
  margin: 0 0 6px;
}

.review-item .review-summary {
  margin: 0 0 0;
  color: var(--muted);
  font-size: var(--text-support);
  line-height: var(--leading-detail-body);
}

.review-summary-list {
  margin: 0;
  padding-left: 1.15rem;
}

.review-item .review-summary-list li {
  margin-bottom: 6px;
  font-size: var(--text-support);
  line-height: var(--leading-detail-body);
}

/* The extracted event: a compact action row separated from the summary by a hairline, the
   reference's own gp-suggested-event, rather than the tinted inset-with-a-left-accent this
   used to be. .review-highlight's own ::before (below) draws the leading gold calendar
   icon -- a generated box, not a real child, because the DOM tests pin
   .review-highlight-main as this row's own first *element* child (firstElementChild), and a
   pseudo-element is invisible to that query while still landing first in the row's own flex
   order. This rule also overrides every one of .app-inset's own properties (see the class
   list in chat.js): the class stays, for email-review.dom-integration.test.ts's own
   '.review-highlights.app-inset' check, but the look is this compact on-card row now, not a
   tinted inset box. */
.review-highlights {
  margin: 10px 0 0;
  padding: 11px 0 0;
  color: var(--ink);
  background: transparent;
  border-top: 1px solid var(--card-line);
  border-radius: 0;
  font-size: inherit;
}

.review-item .review-highlight + .review-highlight {
  margin-top: 10px;
  padding-top: 10px;
  border-top: 1px solid var(--card-line);
}

.review-item .review-highlight {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: 0.15rem 0.6rem;
}

.review-highlight::before {
  content: '';
  flex: none;
  align-self: flex-start;
  width: 17px;
  height: 17px;
  margin-top: 2px;
  background-color: var(--accent);
  -webkit-mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Crect width='18' height='18' x='3' y='4' rx='2'/%3E%3Cline x1='16' x2='16' y1='2' y2='6'/%3E%3Cline x1='8' x2='8' y1='2' y2='6'/%3E%3Cline x1='3' x2='21' y1='10' y2='10'/%3E%3C/svg%3E") center / contain no-repeat;
  mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Crect width='18' height='18' x='3' y='4' rx='2'/%3E%3Cline x1='16' x2='16' y1='2' y2='6'/%3E%3Cline x1='8' x2='8' y1='2' y2='6'/%3E%3Cline x1='3' x2='21' y1='10' y2='10'/%3E%3C/svg%3E") center / contain no-repeat;
}

.review-highlight-main {
  flex: 1 1 auto;
  min-width: 0;
  display: flex;
  flex-direction: column;
  gap: 2px;
}

.review-highlight-head {
  display: flex;
  flex-wrap: wrap;
  align-items: baseline;
  gap: 0 0.4rem;
}

/* Colour is .app-row-meta's own (see the class list in chat.js); size steps down one further,
   to the reference's own gp-suggested-event small (a date reads quieter than the title beside
   it, not at the shared row-meta step every OTHER row's meta line uses). */
.review-highlight-meta {
  display: flex;
  flex-wrap: wrap;
  gap: 0 0.5rem;
  font-size: var(--text-caption);
}

/* The title is the row's first line, .app-row-title's own weight -- but not its ink: the
   class list also carries .app-key (see chat.js), which reads this row's own extracted date
   or time everywhere else in the app, and coloured the title gold along with it. The event
   here is a whole ROW, not a key datum inside one, so the title reads at the reference's own
   plain white (--ink) with the gold reserved for the calendar glyph beside it; the more
   specific selector overrides .app-key's colour without touching the class list every DOM
   test still pins. Size steps down to match -- the reference's own gp-event-copy strong. */
.review-highlight-title {
  min-width: 0;
  color: var(--ink);
  font-size: var(--text-detail-meta);
  overflow-wrap: anywhere;
}

.review-highlight-location {
  color: var(--muted);
}

/* The reference's own gp-add-event: a small square action on a translucent gold ground,
   not the solid accent fill .app-primary-button draws -- the one square action this pane
   has, so it earns its own class rather than composing the shared button. The full "Add
   Event" text stays (chat.dom-integration.test.ts's own email-review suite reads this
   button's textContent for that exact string), even though the reference's own label reads
   just "Add"; the icon carries the rest of the visual compactness. */
.highlight-calendar-preview {
  display: inline-flex;
  flex: none;
  align-items: center;
  gap: 5px;
  margin-left: auto;
  padding: 8px 10px;
  color: var(--accent-ink);
  background: var(--accent-soft);
  border-radius: var(--radius-control);
  font-size: var(--text-detail-meta);
  font-weight: var(--w-title);
  cursor: pointer;
}

.highlight-calendar-preview svg {
  width: 15px;
  height: 15px;
  flex: none;
}

.highlight-calendar-preview:hover,
.highlight-calendar-preview:focus-visible {
  background: color-mix(in srgb, var(--accent) 24%, var(--panel-subtle));
}

.updated-marker,
.unclear-marker {
  margin: 8px 0.35rem 0.35rem 0;
  padding: 0.05rem 0.35rem;
}

.updated-marker {
  color: var(--accent-hover);
}

.unclear-marker {
  color: var(--muted);
}

/* Every category chip a card shows -- the certainty/update markers above plus the
   highlight's own kind tag (.review-highlight-head's .app-tag) -- reads quieter here than
   .app-tag's base outline: a filled ground instead of a bordered pill, and one step down in
   size, the reference's own quiet corner label rather than a loud outlined badge. --panel is
   the shared "nested inside a card" step .app-card's own comment already names (.app-tag-solid
   draws the same pairing); colour is untouched (the two markers above still need their own),
   since only ground and size are shared across all three. */
.review-item .app-tag {
  background: var(--panel);
  border-color: transparent;
  font-size: var(--text-caption);
}

/* Just the source link now, the reference's own gp-mail-top: the actions that used to
   share this band moved to the card's own footer (.review-item-footer, below). */
.review-item-header {
  display: flex;
  padding-right: 52px;
}

/* The reference's own gp-mail-title: the email's real subject line, read as this card's
   headline directly under the sender row. Only rendered when it says something the header's
   own name does not already -- see reviewSubjectHeadline in chat.js, which withholds it when
   the subject and the header's name are the same string (the subject was the name's own
   fallback). The more specific selector (over .review-item p's shared 0-margin rule) is what
   lets this one child keep the reference's own 7px lead-in. */
.review-item .review-item-subject {
  margin: 7px 0 6px;
  padding-right: 52px;
  color: var(--ink);
  font-size: var(--text-item);
  font-weight: var(--w-title);
  line-height: var(--leading-detail-tight);
  overflow-wrap: anywhere;
}

/* The card's own closing band, the mirror of .review-item-header at its opening: Show
   original email leads it in gold, and the rest -- Retry, Dismiss, Hide similar -- trail
   at the far right on .review-item-footer-actions' own margin-left: auto, the reference's
   own gp-mail-actions row. */
.review-item-footer {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: 0.15rem 0.35rem;
  margin-top: 10px;
  padding-top: 8px;
  border-top: 1px solid var(--card-line);
}

.review-item-footer-actions {
  display: flex;
  flex: none;
  flex-wrap: wrap;
  align-items: center;
  gap: 0.1rem 0.35rem;
  margin-left: auto;
  max-width: 100%;
}

.review-source-link,
.review-quiet-action {
  padding: 0.3rem 0.45rem;
  /* Borderless until hovered: these sit inside a card that already has an edge, so a
     row of outlined buttons in it would read as a second grid of boxes. */
  border-color: transparent;
  font-weight: var(--w-body);
  cursor: pointer;
}

/* min-height, not a taller padding: the visible text stays small (the footer's own
   compactness, matching the reference's gp-mail-actions), but the tap target still clears
   the app's 44px floor. */
.review-quiet-action {
  display: inline-flex;
  align-items: center;
  min-height: 44px;
  gap: 4px;
  color: var(--muted);
  font-size: var(--text-caption);
}

.review-quiet-action svg {
  width: 13px;
  height: 13px;
  flex: none;
}

/* Gold and left-aligned -- the one place this row's own accent goes, the reference's own
   "Show original email ›" link. The chevron is generated content, not a child node: adding
   one would give email-review.dom-integration.test.ts's own '.show-original-item' existence
   checks a textContent this file makes no claim about matching either way, so the safer,
   simpler primitive is a pseudo-element neither test nor screen reader ever sees as text. */
.show-original-item {
  margin-right: auto;
  color: var(--accent-ink);
}

.show-original-item::after {
  content: '›';
  margin-left: 2px;
}

/* Full width of the header, name and date pushed to its two ends -- the reference's own
   gp-mail-top -- rather than the shrink-to-fit two-span run this used to be. */
.review-source-link {
  flex: 1 1 auto;
  min-width: 0;
  min-height: var(--control-min);
  display: flex;
  align-items: baseline;
  justify-content: space-between;
  gap: 0.35rem;
  padding: 2px 0;
  flex-wrap: wrap;
  overflow-wrap: anywhere;
  white-space: normal;
}

/* The name is the part that gives way: it is the long one, and the button's own title still
   carries it whole. The date is three or four characters and must never lose one of them, so a
   deficit falls entirely on the name beside it. Both read as the reference's own small quiet
   meta row -- the card's headline is the subject below, not this line -- so both are
   --text-caption/--muted, not the --ink/title-size pair this used to carry. */
.review-source-name {
  flex: 0 1 auto;
  min-width: 0;
  color: var(--muted);
  font-size: var(--text-caption);
  font-weight: var(--w-body);
  overflow-wrap: anywhere;
}

/* No middot: the two spans sit at the header row's own two ends (space-between, above), so
   the separator the reference draws inline is redundant here -- reviewSourceLabel() still
   joins name and date with one for the title/aria-label and the other call sites that quote
   the pair as a single string. */
.review-source-date {
  flex: none;
  margin-left: 0.25rem;
  color: var(--muted);
  font-size: var(--text-caption);
  font-weight: var(--w-body);
}

.review-source-link:hover,
.review-source-link:focus-visible {
  color: var(--ink);
  background: var(--panel);
}

.review-quiet-action,
.retry-item {
  background: transparent;
}

.review-quiet-action:hover,
.retry-item:hover {
  color: var(--ink);
  background: color-mix(in srgb, var(--ink) 6%, transparent);
}

.retry-item {
  color: var(--accent-hover);
}

.retry-item:disabled {
  opacity: 0.6;
  cursor: default;
}

.review-state-empty {
  padding: 0.7rem;
  border: 1px solid var(--line);
}

.restore-summary,
.stop-hiding-similar,
.item-operation-status button {
  padding: 0.25rem 0.65rem;
}

.stop-hiding-similar {
  margin-left: 0.4rem;
}

.item-operation-status {
  margin: 0;
  padding: 0.5rem 0.7rem;
  /* A seam rather than a radius: this one sits flush under the bar above it, so it
     squares off the shared inset's corners and draws the line instead. Its size and ink
     are .app-notice's -- an operation's outcome is a sentence, not a caption. */
  border-radius: 0;
  border-bottom: 1px solid var(--line);
}

.item-operation-status[hidden] {
  display: none;
}

.item-operation-status button {
  margin-left: 0.25rem;
  color: color-mix(in srgb, var(--accent) 70%, var(--ink));
}

/* Connect Gmail's own run control, inline beside the status line in .review-status-line --
   Check now moved onto .app-top-button (the reference's own gp-top-button) when its header
   did, so this is .review-action's one remaining caller. The row, not the button, owns the
   spacing around the pair. */
.review-action {
  min-height: 36px;
  padding: 0.4rem 0.75rem;
  /* Ink on the accent fill below, not the page ground -- see --on-accent. */
  color: var(--on-accent);
  background: var(--accent);
  border: 0;
  border-radius: var(--radius-control);
  font: inherit;
  font-size: var(--text-control);
  font-weight: var(--w-title);
}

.review-action:disabled {
  opacity: 0.6;
}

/* Pushed to the header's far edge, the same margin-left: auto idiom #agenda-today already
   uses for the same job. */
#check-now {
  margin-left: auto;
  white-space: nowrap;
}

.email-item-dismissed,
.email-item-hidden {
  color: var(--muted);
  background: var(--panel);
}

.email-item-dismissed .email-body,
.email-item-hidden .email-body {
  color: color-mix(in srgb, var(--ink) 72%, var(--muted));
}

/* The tag look itself is app-tag's (see markup); this rule only owns the badge's own
   margin, needed because it sits inline among plain paragraphs rather than in a
   gapped row like the agenda's or a highlight's own tags. */
.email-state-badge {
  margin: 0.2rem 0.35rem 0.2rem 0;
}

.email-hidden-reason,
.email-state-overlap {
  margin: 0.15rem 0;
  color: var(--muted);
  font-size: var(--text-detail-body);
}

.restore-summary,
.stop-hiding-similar {
  margin-top: 0.45rem;
}

.email-item h2 {
  margin: 0 0 2px;
  font-size: var(--text-detail-title);
  font-weight: var(--w-title);
  line-height: var(--leading-detail-tight);
  overflow-wrap: anywhere;
}

.email-metadata {
  margin: 0 0 8px;
  color: var(--muted);
  font-size: var(--text-detail-body);
  line-height: var(--leading-detail-tight);
  overflow-wrap: anywhere;
}

/* The message's own control row, above <details>: the primary action first, the quiet
   Open in Gmail link beside it, wrapping rather than truncating on a narrow pane. */
.email-item-actions {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: 0.35rem;
  margin: 0 0 0.45rem;
}

/* The same gold ink the review card's own footer link carries (see .show-original-item):
   this is the one control in the Emails list that opens the parsed detail rather than the
   raw message, so it reads as the article's primary action, not another quiet link. */
.explore-detail-item {
  color: var(--accent-ink);
}

.email-notes {
  margin: 0.15rem 0;
  color: var(--muted);
  font-size: var(--text-detail-body);
}

.email-item a,
.email-item summary {
  color: color-mix(in srgb, var(--accent) 70%, var(--ink));
  font-size: var(--text-detail-body);
}

.email-item details {
  margin-top: 0.35rem;
}

.email-item:focus-visible {
  outline: 2px solid var(--accent);
  outline-offset: 3px;
}

.email-sources {
  margin: 0.5rem 0 0;
  padding: 0 0 0 0.6rem;
  /* See .meals-preference-list: --line-strong, because a hairline tuned for a seam
     between two lines of a document cannot be seen against the inset's own ground. */
  border-radius: 0;
  border-left: 2px solid var(--line-strong);
}

.email-sources-label {
  margin: 0 0 0.3rem;
  color: var(--muted);
  font-size: var(--text-detail-meta);
  letter-spacing: 0.04em;
  text-transform: uppercase;
}

.email-source {
  margin: 0 0 0.45rem;
}

.email-source-excerpt {
  margin: 0;
  overflow-wrap: anywhere;
  white-space: pre-wrap;
  font-size: var(--text-detail-body);
}

.email-item summary {
  cursor: pointer;
}

.email-item {
  min-width: 0;
  max-width: 100%;
  overflow-wrap: anywhere;
}

.email-body {
  max-width: 100%;
  margin: 0.45rem 0 0;
  padding: 0.6rem;
  overflow-wrap: anywhere;
  white-space: pre-wrap;
  color: var(--ink);
  border-left: 2px solid color-mix(in srgb, var(--accent) 50%, var(--line));
  /* A quoted message body reads at the caption step, which is .app-inset's own -- this
     used to be a `font:` shorthand carrying a tenth per-surface rem size that the
     typography guard's longhand-only regex could not see. The guard sees a shorthand
     now; the size is simply gone. */
}

/* Defensive scaling for any image or table content rendered into these two Email
   containers, scoped to them only -- not an app-wide rule. Neither element currently
   appears in email content (summaries and the raw body are plain text), but the
   containers' own overflow-x: hidden above only stays honest if nothing that could
   still show up inside them is allowed to force extra width. */
.email-review-detail img,
.email-detail-body img {
  max-width: 100%;
  height: auto;
}

.email-review-detail table,
.email-detail-body table {
  max-width: 100%;
  table-layout: fixed;
}

.email-review-detail td,
.email-review-detail th,
.email-detail-body td,
.email-detail-body th {
  overflow-wrap: anywhere;
}

.email-review-bound {
  margin-top: 0.75rem;
}

/* The footnote ends the list; the scroller's shared end padding is its whole bottom gap. */
.email-review-detail > .email-review-bound:last-child {
  margin-bottom: 0;
}

.calendar-confirmation p,
.calendar-add-failure {
  margin: 0 0 0.55rem;
}

/* The row now carries up to four things -- the checkbox, the event's own fields, the
   Event/Task switch and the reminder group -- and at a phone width they do not fit on one
   line. The row wraps, and the details claim the line with a real basis, so the two
   control groups drop below the text together instead of each being squeezed into a
   column narrow enough to stack its own buttons. Nothing moves at a desktop width, where
   there was room all along. */
.calendar-proposal-event {
  display: flex;
  align-items: flex-start;
  flex-wrap: wrap;
  gap: 0.6rem;
}

/* The candidate list's own checkbox, drawn with the app's circular form: a 24px ring
   that fills solid --accent with an --on-accent mark when chosen, the same treatment
   .agenda-task-check, .meals-proposal-choice input, .grocery-check-input and
   .grocery-ingredient-check all draw. margin-top keeps it aligned with the row's first
   text line when align-items: flex-start applies (see .calendar-proposal-event above). */
/* The candidate list's checkbox is the shared .app-check. margin-top is all that is
   left of this rule's own job: keeping the ring aligned with the row's first text line
   when align-items: flex-start applies (see .calendar-proposal-event above). */
.calendar-candidate-choice {
  margin-top: 0.25rem;
}

/* A long proposal's header: what it holds, how much of it is selected, and the two
   controls that move the whole selection at once. Only rendered above
   CALENDAR_PROPOSAL_GROUP_FROM actions; a short proposal stays a plain list. */
.calendar-proposal-summary {
  margin: 0 0 0.4rem;
  font-size: var(--text-detail-body);
  color: var(--muted);
}

.calendar-proposal-select-controls {
  display: flex;
  gap: 0.4rem;
  margin-bottom: var(--card-gap);
}

/* One operation's disclosure. Built on the same quiet-toggle-over-a-hidden-list shape
   as the agenda's "Completed · N" group, so the two read as one idiom: the list is
   hidden, never unbuilt, and every proposed action is in the document from first render. */
.calendar-proposal-group {
  margin-bottom: var(--card-gap);
}

.calendar-proposal-group-head {
  display: flex;
  align-items: center;
  gap: 0.4rem;
}

.calendar-proposal-group-toggle {
  flex: 1;
  min-width: 0;
  text-align: left;
}

.calendar-proposal-group-count {
  color: var(--muted);
}

.calendar-proposal-group-list {
  margin-top: var(--card-gap);
}

.calendar-proposal-group-list[hidden] {
  display: none;
}

.calendar-event-details {
  min-width: 0;
  /* A basis, not just a grow: with `auto` the details shrink to fit whatever the control
     groups demand, which is what left the reminder group a 45px column with its two
     buttons stacked. 12rem is wide enough to read a title on a 390px phone and short
     enough that the controls still share the line on a desktop. */
  flex: 1 1 12rem;
}

.calendar-add {
  /* Ink on the accent fill below, not the page ground -- see --on-accent. */
  color: var(--on-accent);
  background: var(--accent);
  border: 0;
}

/* min-height only: at --text-detail-body a 36px fixed height is one pixel short of the
   line box plus its padding, and the label clips. */
.calendar-proposal-control {
  min-height: 36px;
  padding: 0.4rem 0.85rem;
  border-radius: var(--radius-control);
  font: inherit;
  font-size: var(--text-detail-body);
  font-weight: var(--w-title);
}

/* Add is the region's one accepted action and keeps .calendar-add's accent fill above;
   Cancel is a dismissal, not a second action, so it goes neutral -- the same shape the
   nine-id Cancel stopgap gives a bare button elsewhere in this file. */
#calendar-proposal-cancel {
  background: var(--panel-subtle);
  color: var(--muted);
  border-color: var(--line-strong);
}

.calendar-add:disabled {
  opacity: 0.6;
}

.calendar-add-failure {
  margin-top: 0.55rem;
  font-size: var(--text-detail-body);
}

.calendar-confirmation p {
  font-size: var(--text-detail-body);
}

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

  .dictate[data-state="listening"] {
    animation: none;
  }
}

/* The one composer row: 67px resting height (50px row + the reference's own gp-footer
   padding, 17px 20px 0 -- 16px sides under 370px wide, see the media block below). The
   image-previews strip, the failure/dictation status lines, and the overflow sheet are
   all absolutely positioned overlays anchored to the top of this box (.composer-status,
   .composer-overflow-sheet, below) rather than participants in its flow, so a staged
   attachment or a dictation status line never moves the row's own height (INV-1) --
   only typing a longer message legitimately grows it. The status rail (above) is the one
   thing that does sit in the flow above the row: attached to the pill's top, it grows
   this box and therefore the measured reservation with it, while the overlays keep
   anchoring to whatever the box's top then is -- above the rail when one is showing.

   Flat (INV-6): #command-bar (above) now draws no surface of its own -- the composer pill
   (.composer-row, below) is what floats directly on the wallpaper, the reference's own
   arrangement, so .composer here stays a plain padded row rather than a card: no
   background, border or shadow of its own. The bottom padding is 0, not symmetric with
   the top -- the dock below (.dock-nav) supplies its own 10px top padding for the gap
   between the pill and the dock, the same split the reference's own gp-footer/gp-nav pair
   uses, rather than doubling that gap here too. background/border/shadow are written
   explicitly rather than simply omitted, so nothing the cascade adds later (a button skin,
   a focus style) fills them in unnoticed. */
.composer {
  position: relative;
  padding: 17px 20px 0;
  background: transparent;
  border: none;
  border-radius: 0;
  box-shadow: none;
}

/* [hidden] is not set on the composer by anything today: the command bar is visible on
   every display tab, Today included -- there is no more per-tab hide to guard against.
   This rule is kept only as a cheap safety net should [hidden] ever be set for an
   unrelated reason; it must not be reintroduced as a way to hide the composer per tab. */
.composer[hidden] {
  display: none;
}

/* flex-end, not center, and this is INV-3 rather than taste: the field grows upward as it
   fills (to five lines, then it scrolls internally), which makes this row taller. Centred,
   the mic and send anchors would ride up by half of every pixel the field gained -- measured
   at 22.5px by three lines in a real browser, which no JSDOM test can see. Pinned to the
   bottom they stay at one screen coordinate in every state, which is what the composer
   promises: the two anchors never move, whatever the field does above them. */
/* The reference's own pill composer: one rounded surface holding every control, rather
   than a bare row of icons on the bar's own ground. align-items stays flex-end (INV-3's
   own reasoning, restated below) even though the pill's padding is even on every side --
   a multi-line textarea still grows the row taller than its neighbours, and flex-end is
   what keeps the mic and Send pinned to one screen coordinate while it does. */
.composer-row {
  display: flex;
  align-items: flex-end;
  min-width: 0;
  min-height: 50px;
  gap: 0.4rem;
  padding: 3px 6px;
  background: var(--panel-subtle);
  border-radius: var(--radius-surface);
  box-shadow: var(--shadow-inset-highlight);
}

.composer-overflow,
.dictate {
  flex: none;
}

/* The field never drops below 16px (iOS zooms the page on focus under it). */
.composer-input textarea {
  font-size: var(--text-input);
}

/* At Large the field gets too few characters beside three buttons, so once the row is in
   use (focused or holding text) the field takes its own full-width line inside the same
   pill and the buttons sit beneath it, Send at the trailing end. An empty, unfocused Large
   composer stays one row at every phone width. */
:root[data-text-size="large"] .composer-row:focus-within,
:root[data-text-size="large"] .composer-row:has(.message-clear:not([hidden])) {
  flex-wrap: wrap;
}

:root[data-text-size="large"] .composer-row:focus-within .composer-input,
:root[data-text-size="large"] .composer-row:has(.message-clear:not([hidden])) .composer-input {
  order: -1;
  flex: 1 0 100%;
}

:root[data-text-size="large"] .composer-row:focus-within #send,
:root[data-text-size="large"] .composer-row:has(.message-clear:not([hidden])) #send {
  margin-left: auto;
}

#send {
  flex: none;
  width: var(--touch-min);
  min-width: var(--touch-min);
  min-height: var(--touch-min);
}

/* The sheet the `+` button opens: camera and photo library, plus the compact answer-model
   picker (.composer-model-picker) -- nothing else lives here. Anchored above the row it
   opens from rather than growing the composer -- .composer's own position: relative is what
   this and .composer-status both position against. */
.composer-overflow-sheet {
  position: absolute;
  z-index: 1;
  left: 0.75rem;
  bottom: calc(100% + 0.5rem);
  display: flex;
  align-items: center;
  flex-wrap: wrap;
  gap: 0.5rem;
  padding: 0.5rem;
  background: var(--panel-subtle);
  border: 1px solid var(--line);
  border-radius: var(--radius-surface);
  box-shadow: var(--shadow-sheet);
}

.composer-overflow-sheet[hidden] {
  display: none;
}

/* The sheet's items carry their visible names beside their icons: an icon-only item in a
   menu is a guess, and this sheet is the app's reference pattern for every overflow menu
   ("Add images", "Take photo" -- aria-labels alone never did the naming here). Row-shaped
   rather than the square .icon-button default, on the same 44px floor. */
.composer-overflow-sheet .icon-button {
  display: inline-flex;
  align-items: center;
  gap: 0.4rem;
  width: auto;
  min-width: 44px;
  padding: 0 0.7rem;
}

.composer-overflow-label {
  color: var(--ink);
  font-size: var(--text-detail-body);
  font-weight: var(--w-meta);
  white-space: nowrap;
}

/* The one non-button item in the overflow sheet: a plain .app-field select, sized to the
   same row rather than growing the sheet. Scoped narrow -- this menu is a small popover,
   not a settings page -- and truncates a long label rather than wrapping the sheet wider. */
.chat-model-select {
  max-width: 12rem;
  min-height: 44px;
}

.composer-input {
  position: relative;
  display: flex;
  align-items: center;
  min-width: 0;
  flex: 1 1 auto;
}

/* The border is not the shared rule's: the pill itself is the field's whole boundary now
   (.composer-row), so the textarea inside it draws none of its own -- unlike every other
   textarea in the app, which still keeps the shared rule's accent outline. */
.composer-input textarea {
  border-color: transparent;
}

/* Room for the inline x at the field's own right edge, reserved only while that button is
   actually showing (chat.js's resizeInput() unhides it once the field is non-empty) --
   :has() is already this stylesheet's idiom for asking the DOM a question like this (see
   .today-row, .agenda-day). Reserving it unconditionally starved the empty, resting field
   of width it never needed. */
.composer-input:has(.message-clear:not([hidden])) textarea {
  padding-right: 2rem;
}

.message-clear {
  position: absolute;
  top: 50%;
  right: 0.4rem;
  transform: translateY(-50%);
  width: 1.5rem;
  min-width: 1.5rem;
  min-height: 1.5rem;
  padding: 0;
  color: var(--muted);
  background: transparent;
  border-color: transparent;
  border-radius: var(--radius-pill);
}

/* The visible x stays 1.5rem; the overlay grows the hit area to --touch-min the way
   .app-check-reach does, with the button's own position: absolute as its containing block. */
.message-clear::after {
  content: '';
  position: absolute;
  inset: calc((var(--touch-min) - 1.5rem) / -2);
}

.message-clear[hidden] {
  display: none;
}

.message-clear:hover,
.message-clear:focus-visible {
  color: var(--ink);
  background: var(--code);
}

/* The staged-attachment strip and the failure/dictation status lines: an overlay above
   the row, never part of its flow (see .composer above) -- a staged attachment must not
   move the composer's own top edge (INV-1). */
.composer-status {
  position: absolute;
  z-index: 1;
  left: 0.75rem;
  right: 0.75rem;
  bottom: 100%;
  display: flex;
  flex-direction: column;
  gap: 0.3rem;
  padding-bottom: 0.4rem;
}

.composer-status:empty {
  display: none;
}

.image-previews {
  display: flex;
  flex-wrap: wrap;
  gap: 0.6rem;
  padding: 0.1rem 0.1rem 0;
  min-width: 0;
}

.image-previews[hidden],
.image-message[hidden] {
  display: none;
}

.image-preview {
  position: relative;
  width: 4rem;
  height: 4rem;
}

.image-preview img {
  display: block;
  width: 100%;
  height: 100%;
  object-fit: cover;
  border-radius: var(--radius-control);
}

.remove-image {
  position: absolute;
  top: -0.35rem;
  right: -0.35rem;
  width: 1.75rem;
  min-width: 1.75rem;
  min-height: 1.75rem;
  padding: 0;
  color: var(--ink);
  background: var(--panel);
  border: 2px solid var(--line);
  font-size: 1.15rem;
  line-height: 1;
}

/* The 1.75rem button stays as drawn; the overlay grows its hit area to --touch-min. It
   sits in the corner of the 4rem preview, so it only reaches past the preview's edge. */
.remove-image::after {
  content: '';
  position: absolute;
  inset: calc((var(--touch-min) - 1.75rem) / -2);
}

.image-message,
.dictation-status {
  margin: 0;
  color: var(--error);
  font-size: var(--text-detail-body);
  overflow-wrap: anywhere;
  background: var(--panel-subtle);
  border: 1px solid var(--line);
  border-radius: var(--radius-control);
  padding: 0.3rem 0.5rem;
}

.dictation-status:empty {
  display: none;
}

.dictation-status[data-kind="interim"] {
  color: var(--muted);
}

textarea {
  flex: 1;
  min-width: 0;
  min-height: 0;
  max-height: 15rem;
  width: 100%;
  padding: 0.35rem calc(0.3rem + 5px);
  font: inherit;
  color: inherit;
  background: transparent;
  border: 1px solid var(--accent);
  border-radius: var(--radius-control);
  resize: none;
  overflow-y: hidden;
}

textarea:focus {
  outline: none;
}

select:focus-visible,
button:focus-visible {
  outline: none;
  border-color: var(--accent);
  box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 22%, transparent);
}

html.keyboard-focus textarea:focus-visible {
  outline: 2px solid var(--muted);
  outline-offset: 2px;
  border-color: var(--accent);
  box-shadow: none;
}

/* The global default every button in the app inherits unless it overrides away (.icon-button,
   .app-quiet-button, etc.): a solid accent rounded rectangle. Ink is --on-accent, not the page ground --
   the two used to be the same white, so this rule went black-on-blue the moment the page
   ground stopped being white in dark mode. */
button {
  min-height: 44px;
  flex: none;
  padding: 0.7rem 1.2rem;
  font: inherit;
  font-weight: var(--w-title);
  color: var(--on-accent);
  background: var(--accent);
  border: 1px solid var(--accent);
  border-radius: var(--radius-control);
  cursor: pointer;
}

/* 44px everywhere (SLICE K): the composer's `+`, its overflow sheet's two controls, and
   both mic buttons (composer and Groceries) all reuse this one base rather than a
   phone-only bump -- "one size everywhere is fine" was the person's own call, and the app
   already has one narrower breakpoint (the TV block below) that overrides up from here, so
   a second, downward one would be the odd rule out. */
.icon-button {
  display: grid;
  place-items: center;
  width: var(--touch-min);
  min-width: var(--touch-min);
  min-height: var(--touch-min);
  padding: 0;
  color: var(--muted);
  background: transparent;
  border-color: transparent;
  border-radius: var(--radius-pill);
}

.icon-button[hidden] {
  display: none;
}

.icon-button:hover,
.icon-button:focus-visible {
  color: var(--ink);
  background: var(--code);
}

.dictate[data-state="listening"] {
  color: var(--ink);
  background: color-mix(in srgb, var(--accent) 30%, transparent);
  border-color: var(--accent);
  animation: dictation-pulse 1.4s ease-in-out infinite;
}

.dictate[data-state="stopping"] {
  color: var(--muted);
  background: var(--code);
  border-color: var(--muted);
}

.dictate[data-state="starting"] {
  color: var(--muted);
  background: var(--code);
  border-color: var(--accent);
  border-style: dashed;
}

.dictate:disabled {
  color: color-mix(in srgb, var(--muted) 55%, var(--page));
  background: transparent;
  border-color: transparent;
}

@keyframes dictation-pulse {
  50% {
    box-shadow: 0 0 0 4px color-mix(in srgb, var(--accent) 15%, transparent);
  }
}

/* A gold icon, not the generic button rule's filled pill: Send sits beside the mic as a
   quiet control on the composer's own pill (see .composer-row), and the reference draws
   it in the one accent colour rather than a solid accent disc. */
#send {
  display: grid;
  place-items: center;
  padding: 0;
  color: var(--accent);
  background: transparent;
  border-color: transparent;
}

#send:hover:not(:disabled),
#send:focus-visible {
  color: var(--accent-hover);
  background: var(--code);
}

/* 20px, not the old 18px: the glyph grows with the box (SLICE K's 40px -> 44px bump)
   instead of floating in extra empty square. */
.icon-button svg,
#send svg {
  width: 20px;
  height: 20px;
  fill: none;
  stroke: currentColor;
  stroke-width: 1.8;
  stroke-linecap: round;
  stroke-linejoin: round;
}

.message-clear svg {
  width: 14px;
  height: 14px;
  fill: none;
  stroke: currentColor;
  stroke-width: 1.8;
  stroke-linecap: round;
  stroke-linejoin: round;
}

button:disabled,
textarea:disabled,
select:disabled {
  opacity: 0.6;
  cursor: default;
}

.take-photo:disabled {
  color: color-mix(in srgb, var(--muted) 55%, var(--page));
  background: transparent;
  border-color: transparent;
}

.remove-image:hover:not(:disabled) {
  color: var(--ink);
  background: var(--code);
  border-color: var(--muted);
}

.visually-hidden {
  position: absolute;
  width: 1px;
  height: 1px;
  overflow: hidden;
  clip-path: inset(50%);
}

/* Every dialog shares one frame; each keeps only the classes its own content needs
   beside these (typography, its footer's extra layout). */
.app-dialog {
  width: min(40rem, calc(100% - 1.5rem));
  max-height: min(82vh, 46rem);
  max-height: min(82dvh, 46rem);
  padding: 0;
  color: var(--ink);
  background: var(--panel-subtle);
  border: 1px solid var(--line);
  border-radius: var(--radius-overlay);
}

.app-dialog::backdrop {
  background: var(--scrim);
}

.app-dialog-frame {
  display: flex;
  max-height: min(82vh, 46rem);
  max-height: min(82dvh, 46rem);
  flex-direction: column;
  min-height: 0;
}

.app-dialog-header {
  display: flex;
  flex: none;
  align-items: flex-start;
  justify-content: space-between;
  gap: 1rem;
  padding: 0.8rem 0.9rem;
  border-bottom: 1px solid var(--line);
}

.app-dialog-header h2 {
  flex: 1 1 auto;
  min-width: 0;
  margin: 0;
  font-size: var(--text-detail-title);
  overflow-wrap: break-word;
}

.app-dialog-header button {
  min-width: var(--touch-min);
  min-height: var(--touch-min);
}

/* The body carries the detail body step for every dialog, so a dialog's own prose
   inherits it and a new dialog needs no font-size of its own. Only a heading inside one
   still declares a size, and it declares --text-detail-title. */
.app-dialog-body {
  min-height: 0;
  padding: 0.9rem;
  overflow-y: auto;
  overscroll-behavior: contain;
  font-size: var(--text-detail-body);
  line-height: var(--leading-detail-body);
}

.app-dialog-footer {
  display: flex;
  flex-wrap: wrap;
  justify-content: flex-end;
  flex: none;
  gap: 0.55rem;
  padding: 0.8rem 0.9rem;
  border-top: 1px solid var(--line);
}

@media (max-width: 47.99rem) {
  .app-dialog-footer > button {
    flex: 1 1 auto;
  }
}

/* STOPGAP -- remove when the bare-button inversion lands.
   These eleven have no class and inherit the global primary style,
   so every dialog's Cancel currently reads as its Confirm. */
#hide-preference-cancel, #meals-proposal-cancel, #meal-eaten-cancel,
#meal-save-cancel, #grocery-meals-cancel, #music-proposal-cancel, #view-replace-cancel,
#event-preview-close, #recipe-close, #email-detail-close, #agenda-day-close,
#choice-cancel {
  background: var(--panel-subtle);
  color: var(--muted);
  border-color: var(--line-strong);
}

/* Centring only where a footer's items are all one-line controls; the footers that can
   grow a wrapping note or error line also wrap. hide-preference-footer and
   meal-eaten-footer joined this list along with their own error paragraphs -- both are
   dialogs a failure can arrive in while already open, the same shape event-preview-footer
   and recipe-footer already have one of. So is view-replace-footer: a refused or
   conflicted replacement is explained in the dialog still open on it. */
.calendar-proposal-footer,
.event-preview-footer,
.recipe-footer,
.hide-preference-footer,
.meal-eaten-footer,
.meal-save-footer,
.meals-proposal-footer,
.music-proposal-footer,
.view-replace-footer,
.choice-footer {
  align-items: center;
}

.event-preview-footer,
.recipe-footer,
.hide-preference-footer,
.meal-eaten-footer,
.meal-save-footer,
.view-replace-footer,
#native-app-dialog .app-dialog-footer {
  flex-wrap: wrap;
}

/* Three actions ('Get it from Google Play', 'Remove from selector', 'Close') do not fit one
   row on a phone: each takes its own full-width row, 44px or taller. */
#native-app-dialog .app-dialog-footer > button {
  flex: 1 1 100%;
  min-height: 44px;
}

#native-app-dialog .app-dialog-footer > [hidden],
#native-app-dialog .app-dialog-footer > button[hidden] {
  display: none;
}

/* Same fix as .email-review-detail above, scoped to this dialog body only -- not to the
   shared .app-dialog-body class every other dialog also uses. */
.email-detail-body {
  overflow-x: hidden;
  touch-action: pan-y;
}

.email-detail-body h3,
.email-detail-body p,
.email-detail-body ul {
  margin-block: 0 0.65rem;
}

.email-detail-body h3 {
  font-size: var(--text-detail-title);
}

.email-detail-body li,
.email-detail-body p,
.email-detail-body a {
  overflow-wrap: anywhere;
}

.email-detail-body li,
.email-detail-body p {
  line-height: 1.5;
}

.email-detail-source-excerpt {
  white-space: pre-wrap;
}

.email-detail-footnote {
  color: var(--muted);
}

.email-detail-error {
  color: var(--error);
}

.event-preview-note {
  flex: 1 1 0;
  min-width: 10rem;
  margin: 0;
  color: var(--muted);
  font-size: var(--text-detail-body);
  overflow-wrap: anywhere;
}

.event-preview-error {
  flex: 1 1 100%;
  margin: 0;
  color: var(--error);
  font-size: var(--text-detail-body);
  overflow-wrap: anywhere;
}

/* Placement only -- colour and size are .app-failure's own, already on all elements. Full
   width in the wrapping footer so a long reason never squeezes beside Mark eaten/Confirm
   and Cancel, or beside Replace and Cancel; each grows only when it actually has
   something to say. */
.hide-preference-error,
.meal-eaten-error,
.meal-save-error,
.view-replace-error {
  flex: 1 1 100%;
  overflow-wrap: anywhere;
}

.event-preview-body p {
  margin: 0 0 0.55rem;
}

.event-preview-body p:last-child {
  margin-bottom: 0;
}

.event-preview-field-label {
  color: var(--muted);
}

.hide-preference-body h3,
.hide-preference-body p,
.hide-preference-body ul {
  margin-block: 0 0.55rem;
}

.hide-preference-body h3 {
  font-size: var(--text-detail-title);
}

.hide-preference-option {
  display: flex;
  align-items: flex-start;
  gap: 0.5rem;
  margin-block: 0 0.4rem;
}

.hide-preference-option input[type="radio"] {
  margin-top: 0.2rem;
  flex: none;
}

/* The caution reads at .app-notice's own ink and step -- a line the person should read
   first is not a caption, and it used to be --muted here for no reason the dialog gave. */
.hide-preference-caution {
  margin: 0;
}

.calendar-proposal-footer .calendar-add-failure {
  flex: 1 1 0;
  min-width: 0;
  margin: 0;
  overflow-wrap: anywhere;
}

/* On its own line above Add/Cancel: the sentence plus its button need the footer's full
   width, and a modal hides the composer toast, so this is the one place a reminder-related
   failure or call-to-action can live. */
.calendar-reminder-notice {
  flex: 1 1 100%;
  margin: 0;
  color: var(--muted);
  font-size: var(--text-detail-body);
  overflow-wrap: anywhere;
}

/* Meals' own surface even though the dialog lives outside .meals-plan in the DOM, so
   its one accented line can use the pane's accent. */
.recipe-dialog {
  --pane-accent: var(--accent-meals);
}

.recipe-error {
  flex: 1 1 100%;
  margin: 0;
  color: var(--error);
  font-size: var(--text-detail-body);
  overflow-wrap: anywhere;
}

/* The Serves line is the one accented datum in the dialog: app-key's weight, on the
   pane's own accent instead of the house blue. */
.recipe-servings {
  margin: 0 0 0.55rem;
  color: var(--pane-accent);
  font-size: var(--text-detail-meta);
}

/* The recipe body's two halves -- the facts and ingredients a person measures from, and
   the instructions they cook from -- arrive from meals.js as two plain stacked blocks, so
   every surface that shows a recipe (the Add Meals disclosure, the Meals review dialog)
   keeps the single column it has always had. The recipe dialog alone stands them side by
   side at the app's own 1100px breakpoint (the shared desktop block further down, beside
   Meals' own two-column rule there) and widens to hold it: one media query over the one
   markup, never a second renderer. */
.recipe-detail-main,
.recipe-detail-steps {
  min-width: 0;
}

/* Stacked below the breakpoint, the steps sit under the facts with the rhythm a section
   break gives; the desktop block stands them side by side and removes the break. */
.recipe-detail-steps {
  margin-top: 0.9rem;
}

/* The one `.agent-` rule that outlived the agent inspector, because it was never only the
   inspector's: the Household facts pane wraps its own saved lines in `.agent-household`
   too, and that pane is still on screen. Everything else this block held styled the
   dialog alone and went with it. The gold-and-blue card round moves it onto the shared
   .app-card surface (composed in the markup) rather than its own one-off inset -- its own
   rule now states only what .app-card does not: the gap above it. */
.agent-household {
  margin-top: var(--card-gap);
}

.agent-household p {
  color: var(--muted);
}

/* The person switcher gets the same card treatment, with the same gap below it before the
   facts card that follows -- two equal cards, not a card and a loose control block. */
.person-selector-card {
  margin-bottom: var(--card-gap);
}

/* One pane of the display band (see .chat-lower): it fills the space between the header
   band and the command bar, rather than sizing itself as a standalone page. */
/* Transparent, not the old page-sized panel: the wallpaper (.app-wallpaper, fixed behind
   everything) shows straight through, the same idiom .app-scroller-field/-page's own
   comment states for every other pane. A card still draws its own surface (.app-card); it
   is only the FIELD around them, and the header/strip/day-heading text directly on the
   wallpaper, that stopped drawing one of its own -- see the on-wall ink rules below. */
.agenda {
  display: flex;
  flex-direction: column;
  flex: 1 1 0;
  min-height: 0;
  background: transparent;
}

.agenda[hidden] {
  display: none;
}

/* Another pane of the same display band, sized identically to .agenda beside it, but --
   unlike .agenda -- simple enough to be its own single content scroller directly, with no
   inner scrolling region of its own. */
/* Five panes -- Household, Meals, Settings, Groceries and the Library -- each declared
   the same eight-line scroller here. They compose .app-scroller and .app-scroller-page in
   the markup now and have no rule of their own at all; the five that were left behind are
   deleted rather than kept empty, because an empty rule is somewhere for a declaration to
   creep back into. */
.household-facts[hidden] {
  display: none;
}

/* The gold-and-blue card round drops the per-destination accent this subtitle used to
   read (SURFACES: "cards are equal"), the same move Meals'/Podcasts'/Music's own headers
   and section markers made -- the shared subtitle rule's on-wall ink pair is enough. */

/* Using as: two quiet buttons, so the selected person is stated by the same
   vocabulary every other small control in the app already uses. The selected
   state is deliberately NOT colour alone -- the role="radio"/aria-checked pair
   carries it for a screen reader, and an inset ring plus a heavier weight
   carries it visually, with no layout shift between the two states because the
   ring is a box-shadow rather than a thicker border. */
/* No bottom margin of its own now: it is always the whole and only content of
   .person-selector-card, so the card's own padding is the space below it, not a second
   margin stacked inside that boundary. */
.person-selector {
  margin: 0;
}

.person-selector-label {
  margin: 0 0 0.35rem;
  color: var(--muted);
  font-size: var(--text-detail-body);
  font-weight: var(--w-meta);
}

.person-selector-options {
  display: flex;
  flex-wrap: wrap;
  gap: 0.5rem;
}

/* 44px, not the quiet button's own 32px: this is a touch target on a phone. In the
   Household selector each option also carries its person's key (data-person-key), the one
   attribute the [data-person-key] table above needs to set --person-color on it, so the
   shared .person-dot beside the name says whose tasks, mail and conversation the option
   selects -- the same colour the calendar washes that person's events with. */
.person-option {
  display: inline-flex;
  align-items: center;
  gap: 0.4rem;
  min-height: 44px;
  /* A long name wraps inside its option rather than being cut. */
  max-width: 100%;
  overflow-wrap: break-word;
  text-align: left;
}

/* The checked half of the Household radiogroup reads as "this is the active person"
   through one deliberate step more than the shared selected wash: the SAME person colour
   that option's own dot already draws (--person-color, set by the [data-person-key] table
   above) mixed into the card, not the generic blue --accent every other selected control
   uses -- the brief's own "give the active person a clear identity surface using existing
   person colour." Scoped to the selector's own options so the calendar proposal's
   identically-classed assign rows keep the plain shared treatment. */
.person-selector-options .person-option[aria-checked="true"] {
  background: color-mix(in srgb, var(--person-color, var(--accent)) 22%, var(--panel-subtle));
  border-color: color-mix(in srgb, var(--person-color, var(--accent)) 45%, var(--line));
}

.person-selector-note {
  margin: 0.4rem 0 0;
  color: var(--muted);
  font-size: var(--text-detail-body);
}

/* The indent the Household facts list is read at. It used to match a rule in the agent
   inspector's dialog; that dialog is gone, so this is now the only statement of it. */
#household-list ul {
  margin: 0;
  padding-left: 0;
  list-style: none;
}

/* A compact row per fact; the hairline between rows is .app-divider-list's own. */
#household-list li {
  padding-block: 10px;
  font-size: var(--text-detail-body);
  line-height: 1.4;
}

#household-list li:first-child {
  padding-top: 0;
}

#household-list li:last-child {
  padding-bottom: 0;
}

/* Small headings over the groups: the current person's, Everyone, the other person's. */
.household-group-heading {
  margin: var(--card-gap) 0 0.25rem;
}

.household-group-heading:first-child {
  margin-top: 0;
}

/* The text takes the row; Edit and Delete sit at its end, and the edit form lays its field
   and two buttons out the same way. */
.household-fact,
.household-fact-edit {
  display: flex;
  align-items: center;
  gap: 0.5rem;
}

.household-fact-text {
  flex: 1 1 auto;
  min-width: 0;
  overflow-wrap: anywhere;
}

.household-fact-actions {
  display: flex;
  flex: none;
  gap: 0.25rem;
}

.household-fact-edit {
  flex: 1 1 auto;
}

.household-fact-input {
  flex: 1 1 auto;
  min-width: 0;
}

/* A third pane of the same display band, sized identically to .household-facts beside
   it: a scrollable column owning the band's single content scroller while it shows.
   The pane itself reads in the shared neutral vocabulary now -- its section labels
   inherit .app-section-label's muted tone and its preference rule uses --line -- and it
   sets no --pane-accent of its own: the one accented datum the feature has, the Serves
   line, lives in the two dialogs, .recipe-dialog and .meals-proposal-dialog, which set
   their scopes from --accent-meals. */
.meals-plan[hidden] {
  display: none;
}

/* The gold-and-blue card round: Meals' toolbar used to be a featured pale-peach band and
   its two section markers (Food preferences, Saved recipes) the pane's own terracotta --
   both obsolete now that cards are equal (SURFACES: "no per-destination tint") and every
   recipe is its own opaque card rather than a line in a divider list. The toolbar sits
   directly on the wallpaper instead (zeroing the shared primitive's ground and rule, the
   same way Calendar's own page header does), so its title and Add groceries need the
   on-wall ink pair every other caption between cards reads through -- Add groceries reads
   as .app-top-button, not the quiet card-button, for the same reason Calendar's Today
   control does. Food preferences and Saved recipes get the same pair below, scoped to
   where each actually lives (the two .podcasts-collapsible bodies), since neither
   disclosure sits on a card of its own. The headings themselves are the shared
   .podcasts-collapsible-toggle, already in the on-wall ink; the scroller's own gutter
   places them, so no inset is restated here. */
.meals-plan .app-toolbar {
  background: transparent;
  border-bottom: 0;
}

.meals-plan .app-toolbar-title {
  color: var(--ink-heading);
  text-shadow: var(--text-shadow-on-wall);
}

/* Each recipe is its own card now (.meal-row composes .app-card), so the empty-plan
   sentence is the one thing left directly on the wallpaper in #meals-list. */
/* Food preferences and Saved recipes are each one card: a 44px summary row and, when open,
   the content inside the same surface (both containers compose .app-card in the markup).
   The card's vertical padding is zeroed so the toggle's own 44px is the row height; the
   body carries the bottom inset. The toggle reads in the card's ink, not the on-wall pair. */
.meals-preferences,
.meals-saved {
  margin-top: var(--card-gap);
  padding-block: 0;
}

.meals-preferences .podcasts-collapsible-toggle,
.meals-saved .podcasts-collapsible-toggle {
  color: var(--ink);
  text-shadow: none;
}

.meals-collapsible-body {
  padding-bottom: var(--card-pad-y);
}

/* A recipe card inside the open card steps one tone up rather than stacking a second shadow. */
.meals-collapsible-body .meal-row {
  background: var(--panel);
  box-shadow: none;
}

/* Anything inside a card sits on the card's own surface, so it keeps the card's ink. */
.meals-collapsible-body .app-row-meta:not(.app-card *),
.meals-collapsible-body .app-empty:not(.app-card *),
.meals-collapsible-body .app-status:not(.app-card *) {
  color: var(--ink-on-wall-muted);
  text-shadow: var(--text-shadow-on-wall);
}

.meals-collapsible-body[hidden],
.meals-collapsible-body [hidden] {
  display: none;
}

.meals-saved-search-row,
.books-search-row {
  display: flex;
  align-items: center;
  gap: 0.5rem;
  margin-bottom: 0.5rem;
}

.meals-saved-search-row .app-field,
.books-search-row .app-field {
  flex: 1 1 auto;
  min-width: 0;
}

/* The Clear button already does this; WebKit's own cancel x would be a second one. */
#meals-saved-search input::-webkit-search-cancel-button {
  -webkit-appearance: none;
  appearance: none;
  display: none;
}

.meals-saved-search-status {
  margin: 0;
  padding: 0.375rem 0;
}

.meals-saved-search-status:empty {
  display: none;
}

/* Add groceries, pushed to the header's far right like the Groceries pane's Add to Kroger
   Cart (#kroger-cart-open) and sharing its .app-primary-button look. */
.grocery-meals-open {
  flex: none;
  align-self: center;
  margin-left: auto;
  white-space: nowrap;
}

/* The plan and the two things beside it. .meals-body is a plain block on a phone and the
   wrappers are display: contents, so the five children lay out exactly as they did as its
   own children -- same order, same margins, same rhythm -- and the phone is unchanged. The
   desktop block below turns the wrappers into real boxes and stands them side by side. */
.meals-column {
  display: contents;
}

/* One card per recipe (.meal-row composes .app-card): its opener and Save/Unsave (plus
   Mark eaten, in the plan list) wrap onto their own line inside the card rather than
   crushing against a narrow one. */
.meal-row {
  display: flex;
  flex-wrap: wrap;
  align-items: flex-start;
  gap: 0.5rem;
}

/* The row's clickable title: an .app-row like an agenda event row, plus only what is
   genuinely Meals-specific -- it shares its line with the Save/Unsave button, so it
   grows into the space that button leaves, at the house tap height. */
.meal-open {
  flex: 1 1 auto;
  min-width: 0;
  min-height: 40px;
  cursor: pointer;
}

/* The opener's single column: Meals has no leading time column to lay out beside, so
   the title and meta stack instead, like the agenda row's own main column. */
.meal-open-main {
  flex: 1 1 auto;
  min-width: 0;
}

/* The meal name now reads at the same step and weight as an event title -- level with the
   rest of the restrained hierarchy, not a step above it. */
.meal-title {
  display: block;
  overflow-wrap: anywhere;
  font-size: var(--text-detail-body);
  font-weight: var(--w-title);
  line-height: 1.3;
}

/* Its own line under the title (.app-row-meta's measures), not trailing it: a recipe
   title long enough to wrap otherwise leaves "Serves 4 · 9 ingredients" stranded
   mid-line, which is exactly the case at phone width. */
.meal-meta {
  display: block;
}

/* The pane's only control, an .app-quiet-button plus its own placement in the row. */
.meal-save {
  flex: none;
  margin-top: 0.15rem;
  font-weight: var(--w-meta);
}

/* The row's second action, beside Save/Unsave: same placement, same weight, so the two
   read as one family of quiet row controls rather than one styled differently from the
   other. .meal-row's own flex-wrap is what keeps a narrow row from crushing three items
   onto one line -- the buttons wrap below the opener rather than truncating it. */
.meal-eaten {
  flex: none;
  margin-top: 0.15rem;
  font-weight: var(--w-meta);
}

/* #meal-eaten-dialog: a label above an optional textarea, both inheriting the dialog
   body's own detail-body size and line-height -- no size of their own to declare. */
.meal-eaten-body {
  display: flex;
  flex-direction: column;
  gap: 0.5rem;
}

.meal-eaten-label {
  display: block;
  color: var(--ink);
}

.meal-eaten-comment {
  field-sizing: content;
  min-height: 4.5rem;
  max-height: min(40dvh, 18rem);
  overflow-y: auto;
}

/* #meal-save-dialog: built the same way as #meal-eaten-dialog just above -- a label above
   an optional textarea, both inheriting the dialog body's own detail-body size and
   line-height, so the two comment prompts read as one pattern rather than two. */
.meal-save-body {
  display: flex;
  flex-direction: column;
  gap: 0.5rem;
}

.meal-save-label {
  display: block;
  color: var(--ink);
}

.meal-save-comment {
  field-sizing: content;
  min-height: 4.5rem;
  max-height: min(40dvh, 18rem);
  overflow-y: auto;
}

/* The Meals review dialog (#meals-proposal-dialog) on the shared .app-dialog frame: one
   section per proposed meal. Meals' own accent scope, like .recipe-dialog above it, so
   the one accented line a recipe detail carries -- the Serves line -- uses the Meals
   accent inside this dialog too. */
.meals-proposal-dialog {
  --pane-accent: var(--accent-meals);
}

/* One proposed meal per section, the same hairline-and-gap rhythm .grocery-meal uses in
   the Add Meals dialog; nothing before the first. */
.meals-proposal-meal + .meals-proposal-meal {
  margin-top: 0.9rem;
  padding-top: 0.9rem;
}

/* The meal's title as its own disclosure: the shared quiet button carrying the shared
   arrow, full width and left aligned like the calendar dialog's group toggle. */
.meals-proposal-toggle {
  display: flex;
  align-items: center;
  gap: 0.5rem;
  width: 100%;
  min-height: 40px;
  padding: 0.3rem 0.55rem;
  text-align: left;
  font-weight: var(--w-title);
}

.meals-proposal-toggle-title {
  min-width: 0;
  overflow-wrap: anywhere;
}

/* The recipe itself, set in under its own title far enough to read as that meal's. */
.meals-proposal-recipe {
  margin: 0.45rem 0 0.55rem;
  padding-left: 0.55rem;
}

/* The meal's two choices side by side, wrapping rather than squeezing at a phone
   width. */
.meals-proposal-choices {
  display: flex;
  flex-wrap: wrap;
  gap: 0.4rem 1.2rem;
}

/* The whole label is the touch target at the house 44px, so the 24px box keeps its own
   size and is padded rather than stretched -- a checkbox is a replaced element, the same
   rule .grocery-ingredient follows. */
.meals-proposal-choice {
  display: flex;
  align-items: center;
  gap: 0.55rem;
  min-height: 44px;
  cursor: pointer;
}

/* Same ringed-circle pattern .grocery-check-input already draws (appearance reset, a
   ::before dot that scales in on :checked, an accent focus ring) -- copied rather than
   left on bare accent-color, so the three checkboxes still on the browser's own control
   read consistently with the one the app already got right. */
/* The dialog's checkbox is the shared .app-check (chat.js composes it onto the input
   this label wraps) -- the last of the copies of that circle to go. */

/* The dialogs' own failure line, in the footer beside Save -- the calendar dialog's
   failure shape, sharing the row and wrapping its words rather than squeezing them. */
.meals-proposal-failure,
.share-failure,
.music-proposal-failure {
  flex: 1 1 0;
  min-width: 0;
  margin: 0;
  color: var(--error);
  font-size: var(--text-detail-body);
  overflow-wrap: anywhere;
}

/* The Kroger cart review dialog (#kroger-cart-dialog) on the shared .app-dialog frame: one
   row per proposed grocery-to-product match, reached from the Groceries pane's own header
   button. */

/* The Groceries pane's Add to Kroger Cart button, a direct child of the page header beside
   the h1 and pushed to the header's far edge -- the same margin-left: auto idiom #agenda-today
   uses for the same job. The header aligns its children on the title's baseline, so the
   button centres itself against the large title instead, and the header's own flex-wrap
   drops it to a line of its own on a narrow phone or at enlarged text rather than letting
   it overflow. It is never hidden by a wrapper -- and .app-primary-button names no display,
   so the browser's own [hidden] rule hides it unaided. */
#kroger-cart-open {
  flex: none;
  align-self: center;
  margin-left: auto;
  white-space: nowrap;
}

/* The pane-side propose line, beside the button in the same header: a full flex line of
   its own when shown, so it never has to share the row a wrapped button already took. The
   [hidden] guard itself lives on the shared .kroger-cart-progress rule below. */
#kroger-cart-progress {
  flex: 1 1 100%;
}

/* The propose and apply pending lines (#kroger-cart-progress, #kroger-cart-progress-apply):
   the shared .chip-spinner beside its explanatory text, on one row. [hidden] must still win
   over this display:flex, the same rule .groceries-failure[hidden] restates below for its
   own pane's status line. */
.kroger-cart-progress {
  display: flex;
  align-items: center;
  gap: 0.375rem;
}

.kroger-cart-progress[hidden] {
  display: none;
}

/* One proposed item: its checkbox and text, the matched product, an optional reason, and a
   Change disclosure when alternatives exist -- lines inside one document, so the hairline
   rhythm is a bottom border with none after the last. */
.kroger-cart-row {
  display: flex;
  flex-direction: column;
  gap: 0.375rem;
  padding: 0.75rem 0;
  border-bottom: 1px solid var(--line);
}

.kroger-cart-row:last-child {
  border-bottom: none;
}

.kroger-cart-choice {
  display: flex;
  align-items: center;
  gap: 0.5rem;
}

.kroger-cart-candidate {
  display: flex;
  align-items: center;
  gap: 0.5rem;
}

.kroger-cart-candidate-image {
  width: 2.5rem;
  height: 2.5rem;
  object-fit: contain;
  border-radius: var(--radius-control);
}

.kroger-cart-candidate-text {
  display: flex;
  flex-direction: column;
}

.kroger-cart-candidate-detail {
  color: var(--muted);
  font-size: var(--text-detail-meta);
}

.kroger-cart-reason {
  color: var(--muted);
  font-size: var(--text-detail-meta);
  margin: 0;
}

.kroger-cart-alternatives {
  list-style: none;
  margin: 0.25rem 0 0;
  padding: 0;
  display: flex;
  flex-direction: column;
  gap: 0.25rem;
}

.kroger-cart-notice {
  color: var(--muted);
  margin: 0 0 var(--card-gap);
}

/* The Music review dialog (#music-proposal-dialog) on the shared .app-dialog frame: one
   row per proposed candidate. No accent scope of its own -- Music has no dedicated accent
   token, so the dialog keeps the app's plain default accent, the same way the Calendar
   proposal dialog does. */
.music-proposal-summary {
  margin: 0 0 0.55rem;
}

/* One proposed candidate per row, the same hairline-and-gap rhythm .meals-proposal-meal
   uses above; nothing before the first. */
.music-proposal-choice + .music-proposal-choice {
  margin-top: 0.9rem;
  padding-top: 0.9rem;
}

/* The whole label is the touch target at the house 44px, so the 24px box keeps its own
   size and is padded rather than stretched -- the same rule .meals-proposal-choice
   follows. */
.music-proposal-choice {
  display: flex;
  align-items: center;
  gap: 0.6rem;
  min-height: 44px;
  cursor: pointer;
}

.music-proposal-choice-text {
  min-width: 0;
  overflow-wrap: anywhere;
}

/* The choice dialog (#choice-dialog): one tappable row per candidate, the shape the
   retired chat modal drew -- podcast artwork beside the title at 48px, a publisher or
   date under it. Every choice opens this dialog (a chat clarifying question, any domain's
   ambiguity, a background browser job's question), over its inline thread or row. */
.choice-list {
  margin: 0;
  padding: 0;
  list-style: none;
  display: flex;
  flex-direction: column;
  gap: 0.4rem;
}

.choice-row {
  display: flex;
  align-items: center;
  width: 100%;
  gap: 0.7rem;
  min-height: 56px;
  padding: 0.4rem 0.6rem;
  color: var(--ink);
  text-align: left;
  background: var(--panel-subtle);
  border: 1px solid var(--card-line);
  border-radius: var(--card-radius);
}

.choice-row:hover,
.choice-row:focus-visible {
  background: var(--panel);
}

/* Bounded like .thread-option-art above, one step larger for the dialog's own room. */
.choice-art {
  width: 48px;
  height: 48px;
  flex: none;
  object-fit: cover;
  border-radius: var(--radius-control);
}

.choice-row-text {
  display: flex;
  flex-direction: column;
  min-width: 0;
  flex: 1 1 auto;
}

.choice-row-label,
.choice-row-detail,
.choice-row-note {
  overflow-wrap: anywhere;
}

.choice-row-label {
  font-size: var(--text-detail-body);
}

.choice-row-detail,
.choice-row-note {
  color: var(--muted);
  font-size: var(--text-detail-meta);
}

/* The phone bottom-sheet rules for this dialog live in the first max-width: 30rem block
   below (grep .choice-dialog): that block must stay the first in the file. */

/* The dialog's own failure line -- .music-proposal-failure's declarations again: it can
   arrive while the dialog is open, sharing the footer row with Cancel. */
.choice-error {
  flex: 1 1 0;
  min-width: 0;
  margin: 0;
  color: var(--error);
  font-size: var(--text-detail-body);
  overflow-wrap: anywhere;
}

.meals-confirmation,
.music-confirmation {
  margin-top: 0.55rem;
}

.meals-confirmation p,
.music-confirmation p {
  margin: 0 0 0.3rem;
  font-size: var(--text-detail-body);
}

.meals-confirmation p:last-child,
.music-confirmation p:last-child {
  margin-bottom: 0;
}

/* The treatment .email-sources already set -- quiet panel, thin left rule -- in the
   shared --line vocabulary every other pane's rule reads; the preferences read as one
   bounded group rather than a bullet list. Plain text lines, not bullets: a preference
   changes only by asking in chat. */
.meals-preference-list {
  margin: 0.35rem 0 0;
  padding: 0.4rem 0.6rem;
  /* A left rule instead of a radius: this is a quoted group inside the pane's flow,
     which is the same shape .email-sources draws. --line-strong, not --line: on the
     sunken ground the inset supplies, the document hairline is invisible, so the rule
     that is the whole point of the treatment would not be drawn at all. */
  border-radius: 0;
  border-left: 2px solid var(--line-strong);
}

.meals-preference {
  margin: 0;
  padding: 0.2rem 0;
  font-size: var(--text-detail-body);
}

/* Programmatic only: focused on every open so screen readers announce the new pane, but
   it is visually hidden and never in the tab order, so a visible ring here would carry no
   information. */
.agenda-title:focus,
.household-facts-title:focus,
.meals-plan-title:focus {
  outline: none;
}

/* The Today pane: the same scroller shape as .household-facts and .meals-plan, but on the
   card-list field rather than the white one, because its entries are things in their own
   right -- a calendar row, an email -- not lines inside one document. The field belongs to
   the scroller, not to the list inside it, which is why --panel is set here. */
.today {
  /* The shared scroller on the card field, with no top inset of its own: this pane's
     own toolbar is the first thing in the scroll, and a gutter above it would read as
     the band having two edges. */
  padding-top: 0;
}

.today[hidden] {
  display: none;
}

/* The header: a centred block sitting directly on the wallpaper, above the card stack --
   the live clock is the pane's visible identity now (see index.html's own comment on why
   the h1 is visually hidden), with the server's own day underneath it. */
.today-header {
  padding: 0 20px 8px;
  text-align: center;
}

/* Tabular-nums so the digits do not shift width minute to minute; the AM/PM suffix (a
   separate element, never baked into this string) is the one thing distinguishing a
   12-hour locale's clock from a 24-hour one's -- see todayClockParts() in chat.js. The
   size is its own custom property, not a literal on font-size, purely so the narrow-
   width media query below can override just that one value. */
.today-clock {
  --today-clock-size: 4.5rem;
  display: block;
  font-family: var(--font-display);
  font-weight: var(--w-display);
  font-size: var(--today-clock-size);
  line-height: 0.9;
  letter-spacing: -0.11em;
  color: var(--ink-heading);
  text-shadow: var(--text-shadow-on-wall);
  font-variant-numeric: tabular-nums;
}

.today-clock-suffix {
  margin-left: 7px;
  font-family: var(--font-body);
  font-weight: var(--w-body);
  font-size: var(--text-detail-meta);
  letter-spacing: 0;
  vertical-align: middle;
}

.today-clock-suffix[hidden] {
  display: none;
}

@media (max-width: 370px) {
  .today-clock {
    --today-clock-size: 4.3125rem;
  }
}

/* (This block used to narrow .today's side padding to 15px, which put Today's cards 2px off
   the 17px --list-gutter every other pane's cards keep at every phone width; the measured
   inset at 320 and 360 was 15 against 17 everywhere else. Only the clock size steps down.) */

.today-date-line {
  margin: 0;
  color: var(--ink-on-wall-muted);
  text-shadow: var(--text-shadow-on-wall);
  font-size: var(--text-detail-meta);
  line-height: 1.3;
}

/* One card per kind, separated by one rhythm the body itself owns. It is a gap rather than
   the adjacent-sibling margin it used to be because the cards are no longer all siblings:
   they sit inside two column wrappers now (see today.js). Below the desktop width those
   wrappers are display: contents, which promotes the cards back to direct flex items of
   this box, so the gap applies between all three exactly as the margin did and the phone's
   stack is unchanged. */
.today-body {
  display: flex;
  flex-direction: column;
  gap: 13px;
  /* No inset of its own: .today is already .app-scroller-field, whose 17px list-gutter
     is the card stack's one gutter. A second inset here doubled it up. */
}

.today-column {
  display: contents;
}

/* The card's own heading: an icon, a title and an optional right-aligned fragment (a
   count, a "View N" link, a duration) -- what the pane's old .app-section-label groups
   became once every card stood on the shared surface rather than the wallpaper. */
.today-card-header {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: 2px 8px;
  margin-bottom: 4px;
}

.today-card-icon {
  display: flex;
  flex: none;
  color: var(--accent);
}

.today-card-icon svg {
  width: 17px;
  height: 17px;
  stroke-width: 2;
}

.today-card-title {
  margin: 0;
  flex: 1 1 auto;
  min-width: 0;
  color: var(--ink);
  font-size: var(--text-detail-meta);
  font-weight: var(--w-title);
}

.today-card-right {
  flex: none;
  color: var(--muted);
  font-size: var(--text-detail-meta);
}

/* The Inbox card's own "View N" link -- a quiet button, not .app-row, so it never reads
   as a fourth item row (see .today-card-rows below). */
.today-card-link {
  display: inline-flex;
  flex: none;
  align-items: center;
  gap: 3px;
  padding: 0;
  color: var(--muted);
  background: transparent;
  border: 0;
  font: inherit;
  font-size: var(--text-detail-meta);
  cursor: pointer;
}

.today-card-link-icon {
  display: flex;
}

.today-card-link-icon svg {
  width: 12px;
  height: 12px;
  stroke-width: 2.25;
}

/* Rows separated by a hairline, no boxes -- the same idiom a list of lines inside one
   document already uses (.app-divider-list), reused rather than reinvented because a
   card's own rows are exactly that: lines in the one card, not bounded entries of their
   own. */
.today-card-rows > .today-row {
  padding: 10px 0;
}

.today-card-rows > .today-row:first-child {
  padding-top: 2px;
}

.today-card-rows > .today-row:last-child {
  padding-bottom: 2px;
}

/* Placement only; the row's look is .app-row's. The row wrapper gives it its padding, so
   the row inside one does not add a second inset of its own. */
.today-open-row {
  padding: 0;
  cursor: pointer;
}

/* Today has no leading time column, and a card here says as little as the day allows: the
   title and the one thing still worth saying beside it share a single line rather than
   taking a row each, so more of the day fits on the screen. They are laid out as a wrapping
   run, not truncated -- a long address moves to a second line rather than losing its tail,
   because nothing on this pane offers the full value anywhere else. A failed item's error
   always takes a line of its own, which is what the 100% basis below is for. */
.today-row-main {
  display: flex;
  flex: 1 1 auto;
  flex-wrap: wrap;
  align-items: baseline;
  column-gap: 0.45rem;
  /* The one rhythm a wrapped run needs, replacing the top margins the stacked column used to
     carry: it applies only between lines, so a context that fits beside the title costs
     nothing, and one that does not sits under it exactly where it used to. */
  row-gap: 0.1rem;
  min-width: 0;
}

.today-row-title,
.today-row-meta {
  min-width: 0;
  overflow-wrap: anywhere;
}

/* .app-row-meta's own top margin is what stacked it; a baseline-aligned run does not want it. */
.today-row-meta {
  margin-top: 0;
}

.today-row-error {
  flex: 1 1 100%;
  overflow-wrap: anywhere;
}

/* A row that carries a quick action lays the control and the opener out on one line, and lets an
   inline failure take a full-width line of its own below -- the same shape .agenda-task uses, for
   the same reason. A row with no control has one child and is unaffected. */
.today-row {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  min-width: 0;
}

/* Every card sits on the same plain ground now (see the shared .app-card rule) -- no
   card on this pane is emphasised over another, the Next Meal card included, unlike the
   single featured surface earlier rounds gave it. */

/* A task row has no 64px lead rail (see .today-row-main), so its checkbox sits at the
   row's own start with the rail's gap after it. The negative touch slack on the other three sides
   hands back the space .agenda-task-checkbox's 44px hit area borrows, so the control still occupies
   exactly the 24px the bare checkbox does. */
.today-row > .agenda-task-checkbox {
  margin: calc(var(--touch-slack) * -1)
    calc(var(--list-lead-gap) - var(--touch-slack))
    calc(var(--touch-slack) * -1)
    calc(var(--touch-slack) * -1);
}

/* Grown from a zero basis rather than .app-row's own width: 100%, for the reason
   .agenda-task-toggle is -- a control shares this line, and a long title must shrink the opener
   rather than wrap it onto a line of its own below. */
.today-row > .today-open-row {
  flex: 1 1 0;
  width: auto;
  /* Without this the opener's automatic minimum is its nowrap content, so a long sender or
     subject pushes the 44px actions onto a second line and past the card's edge. */
  min-width: 0;
  overflow: hidden;
}

/* Every box on a Today row that clips its own text (the opener above, the Inbox text column
   and its two lines, the meal title's line clamp) has padding 0, so its clip edge fell exactly
   on the first glyph's origin and shaved the left overhang off a W, an R, a P or a 5 (found at
   4x: "Water the plants", "Planned ...", "Refill", "Sheet-pan", "5:00 PM"). A 2px lead inside
   the clip, given back by an equal negative margin, moves the edge clear without moving a
   single glyph. The opener is also an .app-row with a rounded corner, and overflow: hidden
   clips to that curve, which cut the first line's top-left and the last line's bottom-left
   corner of the same glyphs (a P's stem foot at 10x); a row of hairline-separated text has no
   visible corner to keep, so the opener's own radius goes. */
.today-row > .today-open-row {
  border-radius: 0;
}

.today-row > .today-open-row,
.today-card-inbox .today-open-row > .today-row-main,
.today-card-inbox .today-row-title,
.today-card-inbox .today-row-meta,
.today-meal-row .today-row-title {
  padding-inline-start: 2px;
  margin-inline-start: -2px;
}

/* The Inbox row stays one line at 320-430px: avatar | text column | chevron inside the
   opener, then the check and overflow buttons, none of which may shrink or wrap. Only a
   failed item's error line (flex-basis 100%) is allowed a second line, so the row wraps
   for that alone. */
.today-card-inbox .today-open-row > .today-row-main {
  flex: 1 1 auto;
  min-width: 0;
  overflow: hidden;
}

.today-card-inbox .today-row-title,
.today-card-inbox .today-row-meta {
  max-width: 100%;
}

/* The same task must read the same weight wherever it appears, and the agenda's
   .agenda-task-title puts it at 500. A task row is told apart here by the one thing only a
   task has: its completion checkbox. :has() is already this stylesheet's idiom for that
   kind of question (see .agenda-day). */
.today-row:has(> .agenda-task-checkbox) .today-row-title {
  font-weight: var(--w-meta);
}

/* An event's own leading time column: a real second column, wide enough for a compact
   time, tabular and in the accent gold -- the one row on this pane dense enough to earn
   a rail of its own rather than a key folded into its title. Two lines, not one -- the
   start time alone on top (never "6:30 AM-7:00 AM", which wrapped mid-time in this
   58px rail) and its period underneath, the reference's own gp-event-time shape. */
.today-row-lead {
  flex: none;
  width: 58px;
  display: flex;
  flex-direction: column;
  gap: 2px;
}

.today-row-lead-time {
  font-variant-numeric: tabular-nums;
  font-size: var(--text-section);
  line-height: var(--leading-detail-tight);
}

.today-row-lead-period {
  color: var(--muted);
  font-size: var(--text-caption);
  font-weight: var(--w-body);
}

/* All-day carries no start time to lead with, so it reads smaller than a timed event's
   own rail -- one gold word rather than a number wide enough to anchor a bigger size. */
.today-row-lead-allday {
  font-size: var(--text-caption);
}

/* The Inbox row's own 32px avatar, the sender's initial over the shared .app-media
   ground (see today.js's itemRow()) -- sits in the same leading slot an event's time
   rail does, but draws its own ground rather than that rail's plain gold text. */
.today-row-avatar {
  display: flex;
  align-items: center;
  justify-content: center;
  flex: none;
  width: 32px;
  height: 32px;
  color: var(--ink-heading);
  font-size: var(--text-detail-meta);
  font-weight: var(--w-title);
}

.today-row-chevron {
  display: flex;
  flex: none;
  align-items: center;
  color: var(--muted);
  margin-left: 4px;
}

.today-row-chevron svg {
  width: 14px;
  height: 14px;
  stroke-width: 2;
}

/* The Inbox card's own visual weight, reversed from every other card's: the sender is
   the name a person actually scans for, so it reads at the title's own size and weight,
   while the subject beneath it is the muted, secondary line -- without renaming which
   element is which, since .today-row-title is still what the row's own accessible name
   and every existing lookup by subject are built on (see today.js's itemRow()). */
.today-card-inbox .today-row-main {
  flex-direction: column;
  flex-wrap: nowrap;
  align-items: stretch;
  row-gap: 1px;
}

/* The reference's own row: sender (14px, ink) on line 1, subject (12px, muted) on line 2,
   each a single ellipsised line. The sender is the row's second child in the DOM (the
   title is what every accessible name and lookup keys on), so it is ordered first. */
.today-card-inbox .today-row-title {
  order: 2;
  display: -webkit-box;
  -webkit-box-orient: vertical;
  -webkit-line-clamp: 2;
  line-clamp: 2;
  overflow: hidden;
  color: var(--muted);
  font-size: var(--text-caption);
  font-weight: var(--w-body);
  line-height: 1.35;
  overflow-wrap: break-word;
  text-align: left;
  white-space: normal;
}

.today-card-inbox .today-row-meta {
  order: 1;
  display: -webkit-box;
  -webkit-box-orient: vertical;
  -webkit-line-clamp: 2;
  line-clamp: 2;
  overflow: hidden;
  color: var(--ink);
  font-size: var(--text-detail-meta);
  font-weight: var(--w-title);
  line-height: 1.35;
  overflow-wrap: break-word;
  text-align: left;
  white-space: normal;
}

/* Dismiss and Hide similar are real actions but not the row's point: two icon-sized 44px
   targets, so the text column keeps the width. */
.today-item-row .dismiss-item,
.today-item-row .hide-similar-item {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  width: var(--touch-min);
  min-width: var(--touch-min);
  height: var(--touch-min);
  padding: 0;
}

.today-item-row .dismiss-item svg,
.today-item-row .hide-similar-item svg {
  width: 16px;
  height: 16px;
}

/* The meal card's own row: no lead rail, so its title starts at the row's own edge like
   every other single-line opener. */
.today-meal-row .today-row-title {
  overflow-wrap: break-word;
  white-space: normal;
}

/* The trailing control keeps its own width and never shrinks; the opener beside it gives way. */
.today-action {
  flex: none;
}

/* At Large, and on a very narrow phone, a text action (Later, Eaten) must not take width from
   the title: the opener asks for a readable measure and the row (already flex-wrap: wrap)
   drops the action onto its own line below it. The action stays, at its full size. */
:root[data-text-size="large"] .today-row:has(> .task-later, > .meal-eaten) > .today-open-row {
  flex: 1 1 12rem;
}

@media (max-width: 374px) {
  .today-row:has(> .task-later, > .meal-eaten) > .today-open-row {
    flex: 1 1 12rem;
  }
}

/* The card's own failure line, below both, exactly where .agenda-task-error sits on a task row. */
.today-action-error {
  flex: 1 1 100%;
  margin-top: 6px;
  overflow-wrap: anywhere;
}

.today-action-error[hidden] {
  display: none;
}

.agenda-strip {
  display: flex;
  flex: none;
  gap: 0.3rem;
  padding: 0.3rem 0.6rem 0.6rem;
  overflow-x: auto;
  overscroll-behavior-x: contain;
  scroll-snap-type: x proximity;
}

/* The strip is the scrolling list's own index; the four-week grid is its own. Hiding it
   needs a rule because display: flex above outranks the hidden attribute. */
.agenda-strip[hidden] {
  display: none;
}

/* The phone strip's own shape (agenda.js toggles this class; the tablet keeps the flat
   ribbon above): not a row of buttons but week-sized pages -- the selected week with a
   swipeable week either side -- moved a whole week per swipe. column-gap equals the strip's
   own 0.6rem inline padding, so consecutive pages sit exactly one strip-width apart: a
   neighbouring page never peeks in at an edge, and the last page is reachable exactly.
   scroll-snap-stop: always is what makes one swipe one week rather than a run of them, and
   the scrollbar is hidden because the pages themselves are the affordance. */
.agenda-strip-paged {
  column-gap: 0.6rem;
  scroll-snap-type: x mandatory;
  scroll-padding-inline: 0.6rem;
  scrollbar-width: none;
}

.agenda-strip-paged::-webkit-scrollbar {
  display: none;
}

.agenda-strip-week {
  display: flex;
  flex: 0 0 100%;
  gap: 0.3rem;
  min-width: 0;
  scroll-snap-align: start;
  scroll-snap-stop: always;
}

/* The strip's own .agenda-date rule sets min-width: 2.6rem, which seven buttons across one
   page cannot always afford; a page's buttons share the width the page has instead. */
.agenda-strip-week .agenda-date {
  min-width: 0;
}

/* Large text, or under 360px: a date never squeezes below 2.75rem (a 44px target); the week
   scrolls sideways instead. Paging between weeks is unchanged. */
:root[data-text-size="large"] .agenda-strip-week {
  overflow-x: auto;
  overscroll-behavior-x: contain;
}

:root[data-text-size="large"] .agenda-strip-week .agenda-date {
  min-width: 2.75rem;
}

@media (max-width: 359px) {
  .agenda-strip-week {
    overflow-x: auto;
    overscroll-behavior-x: contain;
  }

  .agenda-strip-week .agenda-date {
    min-width: 2.75rem;
  }
}

/* Directly on the wallpaper now, not a card -- white ink and the shared soft shadow, the
   same on-wall pair the header text above it already reads through. */
.agenda-date {
  display: flex;
  flex: 1 1 0;
  flex-direction: column;
  align-items: center;
  gap: 0.2rem;
  min-width: 2.75rem;
  min-height: var(--touch-min);
  padding: 0.35rem 0.2rem;
  color: var(--ink-heading);
  text-shadow: var(--text-shadow-on-wall);
  background: transparent;
  border: 1px solid transparent;
  border-radius: var(--radius-control);
  font: inherit;
}

.agenda-strip-scrollable .agenda-date {
  flex: 0 0 3.25rem;
  scroll-snap-align: center;
}

.agenda-date:hover,
.agenda-date:focus-visible {
  border-color: var(--line);
}

.agenda-date-weekday {
  color: var(--ink-on-wall-muted);
  font-size: var(--text-caption);
  letter-spacing: 0.02em;
  text-transform: uppercase;
}

.agenda-date-day {
  font-size: var(--text-detail-title);
  font-weight: var(--w-title);
  font-variant-numeric: tabular-nums;
}

/* Not the accent -- a pip is a mark, not a datum, and the strip already has one accent
   (the selected/today state below). */
.agenda-date-dot {
  width: 4px;
  height: 4px;
  border-radius: 50%;
  background: var(--faint);
}

.agenda-date-dot[hidden] {
  visibility: hidden;
}

/* Ink, and the two children below it, on the accent fill -- not the page ground. All
   three used to read --page, which happened to work only because --page was white. The
   on-wall text-shadow every other date in the strip carries would only muddy dark ink on a
   light gold fill, so the selected pill drops it. */
.agenda-date-today {
  color: var(--on-accent);
  text-shadow: none;
  background: var(--accent);
  border-color: var(--accent);
}

.agenda-date-today .agenda-date-weekday {
  color: color-mix(in srgb, var(--on-accent) 80%, transparent);
}

.agenda-date-today .agenda-date-dot {
  background: var(--on-accent);
}

/* No rule here on purpose. A weekend day used to carry a few-percent tint of its own, which
   a screenshot review read as a boxed/dim cell sitting beside plain ones -- only the
   selected/today day is meant to carry any ground at all (the gold pill above); every other
   day, weekend or not, stays directly on the wallpaper and is told apart by its dot and its
   weekday label alone. */

/* Marks the date the strip is following at the top of the list -- a border ring, not a
   text-colour change, so it reads over the weekend tint and never touches contrast in the
   light theme. Excluded when it is also today so the filled accent state always wins. */
.agenda-date[aria-current="date"]:not(.agenda-date-today) {
  border-color: var(--accent);
}

/* The strip always draws a whole Sunday-to-Saturday week, so in the current week it draws
   days that are already gone. They stay drawn -- the week's shape is what the strip is for,
   and a week that grew from three columns on Wednesday to seven on Sunday would be a
   stranger thing than a greyed day. They are `disabled` in the markup, which is what
   actually stops the navigation; this only makes them look the way they now behave. Both
   spans are dimmed rather than the button, so the weekday caption keeps its own relationship
   to the number above it, and the today/weekend/current states below can still be read. */
.agenda-date[data-agenda-past] .agenda-date-weekday,
.agenda-date[data-agenda-past] .agenda-date-day {
  color: color-mix(in srgb, var(--muted) 55%, var(--page));
}

.agenda-date[data-agenda-past]:hover,
.agenda-date[data-agenda-past]:focus-visible {
  border-color: transparent;
}

.agenda-status,
.agenda-failure {
  margin: 0 var(--list-gutter) 0.3rem;
  font-size: var(--text-detail-body);
  text-shadow: var(--text-shadow-on-wall);
}

.agenda-status {
  color: var(--ink-on-wall-muted);
}

.agenda-failure {
  color: var(--error);
}

/* The shared scroller, with no vertical padding of its own: the agenda's own full-bleed
   inset used to carry both, and now that .agenda lives inside .chat's own padded box
   (see .chat above) that inset would double up. */
.agenda-days {
  flex: 1 1 0;
  padding-block: 0;
  touch-action: pan-y;
}

/* Scroll-trigger markers for the top/bottom IntersectionObserver, not day content: never
   display: none (the observer would stop firing), and normally show nothing. On a failed
   range request at that end, the retry button inside becomes visible with a quiet,
   retryable message (clicking it retries that end's request). */
.agenda-edge {
  display: block;
  width: 100%;
  min-height: 1px;
  margin: 0;
  padding: 0;
}

.agenda-edge-retry {
  display: block;
  width: 100%;
  margin: 0;
  padding: 0;
  color: var(--ink-on-wall-muted);
  text-align: center;
  text-shadow: var(--text-shadow-on-wall);
  background: transparent;
  border: 0;
  font: inherit;
  font-size: var(--text-detail-body);
}

.agenda-edge-retry:not([hidden]) {
  padding: 0.5rem 0;
}

/* The hidden attribute's UA display: none was being overridden by the block rule above,
   which left both sentinels rendering as a 44px-tall empty button. */
.agenda-edge-retry[hidden] {
  display: none;
}

/* A group of cards, not a filled slab: the day itself carries no border or background of its
   own, and 20px of its bottom padding is the gap between one day's group and the next. The
   6px inline inset is on EVERY day, not only the tinted ones, so a weekend's band can be a
   little wider than its cards without a weekend day box being a different shape from a weekday
   one -- which is what made a geometry check report a 6px offset that never existed. */
.agenda-day {
  padding: 0.75rem 6px 20px;
  margin-inline: -6px;
}

.agenda-day[data-weekend="true"] {
  background: color-mix(in srgb, var(--accent) 4%, transparent);
  border-radius: var(--card-radius);
}

/* Directly on the wallpaper, not a card -- two pieces (see renderAgendaDay's own
   headingLabel/headingDate spans), the label on the left and the full date pushed to the
   far right, the reference's own gp-day-heading shape. */
.agenda-day-heading {
  display: flex;
  align-items: baseline;
  justify-content: space-between;
  gap: 8px;
  margin: 0 0 12px;
  padding-top: 8px;
  color: var(--ink-heading);
  text-shadow: var(--text-shadow-on-wall);
  font-size: var(--text-detail-title);
  font-weight: var(--w-display);
}

.agenda-day-heading-date {
  flex: none;
  color: var(--ink-on-wall-muted);
  font-size: var(--text-detail-meta);
  font-weight: var(--w-body);
}

/* Today's heading is NOT the accent, and that is rule 01 doing its job: the scrolling agenda
   is one region and it already spends its single accent on the Now line, which is the thing a
   person is actually looking for. Three accent marks for "today" in one viewport -- this
   heading, the week strip's today ring, and the Now line -- is what the restyle was called to
   fix.

   Nothing is lost by making it neutral, because the state was never carried by colour alone
   (rule 07): the heading's own text begins with the word "Today", the strip highlights the
   day, and the Now line sits inside this group. A reader eight feet away who cannot tell the
   accent from any other dark colour still reads the word. */
.agenda-day[data-agenda-today="true"] .agenda-day-heading {
  color: var(--ink-heading);
}

/* A day with nothing in it is a line of scrolling, not a block: the heading's usual 12px of
   clearance is there to separate it from a stack of cards, and there are none to separate it
   from. Scrolling a fortnight of empty days is the common case this keeps cheap. */
.agenda-day:has(> .agenda-day-empty) {
  padding-bottom: 12px;
}

.agenda-day:has(> .agenda-day-empty) .agenda-day-heading {
  margin-bottom: 2px;
}

/* Focused programmatically after a strip tap or Today, unlike .agenda-title this is a
   useful landing cue, so keep a quiet ring rather than suppressing it outright. */
.agenda-day-heading:focus {
  outline: none;
}

.agenda-day-heading:focus-visible {
  outline: 2px solid var(--ink-on-wall-muted);
  outline-offset: 2px;
}

.agenda-day-empty {
  margin: 0;
  color: var(--ink-on-wall-muted);
  text-shadow: var(--text-shadow-on-wall);
  font-size: var(--text-detail-body);
}

.agenda-allday-group {
  margin-bottom: var(--card-gap);
}

/* Compact label in the row's own rail, not a section banner: an all-day title now aligns
   on the same x as every other row's title. The word itself is .app-key text now (see
   renderAgendaEventRow in agenda.js, and .agenda-event-time.agenda-allday-label .app-key's
   own size below) -- this rule only carries what the wrapper still owns. */
.agenda-allday-label {
  letter-spacing: 0.02em;
  white-space: normal;
}

/* Directly on the wallpaper, not a card -- these three quiet buttons (this one, the
   earlier-today disclosure below, and .agenda-completed-toggle further down) sit as
   siblings of the day's own cards rather than inside one, so the shared .app-quiet-button
   ink (tuned for a card ground) needs the on-wall pair instead. */
.agenda-allday-more {
  align-self: flex-start;
  color: var(--ink-on-wall-muted);
  text-shadow: var(--text-shadow-on-wall);
}

.agenda-earlier {
  display: flex;
  flex-direction: column;
  margin-bottom: var(--card-gap);
}

/* A collapsed Earlier today that ends the day must not leave a blank band before the next
   heading: the group's own bottom margin goes away whenever it is the day's last content,
   and the day's 20px of bottom padding goes away too -- but only while the disclosure is
   collapsed, because only then is there nothing under the toggle to separate from what
   follows. The next day's own 0.75rem top padding plus its heading's 8px padding-top still
   leave a 20px gap between the toggle and that heading, the same section gap a day with no
   earlier group already has before it. Expanded, the list is the day's real content again
   and the day keeps its bottom padding (only the group margin stays dropped, since the
   padding itself now provides that separation). */
.agenda-earlier:last-child {
  margin-bottom: 0;
}

.agenda-day:has(> .agenda-earlier:last-child > .agenda-earlier-toggle[aria-expanded="false"]) {
  padding-bottom: 0;
}

/* The reference's own <summary> reads at 12px with a native disclosure triangle beside it
   (see .gp-earlier); a plain button draws neither on its own, so both are stated here --
   --text-caption for the size, and .agenda-earlier-chevron below for the shape a screenshot
   review found missing entirely. --touch-min, not the shared quiet-button's own 32px floor:
   a disclosure that hides or reveals a whole group of events is worth the app's full resting
   target, the same call .app-primary-button's own comment makes for an "intentional action". */
.agenda-earlier-toggle {
  display: inline-flex;
  align-self: flex-end;
  align-items: center;
  gap: 0.3rem;
  min-height: var(--touch-min);
  color: var(--ink-on-wall-muted);
  text-shadow: var(--text-shadow-on-wall);
  font-size: var(--text-caption);
}

.agenda-earlier-chevron {
  display: flex;
  flex: none;
  align-items: center;
}

.agenda-earlier-chevron svg {
  width: 12px;
  height: 12px;
  stroke-width: 2.5;
}

/* The one place in the pane the accent earns a label rather than just a rule: "now" is a
   fact about the whole day, not a datum inside one row, so it reads at the same
   var(--w-display) the ladder wires everywhere else -- the W4 slice that wired up
   the ladder points this at the token like every other weight in the file. Sentence case,
   not the tracked all-caps a shorter word could afford -- agenda.js's own label already
   spells the time out in full ("Now · 11:37 AM"), and forcing that upper-case read as a
   clipped "11:37 AM" missing its trailing M in a screenshot review. */
.agenda-now-marker {
  display: flex;
  align-items: center;
  gap: 0.4rem;
  margin: 8px 0;
  color: var(--now-ink);
  font-size: var(--text-detail-meta);
  font-weight: var(--w-display);
  text-shadow: var(--text-shadow-on-wall);
}

.agenda-now-marker::before {
  content: '';
  flex: 1 1 auto;
  height: 1px;
  background: var(--now-ink);
}

/* Earlier, expanded: one plain list of what is done or passed -- a single card
   (.app-card) of single-line rows split by hairlines (.app-divider-list), never the gaps or
   spacing of the timeline. */
.agenda-earlier-list[hidden] {
  display: none;
}

.agenda-earlier-row {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: 0.5rem;
  padding: 0.25rem 0;
  font-size: var(--text-detail-body);
}

.agenda-earlier-time {
  flex: none;
  color: var(--muted);
  font-size: var(--text-detail-meta);
  font-variant-numeric: tabular-nums;
}

.agenda-earlier-title {
  flex: 1 1 0;
  min-width: 0;
  margin: 0;
  overflow-wrap: break-word;
  white-space: normal;
}

.agenda-earlier-check {
  display: flex;
  flex: none;
  color: var(--muted);
}

.agenda-earlier-check svg {
  width: 14px;
  height: 14px;
  stroke-width: 2.5;
}

.agenda-earlier-list > .agenda-now-marker {
  /* The Earlier list is an .app-divider-list; the now-marker is a line, not an entry, so it
     takes no hairline of its own. */
  border-top: 0;
  margin: 8px 0;
}

.agenda-timed-list {
  display: flex;
  flex-direction: column;
  gap: 0;
}

.agenda-timed-list[hidden] {
  display: none;
}

/* Adjacent-sibling only: a row that instead follows a gap or the now-marker keeps that
   element's own computed spacing and gains nothing extra here. */
.agenda-timed-list > .agenda-event + .agenda-event,
.agenda-timed-list > .agenda-task + .agenda-event,
.agenda-timed-list > .agenda-event + .agenda-task,
.agenda-timed-list > .agenda-task + .agenda-task {
  margin-top: var(--card-gap);
}

/* A card belonging to exactly one person carries an ACCENT of that person's own saved
   colour, never a tinted or translucent background: the card is the one opaque surface
   between its content and the wallpaper (see .app-card's own comment above), and a wash
   mixed on top of it -- even at a calibrated few percent -- reads close enough to the
   wallpaper's own blue to look like the card had gone translucent, which is what a
   screenshot review actually caught here. The accent instead is a keyline on the card's
   own leading edge -- --keyline wide, solid --person-color -- with the card's own
   --card-pad-x reduced by that same width so the line adds no extra inset of its own.
   agenda.js decides *when* -- one person, and a colour that is actually saved -- and hands
   the colour over as --person-color; multi-person and unassigned cards match neither
   selector and stay exactly as they were, since a card has one keyline and choosing one of
   two people's colours would say something untrue -- their .person-dot chips still carry a
   colour each. The four-week grid's own compact chip keeps the calibrated wash this rule
   used to draw (see .agenda-chip[data-person-tint] below for those ΔE figures) -- a chip has
   no leading edge roomy enough for a keyline to read, and it is not the surface a screenshot
   review flagged. */
.agenda-event[data-person-tint] {
  border-left: var(--keyline) solid var(--person-color);
  padding-left: calc(var(--card-pad-x) - var(--keyline));
}

.agenda-event[data-person-tint]:hover {
  border-left-color: color-mix(in srgb, var(--person-color) 70%, var(--card-line));
}

.agenda-gap {
  height: var(--agenda-gap-px, 0px);
  display: flex;
  align-items: flex-end;
  padding-left: calc(var(--list-gutter) + var(--list-lead) + var(--list-lead-gap));
  overflow: hidden;
  color: var(--ink-on-wall-muted);
  text-shadow: var(--text-shadow-on-wall);
  font-size: var(--text-detail-meta);
}

.agenda-gap[hidden] {
  display: none;
}

/* Placement only: the card now supplies the padding, so the toggle's own copy of it
   would double up. */
.agenda-event-toggle,
.agenda-task-toggle {
  padding: 0;
  gap: var(--list-lead-gap);
  border-radius: 0;
}

/* A hover wash inside a card that already has its own bounded edge reads as a defect,
   not a cue -- the card itself takes the focus ring instead. */
.agenda-event-toggle:hover,
.agenda-event-toggle:focus-visible,
.agenda-task-toggle:hover,
.agenda-task-toggle:focus-visible {
  background: transparent;
}

.agenda-event-toggle:focus-visible,
.agenda-task-toggle:focus-visible {
  outline: 2px solid var(--accent);
  outline-offset: 4px;
}

/* The rail: start time over its own AM/PM, never side by side -- a column is what keeps the
   rail at its fixed width instead of letting "7:30 PM" run wider than the time itself needs.
   An event's end time no longer lives here at all (see .agenda-event-meta's own "Until" text
   in agenda.js) -- two numbers stacked in one rail read as "2p over 3p", a pair of data
   points rather than one time and its unit. 58px, not --list-lead (76px): that token is sized
   for a single-line rail like "10:00 PM" written whole, but this one stacks the time over its
   own period on two lines, the same shape Today's own .today-row-lead uses at the same 58px
   -- wide enough for "12:30" on its own line without clipping, with nothing riding beside it
   that needs the extra 18px. A timed task carries no rail of its own any more (see
   .agenda-task-time-meta above): it takes the same checkbox-led shape the Tasks card gives
   every task, so only an event still reads this rule. */
/* Never the accent -- a time is a datum, not a selection, and this app has no other clock
   on a card to compete with (rule 02: times/dates/counts/durations are --muted,
   tabular-nums -- the platform face now, not a monospace one: see the styles.css header
   comment, "household labels read in the platform font"). */
.agenda-event-time {
  display: flex;
  flex: 0 0 58px;
  flex-direction: column;
  color: var(--muted);
  font-size: var(--text-detail-body);
  line-height: var(--leading-detail-tight);
  white-space: nowrap;
  font-variant-numeric: tabular-nums;
}

/* The reference's own gp-event-time hierarchy, and the same shape Today's own lead column
   reads (see .today-row-lead-time): the start time reads larger than the row's own body text
   (still .app-key's accent-tinted ink, not the full solid accent -- rule 02 reserves that for
   a press/selection), with its AM/PM beneath it a size down and quieter still. An all-day
   event's own "All day" word is this same .app-key span with no second line beneath it, so it
   takes the smaller size on its own -- the chained selector beats the plain one above on
   specificity, not on source order, so it holds regardless of where either rule is edited. */
.agenda-event-time .app-key {
  font-size: var(--text-section);
}

.agenda-event-time.agenda-allday-label .app-key {
  font-size: var(--text-caption);
}

.agenda-event-time-period {
  color: var(--muted);
  font-size: var(--text-caption);
  font-weight: var(--w-body);
}

.agenda-event-main {
  flex: 1 1 0;
  min-width: 0;
  overflow-wrap: break-word;
}

/* At Large on a phone the 58px rail cannot hold the time without clipping, and widening it
   would take the width the title needs. The row becomes one column instead: the time (with
   its period beside it) on top, the title and meta below at the card's full width. */
@media (max-width: 47.99rem) {
  :root[data-text-size="large"] .agenda-event-toggle {
    flex-direction: column;
    align-items: flex-start;
    gap: 0.15rem;
  }

  :root[data-text-size="large"] .agenda-event-time {
    flex: none;
    flex-direction: row;
    align-items: baseline;
    gap: 0.3rem;
  }

  :root[data-text-size="large"] .agenda-event-main {
    flex: none;
    width: 100%;
  }
}

.agenda-event-detail {
  margin-top: 8px;
}

.agenda-event-detail[hidden] {
  display: none;
}

.agenda-event-detail p {
  margin: 0 0 0.3rem;
}

.agenda-event-detail p:last-child {
  margin-bottom: 0;
}

/* The four-week grid: the wide-screen Calendar. The phone keeps the scrolling list of day
   sections above; #agenda-grid stands the same 28 dates a week strip used to index in seven
   weekday columns by four week rows, so a month of the household's plans is read at a glance
   instead of scrolled through. It stays hidden until agenda.js makes it the active layout,
   and everything below is the grid's own geometry -- the cells and chips inside it are built
   there and carry no measures of their own beyond these. */

.agenda-grid {
  display: flex;
  flex-direction: column;
  flex: 1;
  min-height: 0;
  gap: 0.2rem;
  padding-top: 0.25rem;
}

/* For the same reason .agenda[hidden] needs one: display: flex above would otherwise beat
   the hidden attribute's own display: none. */
.agenda-grid[hidden] {
  display: none;
}

/* Today reads as a secondary action beside the title (see .app-top-button), pushed to the
   header's far edge the same way .app-page-header's own flex row already pushes a second
   title-line item -- margin-left: auto rather than a wrapper div, since the header's other
   panes already lay a title and a subtitle out as direct flex children of one row. */
#agenda-today {
  margin-left: auto;
}

/* The month label and the week-step chevrons that flank it, sharing the header's own
   subtitle row (an .app-page-header-subtitle composed onto a wrapping div, the same idiom
   Stocks' own two-line subtitle uses) rather than the grid-only toolbar they used to sit
   in. The month label is reachable on every layout now; the chevrons show in the four-week
   grid and, in the narrow list layout, only for a fine pointer (see agenda.js's
   applyAgendaLayout) -- a mouse or keyboard has no swipe, where a touch phone pages the
   strip itself and keeps its header clear. */
.agenda-subtitle-line {
  align-items: center;
  gap: 0.4rem;
  margin-top: -0.25rem;
  line-height: 1.3;
}

.agenda-month-label {
  flex: 0 1 auto;
  min-width: 0;
  margin: 0;
  font-size: var(--text-caption);
}

/* The 44px touch target stays, but the negative block margin stops it from adding its
   height to the header row: the label's own line sets the row's height. */
.agenda-week-step {
  display: grid;
  flex: none;
  place-items: center;
  width: 44px;
  height: 44px;
  margin-block: -12px;
  padding: 0;
  color: inherit;
  background: transparent;
  border: 0;
  border-radius: var(--radius-control);
}

.agenda-week-step[hidden] {
  display: none;
}

.agenda-week-step svg {
  width: 17px;
  height: 17px;
  stroke-width: 1.8;
}

.agenda-week-step:hover,
.agenda-week-step:focus-visible {
  background: var(--top-button-ground);
}

.agenda-grid-weekdays {
  display: grid;
  grid-template-columns: repeat(7, minmax(0, 1fr));
  column-gap: 0.25rem;
}

.agenda-grid-weekday {
  margin: 0;
  color: var(--muted);
  font-size: var(--text-detail-meta);
  font-weight: var(--w-title);
  letter-spacing: 0.05em;
  text-align: center;
  text-transform: uppercase;
}

.agenda-grid-weeks {
  display: grid;
  grid-template-columns: repeat(7, minmax(0, 1fr));
  grid-auto-rows: minmax(0, 1fr);
  gap: 0.25rem;
  flex: 1;
  min-height: 0;
}

/* A week row is the role="row" wrapper accessibility needs, not a box the grid should see:
   display: contents leaves its seven cells as the grid's real items, one to a column. */
.agenda-week {
  display: contents;
}

.agenda-cell {
  display: flex;
  flex-direction: column;
  gap: 3px;
  min-width: 0;
  min-height: 0;
  overflow: hidden;
  padding: 6px;
  background: var(--panel-subtle);
  border: 1px solid var(--card-line);
  border-radius: var(--card-radius);
  text-align: left;
  cursor: pointer;
  transition: transform var(--motion-press) ease;
}

/* The one pressable card in the grid gets the same brief press feedback every other control
   does -- a past cell is excluded, the same guard its hover wash above uses, since it is
   inert and nothing should suggest a tap does anything. */
.agenda-cell:not([data-agenda-past]):active {
  transform: scale(0.98);
}

@media (prefers-reduced-motion: reduce) {
  .agenda-cell:active {
    transform: none;
  }
}

/* Every cell in the grid is the same opaque surface (--panel-subtle, the base rule above) --
   cards are equal, and a weekend column is not a second surface. This used to mix the accent
   4% into `transparent` rather than into the cell's own panel colour, which read as a
   translucent wash showing the wallpaper through it while the weekday cells beside it stayed
   solid. Nothing distinguishes a weekend cell now beyond what the date itself already says. */

/* Today's cell takes the accent the day heading takes in the list: the border says which
   box is today, and its date number says it again in the colour the heading uses. A soft
   accent ground, the same tint the tab bar's current display takes, says it a third way
   without turning the cell into a solid accent block. */
.agenda-cell[data-agenda-today="true"] {
  background: var(--accent-soft);
  border-color: var(--accent);
}

.agenda-cell[data-agenda-today="true"] .agenda-cell-day {
  color: var(--accent);
}

/* A date before today is inert rather than broken: the same dimmed date colour the strip's
   past days use, on the same --panel-subtle surface every other cell carries (the base rule
   above) -- a past cell used to sit on --panel instead, a different, lighter surface that
   read as a translucent wash beside the weekday cells around it. Muted text is the only cue
   a past day gets now; there is no pointer to invite a tap that does nothing. */
.agenda-cell[data-agenda-past="true"] {
  cursor: default;
}

.agenda-cell[data-agenda-past="true"] .agenda-cell-day {
  color: color-mix(in srgb, var(--muted) 55%, var(--page));
}

/* A quiet hover wash so a cell reads as the target it is: the same wash
   .agenda-event-toggle:hover gives a row in the base rule (var(--panel)), not the
   transparent override the agenda's own card rows take below -- a cell is the bounded box
   itself, not a row inside one. A past cell is excluded: it is inert, and a hover wash on
   it would invite a tap that does nothing. */
.agenda-cell:not([data-agenda-past]):hover {
  background: var(--panel);
}

/* The quiet ring the day heading keeps, drawn inside the cell: a positive offset would run
   into the grid gap and be clipped by the neighbours. */
.agenda-cell:focus {
  outline: none;
}

.agenda-cell:focus-visible {
  outline: 2px solid var(--muted);
  outline-offset: -2px;
}

/* A ring is a colour, and the date number underlining is the part of focus that is not:
   it names the day the keyboard is on even where the ring is thin. */
.agenda-cell:focus .agenda-cell-day {
  text-decoration: underline;
  text-underline-offset: 2px;
}

.agenda-cell-date {
  display: flex;
  align-items: baseline;
  gap: 0.3rem;
  flex: none;
}

.agenda-cell-day {
  font-size: var(--text-detail-body);
  font-weight: var(--w-title);
}

.agenda-cell-month {
  font-size: var(--text-detail-meta);
  font-weight: var(--w-title);
  color: var(--muted);
  text-transform: uppercase;
  letter-spacing: 0.04em;
}

.agenda-cell-chips {
  /* The chip fit is measured against this box's clientHeight, so it has to *be* the space the date line leaves (flex: 1), not its own content's height. */
  display: flex;
  flex-direction: column;
  gap: 3px;
  flex: 1;
  min-height: 0;
  overflow: hidden;
}

.agenda-chip {
  display: flex;
  align-items: flex-start;
  gap: 0.3rem;
  min-width: 0;
  /* A touch more room than the 1px 4px this used to carry, so a two-line label breathes
     inside its clamp -- bought inside the existing +N more counting mechanism (a slightly
     taller chip means one fewer fits a cell, and the count says so), never by cutting a
     day's content quietly. */
  padding: 2px 5px;
  border-radius: var(--radius-control);
  background: var(--panel);
  font-size: var(--text-detail-meta);
  line-height: var(--leading-detail-tight);
}

/* fitAgendaGridChips (agenda.js) trims an overflowing chip by hiding it; display: flex
   above would otherwise beat the hidden attribute, the same trap .agenda-grid[hidden]
   exists for. */
.agenda-chip[hidden] {
  display: none;
}

.agenda-chip-title {
  min-width: 0;
  flex: 1 1 auto;
  color: var(--ink);
  display: -webkit-box;
  -webkit-line-clamp: 2;
  line-clamp: 2;
  -webkit-box-orient: vertical;
  overflow: hidden;
  white-space: normal;
  word-break: break-word;
}

/* A forced-narrow kiosk cell (agenda.js sets body.agenda-grid-narrow once, see AGENDA_FORCE_GRID)
   is too short for a two-line clamp to fit without the chip box clipping it; one line with an
   ellipsis is the same truncate-cleanly pattern .agenda-cell-empty already uses. */
body.agenda-grid-narrow .agenda-chip-title {
  -webkit-line-clamp: 1;
  line-clamp: 1;
}

/* Never the accent -- same rule the scrolling list's own rail keeps (rule 02). */
.agenda-chip-time {
  flex: none;
  color: var(--muted);
  font-variant-numeric: tabular-nums;
}

.agenda-chip-mark {
  flex: none;
  color: var(--muted);
}

/* The one place in the app that still washes a single person's colour across a whole
   surface rather than drawing a keyline: a compact grid chip has no leading edge roomy
   enough for a keyline to read at a glance, so it keeps the tint the scrolling list's own
   .agenda-event[data-person-tint] carried before that rule moved to a keyline (see its own
   comment).

   12% is calibrated, not guessed -- measured as the CIELAB ΔE of the mix against the base
   surface (weakest hue ΔE 7.0, strongest ΔE 9.7, resulting L* 94). The percentage rose from
   an old 8% because the palette's own chroma dropped: an old, highly saturated red read as
   a clear tint at 8%, but these hues are deliberately less saturated, so the same 8% reads
   weaker and needs a larger mix to hold the same perceived tint. The chip is still a
   white/near-white chip with a tint, not a coloured one -- L* holds at 94, so the title
   still stays --ink on a surface that reads as white at a glance. 16% (ΔE 9.5-12.6, L*
   91-92) was rejected for crossing into a coloured chip. Two or more people take no wash at
   all (see .agenda-chip[data-person-edge] below) -- a card/chip has one background, and
   blending several owners' colours into one tint would say something untrue about whose the
   event is. */
.agenda-chip[data-person-tint] {
  background: color-mix(in srgb, var(--person-color) 12%, var(--panel-subtle));
}

/* Two or more people: no per-person dot, no shared wash (blending several owners' colours
   into one tint would say something untrue about whose the event is -- the same reasoning
   the single-person wash rule above states). --person-edge is a hard-banded linear-gradient
   built in JS, one band per named colour; painted only 3px wide so it costs the chip no
   width a row of dots would have taken. */
.agenda-chip[data-person-edge] {
  background-image: var(--person-edge);
  background-repeat: no-repeat;
  background-position: left center;
  background-size: 3px 100%;
  padding-left: 8px;
}

.agenda-chip-done .agenda-chip-title {
  text-decoration: line-through;
  color: var(--muted);
}

/* The count line is a <p>: without this reset its default margin pushes the count out of
   the cell it is counting for. nowrap + ellipsis is the same truncate-cleanly pattern
   .agenda-cell-empty uses below: without it, a narrow cell wraps "+2 more" onto a second
   line, which then costs the chips box twice the height it actually needs. */
.agenda-cell-more {
  margin: 0;
  flex: none;
  color: var(--muted);
  font-size: var(--text-detail-meta);
  font-weight: var(--w-title);
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
}

.agenda-cell-empty {
  margin: 0;
  color: var(--muted);
  font-size: var(--text-detail-meta);
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
}

/* The ordinary (non-wall) grid layout's own Today panel: not body.wall's own
   #agenda-wall-today below, which has its own geometry, its own width variable and its own
   tightened type -- this one is a plain flex column of the grid's own ordinary
   --text-detail-* type by default (the forced-narrow kiosk's stacked shape), and only
   becomes a side column at the desktop breakpoint (see the body:not(.wall) rule inside the
   desktop media block further down, scoped away from the wall so its own #agenda
   grid-template-columns is never touched by this). */
/* flex: none, not the default auto: .agenda-grid-weeks beside this takes flex: 1 and would
   otherwise claim the panel's own space too under any height pressure, shrinking it toward
   zero while its un-clipped content kept painting at full size -- a real headless-Chromium
   screenshot at 390x844 (?agendaGrid=1) caught exactly that: the Tasks heading rendered on
   top of the Events list instead of below it. flex: none keeps this box at the height its
   own content (bounded below) actually asks for, so the two never compete for the same
   pixels again. */
.agenda-today-panel {
  display: flex;
  flex-direction: column;
  flex: none;
  min-height: 0;
  gap: 0.4rem;
}

.agenda-today-panel[hidden] {
  display: none;
}

/* Stacked below the weeks (the default, and the forced-narrow kiosk's own shape): bounded
   rather than left to grow with however many entries today holds, so a dense today cannot
   push the grid itself out of the viewport the way an uncapped panel would. Scrolls its own
   overflow instead of clipping it silently. vh, not a parent-relative %: this box's own
   parent (.agenda-today-panel) sizes to its content (flex: none, no explicit height), and a
   percentage height against a parent with no definite height of its own computes to none per
   the CSS spec -- the max-height silently did nothing at all until this changed, which is
   what let the overflow above happen in the first place. Reset to a full column's height at
   the desktop breakpoint below, where the panel sits beside the grid instead of under it. */
.agenda-today-panel-body {
  display: flex;
  flex-direction: column;
  gap: 0.6rem;
  max-height: 35vh;
  min-height: 0;
  overflow-y: auto;
}

.agenda-today-panel-title {
  flex: none;
  margin: 0;
  color: var(--ink-heading);
  text-shadow: var(--text-shadow-on-wall);
  font-size: var(--text-detail-title);
  font-weight: var(--w-title);
}

/* The panel's own Events/Tasks section labels sit on the wallpaper too, not a card. */
.agenda-today-panel-section .app-section-label {
  color: var(--ink-on-wall-muted);
  text-shadow: var(--text-shadow-on-wall);
}

.agenda-today-panel-section {
  display: flex;
  flex-direction: column;
  gap: 0.3rem;
  min-height: 0;
}

.agenda-today-panel-list {
  display: flex;
  flex-direction: column;
  gap: 3px;
  min-height: 0;
}

/* A panel row states its entry's title in full, the same reason
   body.wall .agenda-wall-today-row .agenda-chip-title does for the wall's own column: this
   panel has no cramped cell to clamp a title for. A separate rule rather than reusing that
   one, because that one is scoped under body.wall and would never reach this panel, and
   because this panel keeps the grid chip's ordinary --text-detail-meta size rather than the
   wall's own tightened px values -- the "ordinary presentation-layer type" the task asked
   for is exactly the base .agenda-chip rule already gives it once the clamp is lifted. */
.agenda-today-panel-row .agenda-chip-title {
  display: block;
  -webkit-line-clamp: unset;
  line-clamp: unset;
  overflow: visible;
  white-space: normal;
}

/* The day dialog reuses a whole day section, whose 20px of bottom padding is the gap
   between one day and the next in the scrolling list; here there is no next day, and
   .app-dialog-body already carries the scroll. */
.agenda-day-body .agenda-day {
  padding-bottom: 0;
}

/* The day dialog's failure line, styled exactly as .recipe-error is: a refresh that fails
   while the modal is open must be said inside the dialog, where it can be read, and
   .recipe-error is already the shape a dialog footer's status line takes. */
.agenda-day-error {
  flex: 1 1 100%;
  margin: 0;
  color: var(--error);
  font-size: var(--text-detail-body);
  overflow-wrap: anywhere;
}

/* The TV keeps the Small sizes whatever this device's stored text size is. */
body.wall {
  --text-detail-body: 1rem;
  --text-detail-meta: 0.875rem;
  --text-detail-title: 1.125rem;
  --text-section: 1.0625rem;
  --text-page-title: 1.875rem;
  --text-item: var(--text-detail-body);
  --text-support: var(--text-detail-meta);
  --text-caption: 0.75rem;
  --text-control: 1rem;
  --text-dock-label: 0.875rem;
  --text-launcher-label: 0.8125rem;
  --text-input: max(1rem, var(--text-detail-body));
}

/* The TV wall route (see ARCHITECTURE.md, "The wall route"): the same document and the same
   four-week grid a desktop draws, at its own path, with the header, the chat overlay and the
   agenda's own phone/desktop toolbar all put away -- nobody is present to navigate or type,
   so the grid and its permanent Today column beside it are the whole screen. */
body.wall .chat-header,
body.wall #command-bar {
  display: none;
}

/* No page scroll: the shell fills the viewport instead of centring a bounded column, and the
   only scrollers left standing are the Today column's own body below and the day overlay.
   .chat-upper and .chat-lower need no rule of their own here -- with the header gone and
   every sibling pane hidden by syncAgentView() itself, .chat-upper's content is empty and
   .chat-lower's existing flex: 1 1 0 already gives #agenda the whole of what that leaves,
   exactly the mechanism every other route already relies on. 100vh is the fallback for a
   browser (or a jsdom harness) with no dvh support. */
body.wall .chat {
  max-width: none;
  height: 100vh;
  height: 100dvh;
  margin: 0;
  padding: 0;
  border: 0;
  border-radius: 0;
}

/* Nothing floats above the wall to clear. */
body.wall .chat-lower {
  padding-bottom: 0;
}

/* The phone/desktop furniture the wall has no use for: there is no date to jump to and no
   window to step, so Today and the week-step nav go with #agenda's own .app-page-header
   that now holds them both. It stays display: none here too -- nobody is present to read a
   meta line, and left unhidden it would land as an unplaced grid item in body.wall
   #agenda's own two-row grid (below), since it names no grid-row/grid-column of its own. */
body.wall #agenda-strip,
body.wall #agenda-days,
body.wall #agenda .app-page-header {
  display: none;
}

/* #agenda-status and #agenda-failure share row 1 across both columns, so a message never
   nudges the grid or the Today column sideways; the grid takes row 2's left column and the
   Today column its right. --wall-today-width is its own token rather than reusing
   --shell-max/--shell-gutter, which size a centred phone/desktop column the wall has
   neither of. The pane's accent top edge goes with the hidden page header above, so the
   kiosk grid's pixel budget is exactly what it was. */
body.wall #agenda {
  display: grid;
  grid-template-columns: minmax(0, 1fr) var(--wall-today-width, clamp(168px, 21%, 280px));
  grid-template-rows: auto minmax(0, 1fr);
  gap: 0.5rem;
  padding: 0.5rem;
  height: 100vh;
  height: 100dvh;
  border-top: 0;
}

body.wall .agenda-status,
body.wall .agenda-failure {
  grid-column: 1 / -1;
  grid-row: 1;
}

/* An ordinary day says nothing in either line; without this an empty <p> still spends a
   line's height on row 1, which the wall -- unlike the phone or the desktop, which scroll --
   has no slack for. The same trap .agenda-grid[hidden] exists for, restated here because it
   is what makes row 1 disappear entirely on a quiet day. */
body.wall .agenda-status:empty,
body.wall .agenda-failure[hidden] {
  display: none;
}

/* The grid used to keep the desktop grid's own geometry and --text-detail-* type wholesale,
   on the theory that a TV read from across a room is physically larger than a desktop
   monitor at the same CSS pixels and so already reads at a distance. A real photo of the
   Fire TV Stick and a real headless-Chromium measurement against live calendar data both
   said that theory does not hold for a 28-cell month: 900x550 drew as few as 2 chips per
   cell, folded most of the month into "+N more", and broke titles mid-word ("Bowlin / g").
   So the wall now has its own denser type throughout this block -- lighter gap and padding
   here, and the cell/weekday/chip sizes below -- sized for a 28-cell grid rather than
   borrowed from the desktop's four-week grid, which never has to share a monitor with 27
   neighbours the way this does. */
body.wall .agenda-grid {
  grid-column: 1;
  grid-row: 2;
  min-height: 0;
  gap: 0.25rem;
}

/* Tighter than the desktop grid's own 10px/3px: less padding and a smaller inner gap buy
   back chip room a 28-cell month cannot otherwise spare. gap here overrides the 3px
   .agenda-cell inherits from the base rule above for the same reason. */
body.wall .agenda-cell {
  padding: 6px;
  gap: 2px;
}

/* A column beside the grid, not a pane of its own: a left border is enough to separate it,
   since the grid already carries the accent, the weekend tint and today's own border.
   Round 3, item 3: the Fire TV's own microphone/voice overlay paints over the top right of
   the screen, exactly where this column's title sat -- 34px from the top, measured against
   fad0e41 with a real headless Chromium at all four measured viewports (the figure does not
   vary by viewport: every contributor above it is a fixed px value, not a percentage). The
   padding-top below therefore rises from 6px to 34px, and the 28px that adds is chosen for
   headroom past the 56px floor rather than measured off the set; the measured result is 62px at
   every one of 900x550, 1024x600, 1280x720 and 1920x1080, and it moves nothing else: the
   grid on the left is untouched (.agenda-grid above keeps its own gap and padding), and none
   of the column's six real entries lose their place -- all six still render without
   scrolling at every measured viewport, before and after (see wall.dom-integration.test.ts
   and the implementer's report for the measured numbers). .agenda-wall-today-body below
   still takes flex: 1 1 0 and fills whatever height this leaves it. */
body.wall .agenda-wall-today {
  grid-column: 2;
  grid-row: 2;
  display: flex;
  flex-direction: column;
  min-height: 0;
  gap: 0.25rem;
  padding: 34px 0 6px 10px;
  border-left: 1px solid var(--card-line);
}

/* Wraps rather than truncating: "Today, Thursday 17 September" does not fit one line at
   900x550 (a 189px column), and a screen whose whole job is saying what today is must not
   ellipsise the month away. flex: none still holds -- the column has the vertical room for
   two lines there, and it is the scrolling body below, not this title, that gives up height.
   13px rather than --text-detail-title for the same reason the grid's own type dropped: this
   is a TV read from across a room, and what that screen has too little of is room, not
   size. */
body.wall .agenda-wall-today-title {
  flex: none;
  margin: 0;
  color: var(--ink);
  font-size: 13px;
  line-height: 1.25;
  font-weight: var(--w-title);
  white-space: normal;
}

/* The "scrollable, no +N" half of the column's whole reason to exist: every entry gets a
   row here, however many there are, and this is the one place that scrolls instead of
   clipping or counting. */
body.wall .agenda-wall-today-body {
  flex: 1 1 0;
  min-height: 0;
  overflow-y: auto;
  display: flex;
  flex-direction: column;
  gap: 3px;
}

/* A wall row states its entry's title in full and with no cap at all: the grid's own cap
   (AGENDA_GRID_CHIPS_WALL, in renderAgendaGridCell) still limits how many chips a cell
   shows before folding the rest into "+N more" -- this column has neither cap nor count,
   so a day too dense for its own cell is still read whole here. Its own rule, so it can
   still vary independently of the grid chip's even though both now wrap the same way (see
   body.wall .agenda-cell .agenda-chip-title below, which used to ellipsise a shortened
   title on one line and now wraps every word instead of dropping any). word-break: normal
   replaces the base rule's break-word so a word is broken only when the word alone cannot
   fit a line -- a wrap that lands mid-word is the defect this whole pair now refuses. */
body.wall .agenda-wall-today-row .agenda-chip-title {
  display: block;
  -webkit-line-clamp: unset;
  line-clamp: unset;
  overflow: visible;
  word-break: normal;
  overflow-wrap: break-word;
}

/* The column's rows take the grid chip's own denser type; only the title's wrapping differs
   (above). A little more padding than a grid chip, which has 28 neighbours and this has
   none. */
body.wall .agenda-wall-today-row.agenda-chip {
  font-size: 11px;
  line-height: 1.3;
  padding: 2px 5px;
}

/* The grid's own denser type, continued: weekday, date and chip sizes sized for a 28-cell
   month rather than the desktop grid's --text-detail-* tokens. .agenda-grid-weeks's own
   gap (see the base rule above) only widens to 0.55rem inside the @media (min-width:
   68.75rem) block further down, which the wall -- 900px to 1280px -- matches inconsistently;
   without a rule of its own here the wall's own cell gap would silently change between
   viewports it is never actually laid out at. */
body.wall .agenda-grid-weeks { gap: 4px; }
body.wall .agenda-grid-weekday { font-size: 11px; letter-spacing: 0.03em; }
body.wall .agenda-cell-date { gap: 0.2rem; }
body.wall .agenda-cell-day { font-size: 12px; line-height: 1.15; }
body.wall .agenda-cell-month { font-size: 10px; }
body.wall .agenda-cell-chips { gap: 2px; }
/* Third feedback round: the flex row above gave the title a fixed narrow column (chip width
   minus whatever the time span's own flex: none took), and at 900x550 that column is only
   ~44px -- narrower than "Academic" or "Ceremony" at 10px, which is why word-break: normal
   still forced a mid-word break there even after the title itself stopped ellipsising. The
   words were never too long for the chip; they were too long for the sliver the time column
   left them. display: block turns the chip into one inline run instead -- the mark, the
   time and the title flow together, so only the *first* line is narrowed by whatever
   precedes the title on it, and every wrapped continuation line gets the chip's own full
   width, exactly as body.wall .agenda-cell .agenda-chip-title below relies on. gap has no
   effect on a block box, so it is dropped here rather than left as dead weight; the space it
   used to buy between the mark/time and whatever follows now comes from the margin-right on
   each of them below. */
body.wall .agenda-cell .agenda-chip {
  display: block;
  font-size: 10px;
  line-height: 1.3;
  padding: 0 4px;
  border-radius: var(--radius-control);
}

/* Mandatory, not decoration: fitAgendaGridChips (agenda.js) trims an overflowing chip by
   setting its hidden attribute, and the base .agenda-chip[hidden] rule (0,2,0) is what
   turns that into display: none -- but body.wall .agenda-cell .agenda-chip above is
   (0,3,1), so once it declares its own display, it already outranks that base rule and a
   hidden wall grid chip would render anyway. This is the same cascade trap the two
   --person-edge padding-left restatements existed for before the wash replaced them; the
   property is different (display, not padding-left) but the reason to restate it here,
   at equal or higher specificity, is the same one. */
body.wall .agenda-cell .agenda-chip[hidden] {
  display: none;
}

/* Second feedback round: every coloured chip on the wall reads as one flat tint, never the
   3px .agenda-chip[data-person-edge] stripe defined earlier in this file -- read from
   across a room, that stripe showed as a lone coloured mark beside an otherwise white chip,
   while a single-person chip took a wash. In
   wall mode, renderAgendaGridChip sets this instead of --person-edge/--person-tint for one
   or more named people with a colour, so both counts go through the one wash path
   (personWashGradient) and can never look inconsistent with each other again. background-size:
   100% 100% is what makes the hard-stopped gradient cover the whole chip rather than the
   3px sliver --person-edge painted. */
body.wall .agenda-chip[data-person-wash] {
  background-image: var(--person-wash);
  background-repeat: no-repeat;
  background-size: 100% 100%;
}

/* The grid chip's title wraps on whole words instead of ellipsising: the second feedback
   round found the one-line ellipsis above threw away words a person still needed to read a
   cell (the "no line cap" choice lives in fitAgendaGridChips/agenda-grid.dom-integration --
   a title that cannot fit its cell at all is dropped whole and counted in "+N more", the
   grid's own existing honest-failure path, rather than clamped here). display: inline, not
   block (third feedback round): the base rule's -webkit-box clamp stops applying under
   either, but inline is what lets the title flow right on from the mark/time above instead
   of opening its own block box on a fresh line -- see body.wall .agenda-cell .agenda-chip
   above for why that is what actually fixed the mid-word breaks a block title still had.
   word-break: normal plus overflow-wrap: break-word is the same pair body.wall
   .agenda-wall-today-row .agenda-chip-title above already uses, and for the same reason --
   it wraps whole words and only ever breaks inside a word that cannot fit a line by itself.
   The full title is still recoverable from chip.title, from the Today column beside the
   grid, and by opening the day; none of that changed. */
body.wall .agenda-cell .agenda-chip-title {
  display: inline;
  -webkit-line-clamp: unset;
  line-clamp: unset;
  white-space: normal;
  overflow: visible;
  word-break: normal;
  overflow-wrap: break-word;
}

/* The mark and the time used to be separated from whatever followed them by the chip's own
   flex gap (see body.wall .agenda-cell .agenda-chip above); a block chip has no gap, and
   these are plain adjacent DOM elements with no whitespace text node between them (agenda.js
   builds the chip with element.append, never text), so without this "4:15p" would run
   straight into "Pick up Nate" with no space at all. Measured against the real Fire TV data
   at 900x550: 0.2rem is enough to read as a word gap without costing a long title a whole
   extra wrapped character. rem, not em -- it resolves against the root font size, so it is
   3.2px here and stays 3.2px whatever the chip's own 10px later becomes. */
body.wall .agenda-cell .agenda-chip-mark,
body.wall .agenda-cell .agenda-chip-time {
  margin-right: 0.2rem;
}

body.wall .agenda-cell-empty,
body.wall .agenda-cell-more { font-size: 10px; }

@media (max-width: 30rem) {
  /* The choice dialog as a phone bottom sheet with big tap targets; None of these full width. */
  .app-dialog.choice-dialog {
    width: 100%;
    max-width: none;
    margin: auto 0 0;
    border-radius: var(--radius-overlay) var(--radius-overlay) 0 0;
  }

  .choice-row-label {
    font-size: var(--text-section);
  }

  #choice-cancel {
    width: 100%;
    min-height: 48px;
  }

  .chat {
    --shell-pad-bottom: 0.75rem;
    padding: calc(0.75rem + env(safe-area-inset-top, 0px)) 0 calc(var(--shell-pad-bottom) + env(safe-area-inset-bottom));
    gap: var(--shell-gap);
    border-right: 0;
    border-left: 0;
    border-radius: 0;
  }

  /* The Library's controls stack full-width below the app's phone breakpoint. flex-wrap
     alone already folds the pair onto two lines, but only stacking makes each a whole
     line: sharing 390px, the filter's refusal to shrink is what clipped the search box
     beside it. */
  .library-controls {
    flex-direction: column;
    align-items: stretch;
  }

  .library-search,
  .library-type-filter {
    width: 100%;
  }

  /* The 14rem flex-basis above is a width hint for the desktop row; in this
     block's column direction flex-basis resolves against height instead, so
     without this reset the stacked search field renders 14rem (224px) tall
     with nothing in it. Qualified by its parent because the base .library-search rule
     sits later in this file: at equal specificity it would win and undo the reset. */
  .library-controls .library-search {
    flex: 0 0 auto;
  }

  /* A grid is a grid on the widths that have room for one; on the phone every column
     count is one column. Stated once for every caller -- the app's own layouts and a
     drafted page alike. Both selectors because the 3-column rule would otherwise
     outrank a bare .app-grid here. */
  .app-grid,
  .app-grid[data-columns="3"] {
    grid-template-columns: minmax(0, 1fr);
  }

  button {
    padding-inline: 1rem;
  }

  .icon-button {
    padding: 0;
  }

}

/* The two widths this app is actually used at, beyond the phone it was built for.
   1100px is where the shell widens and Calendar switches to its four-week grid; 1920px is
   the TV, which is not a bigger desktop but a screen read from across a room.
   They are literals because a media query cannot read a custom property. */
@media (min-width: 68.75rem) {
  :root {
    --shell-max: 100rem;
    --shell-gutter: 1.5rem;
  }

  /* The header spans the full width above the two columns; both keep to the same sensible
     reading measure centred in the pane rather than stretching edge to edge at this width. */
  .today-header,
  .today-weather-slot,
  .today-body {
    max-width: var(--content-max);
    margin-inline: auto;
  }

  /* Every other flex-column page gets the same column, so headers and content line up from
     page to page. Calendar (two-pane grid), the launcher (own measure), Browser (wants the
     width) and Today, Inbox and Groceries (their own rules) are deliberately left out. */
  .chat-lower > section:is(#meals-plan, #household-facts, #weather, #timers, #stocks, #podcasts, #music, #library, #photos, #outcomes, #view, #settings) > * {
    width: 100%;
    max-width: var(--content-max);
    margin-inline: auto;
    box-sizing: border-box;
  }

  /* A header one level inside the column wrapper keeps the column's own edge, like the headers that are the column's direct children. */
  #settings-root > .app-page-header,
  #music-overview > .app-page-header {
    margin-inline: 0;
  }

  /* An auto-margined flex item shrinks to its content; the slot spans the body's own width. */
  .today-weather-slot {
    width: 100%;
  }

  /* Today's two columns: the day's own work on the left, the inbox on the right, at the
     40/60 split a task title and an email subject actually need. The columns are real boxes
     here rather than the pass-through they are on a phone, so each stacks its own groups and
     both section headings start on the same line. minmax(0, …) on both tracks because a
     long unbroken subject would otherwise push a 3fr track past its share.

     The second track only exists when there is something to put in it: today.js appends a
     column wrapper only for a column that has a group, so :has() asks the DOM rather than
     leaving a 60% hole on a day with no email. */
  .today-body {
    display: grid;
    grid-template-columns: minmax(0, 1fr);
    align-items: start;
    gap: 1.5rem;
  }

  .today-body:has(> .today-column-primary):has(> .today-column-secondary) {
    grid-template-columns: minmax(0, 2fr) minmax(0, 3fr);
  }

  .today-column {
    display: flex;
    flex-direction: column;
    gap: 0.9rem;
    min-width: 0;
  }

  /* The empty day and a failed read append their one line straight to the body, with no
     column around it; it takes the whole width rather than a 40% track. */
  .today-body > .app-empty {
    grid-column: 1 / -1;
  }

  /* The four-week grid replaces the scrolling day list at this width: the same 28 dates a
     week strip used to index, laid out so a month of the household's plans is read at a
     glance instead of scrolled through. The grid's geometry lives in the base rules, so
     this block only tunes its measures -- a little more air between the cells than the
     base gap, and a little more room inside each one. */
  .agenda-grid-weeks {
    gap: 0.55rem;
  }

  .agenda-cell {
    padding: 10px;
  }

  /* The ordinary grid's own Today panel takes a side column beside the weeks at this width,
     where the wall's own #agenda already runs a two-column grid of its own with a different
     width and different rules (see body.wall #agenda). body:not(.wall) keeps the two
     provably apart: a body can never carry both, so this can never reach the wall's markup,
     however wide a wall screen measures -- ARCHITECTURE.md notes the wall's own sizes (900px
     to 1280px) straddle this exact breakpoint inconsistently, which is exactly why this is
     scoped by class rather than left to the media query alone. .agenda-grid itself already
     owns the flex:1/min-height:0 sizing that fills the column; this only changes how its own
     two existing children (the weekdays row and the weeks grid) and the new panel share it. */
  body:not(.wall) .agenda-grid {
    display: grid;
    grid-template-columns: minmax(0, 1fr) clamp(220px, 26%, 340px);
    grid-template-rows: auto minmax(0, 1fr);
    column-gap: 1rem;
  }

  /* Mandatory, not decoration: the base .agenda-grid[hidden] rule (0,2,0) would otherwise be
     outranked by body:not(.wall) .agenda-grid above (0,2,1) once that rule names its own
     display, exactly the cascade trap the wall's own [hidden] restatements exist for (see
     body.wall .agenda-cell .agenda-chip[hidden] further up). Without this, the list layout's
     hidden grid would render anyway at this width. */
  body:not(.wall) .agenda-grid[hidden] {
    display: none;
  }

  body:not(.wall) #agenda-grid-weekdays {
    grid-column: 1;
    grid-row: 1;
  }

  body:not(.wall) #agenda-grid-weeks {
    grid-column: 1;
    grid-row: 2;
  }

  /* Spans both of the grid's own rows, so the panel reads as tall as the weekday row plus
     the weeks beside it, not just the weeks row alone. */
  body:not(.wall) .agenda-today-panel {
    grid-column: 2;
    grid-row: 1 / -1;
    min-height: 0;
    padding-left: 1rem;
    border-left: 1px solid var(--card-line);
  }

  /* The stacked/kiosk rule above bounds the body to 35vh so a dense today cannot crowd the
     grid off the stacked layout; beside the grid the panel has its own full column instead,
     so that bound is lifted and the section list is what scrolls if today genuinely does not
     fit. */
  body:not(.wall) .agenda-today-panel-body {
    max-height: none;
    flex: 1 1 0;
  }

  /* The header band, tightened for a screen that is shorter than it is wide: a 900px-tall
     window spent about an eighth of its height on the header and a week strip before the
     first day. The phone keeps its own measures untouched -- it has the height to spare and
     not the width. */
  #agenda .app-page-header {
    padding-top: 0.35rem;
    padding-bottom: 0.2rem;
  }

  .agenda-strip {
    padding: 0.2rem 0.6rem 0.45rem;
  }

  /* Email's content takes a reading measure on a wide screen while the field it sits on
     stays the full scroller. 62rem is about 95 characters at the detail body size -- wide
     enough for a summary card's two columns of metadata, narrow enough that the eye finds
     the next line. The child selector reaches every direct child at once: the status line,
     the failure line, the review list, the email list and the bound note, so a new one
     inherits the measure instead of needing a rule. */
  .email-review-detail > * {
    max-width: var(--content-max);
    margin-inline: auto;
  }

  /* The header and the Review/Emails switch sit outside .email-review-detail (they scroll
     with the page, not inside its own field) and so never picked up the reading measure
     above: at this width they stretched to the full shell while the cards beneath them held
     to the column, reading as two different columns stacked on each other. Same measure, same
     centring, so the switch and the header line up with the cards below them. #email-review
     is a flex column, where auto side margins switch off the cross-axis stretch and
     shrink-wrap a child to its content (the header bunched mid-screen), so each child takes
     the column's full width explicitly. */
  #email-review > .app-page-header,
  #email-review > .email-review-bar {
    width: 100%;
    max-width: var(--content-max);
    margin-inline: auto;
    box-sizing: border-box;
    align-self: stretch;
  }

  /* Groceries takes the same reading-measure shape Email's field above does, at a list's
     own narrower measure: a shopping list is one column of short lines, and at the shell's
     full 100rem a wide screen stretched its rows into a scan across the whole pane. The
     scroller's own page ground still runs edge to edge -- only the content centres. */
  .groceries > * {
    width: 100%;
    box-sizing: border-box;
    max-width: var(--content-max);
    margin-inline: auto;
  }

  /* A long sender name ellipsised on a phone because the row genuinely ran out of width --
     that measurement is recorded on .review-item-header and stands. At this width there is
     room, so the name wraps and is shown whole instead. The date beside it still never
     gives up a character. */
  .review-source-link {
    overflow: visible;
    white-space: normal;
  }

  .review-source-name {
    overflow: visible;
    text-overflow: clip;
    overflow-wrap: anywhere;
  }

  /* Dinner plan takes the larger share; Food preferences and Saved recipes, both of which
     are read occasionally rather than worked in, take the smaller one beside it. */
  .meals-body {
    display: grid;
    grid-template-columns: minmax(0, 3fr) minmax(0, 2fr);
    align-items: start;
    column-gap: 1.5rem;
  }

  .meals-column {
    display: block;
    min-width: 0;
  }

  /* #meals-preferences carries a top margin that separates it from the plan above it in the
     single column. Beside the plan there is nothing above it, so the two columns would start
     on different lines. */
  .meals-column-aside > :first-child {
    margin-top: 0;
  }

  /* The recipe dialog stands its two halves side by side at the same breakpoint Meals'
     own pane stands the plan beside its aside: the facts and the ingredients on the left,
     the instructions on the right, in a widened dialog. Below this width the dialog is
     the single-column modal it has always been -- same markup, one breakpoint. */
  .recipe-dialog {
    width: min(64rem, calc(100% - 3rem));
  }

  .recipe-body .recipe-detail-columns {
    display: grid;
    grid-template-columns: minmax(0, 5fr) minmax(0, 7fr);
    column-gap: 1.4rem;
    align-items: start;
  }

  .recipe-body .recipe-detail-steps {
    margin-top: 0;
  }

  /* The store sections side by side when the width is there. auto-fill, not a fixed count:
     one category takes one track and sits at the pane's left edge with nothing invented to
     balance it, and the 62rem cap means two columns at most, never a thin third. The groups
     are the same sections in the same order -- a grid lays them out across and then down, so
     document order, reading order and tab order are one and the same, exactly as they are in
     the single column.

     The base rule spaces groups with a top margin on all but the first. In a grid that margin
     would land on whichever group happens to start the second column, so the rhythm becomes a
     row-gap here and the margin is dropped. */
  .grocery-list {
    display: grid;
    grid-template-columns: repeat(auto-fill, minmax(24rem, 1fr));
    align-items: start;
    max-width: 62rem;
    column-gap: 2rem;
    row-gap: 0.9rem;
  }

  /* This one names the list, not just the group pair, because the base rule it must outrank
     sits further down the file than this block does: at equal specificity the later base rule
     would win, and the margin would survive into the grid. */
  .grocery-list .grocery-group + .grocery-group {
    margin-top: 0;
  }

  /* The empty list's one line takes the whole width rather than a half track. */
  .grocery-list > .app-empty {
    grid-column: 1 / -1;
  }
}

@media (min-width: 120rem) {
  /* Read from a sofa, not from a desk: one step up on the detail scale and on the shell's
     own inset, and every control at 48px rather than 40-44. The type moves through the
     --text-detail-* tokens on purpose -- no rule on any pane names a size of its own, and
     detail-typography.test.ts enforces exactly that. Today keeps the two columns the
     desktop block gives it and the agenda keeps the same four-week grid the desktop block
     gives it, gaining only larger type and taller rows: a wider screen here is never a
     different layout. */
  :root,
  body.wall {
    --shell-gutter: 2rem;
    --text-detail-meta: 1rem;
    --text-detail-body: 1.125rem;
    --text-detail-title: 1.375rem;
    --list-lead: 88px;
    --card-pad: 16px;
    --card-gap: 10px;
    --list-gutter: 16px;
  }

  .icon-button {
    width: 48px;
    min-width: 48px;
    min-height: 48px;
  }

  /* The dock keeps the same four-column grid here too, gaining only a taller floor. */
  .dock-button {
    min-height: 48px;
  }

  .app-quiet-button,
  .app-pill,
  .agenda-today {
    min-height: 48px;
  }

  /* The TV gets the same grid with more room in each cell, never a different layout. */
  .agenda-cell {
    padding: 14px;
  }

  .agenda-chip {
    padding: 4px 8px;
  }
}

/* Tasks in the agenda and the type control in the proposal dialog. Every rule here is
   new: the agenda's task row borrows the event row's own measures -- same padding,
   sizes and radii -- so both row kinds read as one list, with the checkbox alone
   telling them apart in the timeline. */

/* One shared card (the reference's own gp-panel), not a stack of per-row ones: today's own
   untimed/section tasks are a single group, unlike a timed task inside the day's ordered
   timeline (.agenda-task-timed), which keeps its own card because it is one entry among
   events there, not a member of this list. */
.agenda-tasks {
  margin-bottom: var(--card-gap);
}

.agenda-tasks-header {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 10px;
  margin-bottom: 7px;
}

.agenda-tasks-label {
  margin: 0;
}

.agenda-tasks-count {
  color: var(--muted);
  font-size: var(--text-detail-meta);
}

/* Rows separated by a hairline inside the one shared card, the same idiom
   .today-card-rows already composes .app-divider-list onto. */
.agenda-tasks-rows > .agenda-task {
  padding: 10px 0;
}

.agenda-tasks-rows > .agenda-task:first-child {
  padding-top: 2px;
}

.agenda-tasks-rows > .agenda-task:last-child {
  padding-bottom: 2px;
}

/* Wraps rather than scrolls sideways: the checkbox and the toggle share the first
   line, and the expanded detail or an inline error takes a full-width line of its own
   below, exactly where the event row's detail sits. */
.agenda-task {
  display: flex;
  flex-wrap: wrap;
  align-items: flex-start;
  gap: 0;
  min-width: 0;
}

/* The checkbox and its zero-cost touch target are the shared .app-check and
   .app-check-target now -- agenda.js composes both. The measures those primitives carry
   are this row's own, promoted: --check-size is the 24px a checkbox has always been here
   (and deliberately not a pixel more, because a checkbox is a replaced element and any
   width past its own box stretches the control rather than padding it), and the target's
   padding-and-negative-margin pair is what keeps a 44px hit area from adding 20px to a
   single-line timed task card. What is left below is only where this row PUTS them. */

/* Every task's checkbox sits at the row's own start, the same shape Today's own task row
   gives it (see .today-row > .agenda-task-checkbox) -- timed and untimed alike now: a timed
   task in the day's timeline used to keep a time rail of its own with the checkbox pushed to
   the trailing edge instead, which read as a different row kind rather than the same task the
   Tasks card renders (its time moved to a plain meta line, .agenda-task-time-meta, in
   agenda.js). --list-lead-gap is the one gap either row uses; the negative touch slack on the
   other three sides is folded in so the hit area still costs no layout space. */
.agenda-task > .agenda-task-checkbox {
  align-self: flex-start;
  margin: calc(3px - var(--touch-slack))
    calc(var(--list-lead-gap) - var(--touch-slack))
    calc(var(--touch-slack) * -1)
    calc(var(--touch-slack) * -1);
}

/* The focus ring belongs on the control the keyboard is actually on, not on the label around it. */
.agenda-task-checkbox:focus-within {
  outline: none;
}

/* The Later button sits at a row's trailing edge in both layouts -- right before the
   checkbox in a timed row, at the far end of the growing title in an untimed one -- so it
   never grows or shrinks with the row and stays vertically centred like .agenda-task-undo. */
.agenda-task-later {
  flex: none;
  align-self: center;
  margin-left: var(--list-lead-gap);
}

/* At Large, and on a very narrow phone, Later drops below the title (the row already wraps)
   instead of taking width from it. No extra indent: it keeps the one margin above whether it
   sits beside the title or beneath it. */
:root[data-text-size="large"] .agenda-task > .agenda-task-toggle {
  flex: 1 1 12rem;
}

@media (max-width: 374px) {
  .agenda-task > .agenda-task-toggle {
    flex: 1 1 12rem;
  }
}

/* Placement only; the row look is .app-row's. Grown from a zero basis rather than .app-row's
   own width: 100%, because the checkbox shares this line -- and a zero basis is what makes a
   long title shrink the toggle instead of wrapping it onto a line of its own below. */
.agenda-task-toggle {
  flex: 1 1 0;
  width: auto;
}

.agenda-task-main {
  flex: 1 1 0;
  min-width: 0;
}

.agenda-task-people {
  display: flex;
  flex-wrap: wrap;
  gap: 0.35rem;
  margin: 4px 0 0;
  font-size: var(--text-detail-body);
}

.agenda-task-detail {
  flex: 1 1 100%;
  margin-top: 8px;
}

.agenda-task-detail[hidden] {
  display: none;
}

.agenda-task-detail p {
  margin: 0 0 0.3rem;
}

.agenda-task-detail p:last-child {
  margin-bottom: 0;
}

/* The "Assigned to" radiogroup in a task row's tap-expanded detail. Its label is a
   .calendar-event-field paragraph like the Planned and Repeats fields around it, and the
   options are .person-option buttons, so neither is restated here: the class below is
   only the row the two names sit in. */
.agenda-task-assign {
  display: flex;
  gap: 0.5rem;
  margin: 0 0 0.3rem;
}

.agenda-task-error {
  flex: 1 1 100%;
  margin: 6px 0 0;
  color: var(--error);
  font-size: var(--text-detail-body);
  overflow-wrap: anywhere;
}

.agenda-task-error[hidden] {
  display: none;
}

/* Visibly finished without leaning on colour: the whole row goes quiet and the title
   itself carries the strike. */
.agenda-task-done .agenda-task-toggle {
  color: var(--muted);
}

.agenda-task-done .agenda-task-title,
.grocery-row-pending .grocery-title {
  color: var(--faint);
  text-decoration: line-through;
}

/* A completed card recedes but stays readable, rather than matching the white of an
   open row. */
.agenda-task-done {
  background: var(--panel);
  border-color: var(--line);
}

.agenda-completed {
  margin-top: var(--card-gap);
}

.agenda-completed-toggle {
  color: var(--ink-on-wall-muted);
  text-shadow: var(--text-shadow-on-wall);
}

.agenda-completed-list {
  margin-top: var(--card-gap);
}

.agenda-completed-list[hidden] {
  display: none;
}

.agenda-task-undo {
  flex: none;
  align-self: center;
  margin-left: var(--list-lead-gap);
}

/* A task proposal row is the proposal row: it carries .calendar-proposal-event with
   the .calendar-proposal-task modifier, and the type label below is what tells the
   kinds apart -- so the modifier itself needs no rules of its own. */

/* The word Event or Task is the signal, so the quiet pill the transcript's markers
   already use is enough; colour alone never carries it. */
.calendar-type-label {
  margin: 0 0.35rem 0.35rem 0;
  padding: 0.05rem 0.35rem;
}

.calendar-type-control,
.calendar-reminder-control {
  display: inline-flex;
  align-items: center;
  gap: 0.25rem;
}

/* The reminder group sits on the same row as the type switch; at phone width the row
   wraps and both groups drop below the event's own text (see .calendar-proposal-event).
   The group itself must NOT wrap: a bell and two short toggles belong on one line, and
   letting them stack was what made them unreadable in a narrow column. */
.calendar-reminder-control {
  flex: none;
  flex-wrap: nowrap;
}

.calendar-reminder-bell {
  width: 16px;
  height: 16px;
  flex: none;
  stroke: currentColor;
  fill: none;
}

/* The shared quiet button, one step quieter at rest: these are options in a row, so
   the unchosen ones recede and .app-selected brings the chosen one forward. */
.calendar-type-option,
.calendar-reminder-option {
  padding: 0.25rem 0.6rem;
  color: var(--muted);
}

/* Reaches the 44px touch target the same way the agenda's task checkbox does (see
   --touch-slack above): padding grows the hit area and an equal negative margin hands
   the row's own height straight back. */
.calendar-reminder-option {
  padding-top: calc(0.25rem + var(--touch-slack));
  padding-bottom: calc(0.25rem + var(--touch-slack));
  margin-top: calc(var(--touch-slack) * -1);
  margin-bottom: calc(var(--touch-slack) * -1);
}

/* Selected shows in its own shape, not just another colour: an accent ring plus ink
   text, both of which survive greyscale. */
.calendar-type-option[aria-pressed="true"],
.calendar-reminder-option[aria-pressed="true"] {
  color: var(--ink);
  font-weight: var(--w-title);
  background: var(--panel);
  border-color: var(--accent);
  box-shadow: inset 0 0 0 1px var(--accent);
}

/* A refused switch says so in its border, not only in the shared disabled opacity:
   dashed is the house's not-available outline. */
.calendar-type-option:disabled {
  color: var(--muted);
  background: transparent;
  border-color: var(--line);
  border-style: dashed;
  cursor: not-allowed;
}

/* The refusal is expected behaviour, not a failure, so its reason takes the row's
   quiet field treatment rather than the error colour. */
.calendar-type-blocked {
  margin: 0.25rem 0 0;
  color: var(--muted);
  font-size: var(--text-detail-body);
  overflow-wrap: anywhere;
}

/* Another pane of the same display band, measured from .groceries beside it: a scrollable
   column that owns the band's single content scroller while it is showing. */
.settings[hidden] {
  display: none;
}


/* Programmatic only, like .groceries-title: focused on every open so the new pane is
   announced, but visually hidden and never in the tab order. */
.settings-title:focus {
  outline: none;
}

.settings-body {
  padding-top: 0.35rem;
}

/* Settings is a card list now, one subsection per .app-card -- .app-row's own shape inside
   each card, a title and its description on the leading side with the control at the
   trailing edge -- rather than the wallpaper divider list of loose rows the gold-and-blue
   card round retires (SURFACES: "cards are equal"). What this pane rule adds is only the
   wrap (a row must fold its control under its description on a phone, which .app-row
   leaves to the caller) and a tighter row rhythm for the fold. */
.settings-row {
  flex-wrap: wrap;
  row-gap: 0.3rem;
}

.settings-row-main {
  display: flex;
  flex-direction: column;
  flex: 1 1 12rem;
  min-width: 0;
  gap: 0.15rem;
}

/* The trailing edge: the row's control, and whatever live state belongs to it stacked
   under it. Wraps, unlike the Calendar bar this control came from -- #notifications-status
   holds one word normally and a whole sentence when the server has no key pair; in a bar
   that sentence had to be capped at 45% of the row or it overran the viewport, but a pane
   can simply give it its own line, so the cap is lifted here and the wrap does the work.
   flex: none means this column never shrinks, which is exactly what let its own natural,
   unwrapped width (a long status sentence, "Daily summary" spelled out on one
   line) run past the card's right edge instead of following it once that natural width
   passed the row's own -- max-width holds it to the row's actual width regardless, so a
   child can still refuse to shrink below its content while never rendering wider than the
   card allows; every child stops there too, so a button or the Appearance segments wrap
   their own label rather than pushing the column past that same edge. */
.settings-row-control {
  display: flex;
  flex-direction: column;
  align-items: flex-end;
  flex: none;
  min-width: 0;
  max-width: 100%;
  margin-left: auto;
  gap: 0.3rem;
}

.settings-row-control > * {
  max-width: 100%;
}

/* The column is as wide as the label's margin box, which app-check-target's negative
   margin shrinks to the checkbox; max-width: 100% would clamp the padded label to it. */
.settings-row-control > .app-check-target {
  max-width: none;
}

/* Every trailing action in a settings row (Notifications, Connect Gmail, Daily summary)
   is one height and one type size, whichever older rule styled the button first. */
.settings-row-control > button {
  min-height: var(--touch-min);
  font-size: var(--text-support);
}

.settings-row #notifications-status {
  max-width: 100%;
}

/* Device names, codes, domains and update failures are identifiers a person must read whole:
   they break anywhere rather than overflowing the card or being cut, and the actions wrap. */
#devices-list .devices-row,
#devices-status,
#devices-failure,
#devices-code-failure {
  overflow-wrap: anywhere;
}

#devices-list .android-setup-account {
  display: flex;
  flex-wrap: wrap;
  gap: 0.4rem;
}

/* The note under a control is the caption for it, so it reads at the shared row-meta
   step .app-row-meta gives every other caption in the app. It used to read a step above
   the notifications status line beside it, which put the explanation louder than the
   thing it explains. */
.settings-note {
  margin: 0;
}

/* The chevron sits at the trailing edge like a control would, but names no state -- it only
   says the row opens something. */
.settings-nav-row {
  align-items: center;
  min-height: 44px;
}

/* No font-size of its own -- the detail type scale's own guard counts every declared size,
   and the row's inherited 1rem reads the glyph clearly enough. */
.settings-nav-chevron {
  margin-left: auto;
  color: var(--muted);
  line-height: 1;
}

/* Not sticky any more: the bar has no ground of its own on the wallpaper (see .app-back-bar),
   so a pinned one would draw over the cards scrolling beneath it. */
.settings-detail-toolbar {
  position: static;
  /* The page header above has 18px of top padding; the 44px back button's own line box
     makes up the rest, so a detail page starts at the same y as the main Settings title. */
  padding-top: 14px;
}

.settings-detail-toolbar > .settings-detail-title {
  font-size: var(--text-detail-title);
}

/* The Appearance control: three adjoining segments in one bordered group, not three
   separate buttons, so the choice reads as one control with three states. The group's own
   name (#theme-control-label, which the radiogroup is aria-labelledby'd to) is the row's
   .app-row-title on the leading side now -- .app-row-title's own muted-title treatment,
   the same as the Notifications row's title beside it -- so this wrapper holds only the
   segmented bar itself. */
.theme-control {
  display: flex;
  flex-direction: column;
  gap: 0.35rem;
}

.text-size-sample {
  font-size: var(--text-detail-body);
  line-height: var(--leading-detail-body);
}

.theme-options {
  display: flex;
  min-width: min(100%, 24rem);
  border: 1px solid var(--line-strong);
  border-radius: var(--radius-control);
  overflow: hidden;
}

@media (max-width: 44.99rem) {
  /* The three segmented controls (Appearance, Read aloud, Dictation pause) have no room to
     share a line with their row title at this width. .settings-row-control is flex: none
     everywhere else because its usual contents (a short status word, a toggle) are already
     narrower than the row -- that same refusal to shrink would let the segments' single-line
     width run past the card's edge. Full row width here, so .theme-options (flex: 1 1 0 per
     segment, shrinkable) divides it evenly; min-height on .theme-option floors the touch
     target at 44px whether a label takes one line or two. */
  .settings-row-control:has(.theme-options) {
    flex: 1 1 100%;
    align-items: stretch;
  }
}

/* Large text on a phone: every settings row stacks, so its control sits BELOW the title and
   description at the card's full width instead of stealing width from them. */
@media (max-width: 47.99rem) {
  /* A single-column grid, not a column flexbox: Chrome sizes a column flexbox's block-size
     from the wrapping segmented group at a narrower width than the final layout, which left
     an empty band under it. The row's own row-gap carries over. */
  :root[data-text-size="large"] .settings-row {
    display: grid;
    grid-template-columns: minmax(0, 1fr);
    align-items: start;
  }

  :root[data-text-size="large"] .settings-row[hidden] {
    display: none;
  }

  :root[data-text-size="large"] .settings-row-main {
    flex: none;
  }

  :root[data-text-size="large"] .settings-row-control {
    flex: none;
    justify-self: stretch;
    width: auto;
    align-items: flex-start;
    margin-left: 0;
  }

  :root[data-text-size="large"] .settings-row-control:has(.theme-options) {
    align-items: stretch;
  }
}

/* An option group wraps instead of squeezing: at Large the three segments take a line each
   when they cannot share one. */
.theme-options {
  flex-wrap: wrap;
}

:root[data-text-size="large"] .theme-option {
  flex: 1 1 8rem;
}

.theme-option {
  display: flex;
  flex: 1 1 0;
  align-items: center;
  justify-content: center;
  min-height: var(--touch-min);
  padding: 0.35rem 0.45rem;
  color: var(--muted);
  font-size: var(--text-support);
  line-height: 1.25;
  text-align: center;
  text-wrap: balance;
  /* The bar (.theme-options) draws the outline and the hairlines between segments, so a
     segment draws neither -- it is the shared quiet button with its own box removed. */
  background: none;
  border: 0;
  border-radius: 0;
  cursor: pointer;
}

.theme-option + .theme-option {
  border-left: 1px solid var(--line-strong);
}

/* Hidden the same way the file already hides text a screen reader still needs
   (.visually-hidden) -- never display: none, so the radio stays in the tab order and keeps
   a real focus target. */
.theme-option-input {
  position: absolute;
  width: 1px;
  height: 1px;
  overflow: hidden;
  clip-path: inset(50%);
}

/* This rule is the shared .app-selected now -- its --accent-soft ground and --accent ink
   are what that primitive was built from, because this was the one of the three older
   selected treatments that degrades correctly in both a standalone control and a joined
   segmented bar. What stays here is only the segment's own box. */
.theme-option:has(.theme-option-input:focus-visible) {
  outline: 2px solid var(--accent);
  outline-offset: -2px;
}

/* Appearance is the one segmented control the brief asks to read as a proper chooser
   rather than the shared quiet accent-soft wash every other selected state in the app
   uses: a solid gold ground with dark ink (--on-accent, #293441 in the approved theme),
   the one place selection takes a fill rather than a tint. A deliberate, scoped departure
   from .app-selected -- the same kind the person switcher's own person-colour mix already
   is -- not a restatement of it, since .theme-option still composes app-selected for its
   focus-visible ring and reduced-motion behaviour. */
.theme-option.app-selected:has(.theme-option-input:checked) {
  color: var(--on-accent);
  background: var(--accent);
}

/* The Groceries pane: the same scroller shape as .meals-plan -- a column owning the
   display band's single content scroller while it shows. The mobile visual-polish round
   gave it its own sage identity (the count line, the category markers below, and the
   Kroger action/status block) in place of the plain muted/ink vocabulary an earlier round
   deliberately chose; the gold-and-blue card round removes that per-destination tint again
   (SURFACES: "cards are equal") now that each category is its own opaque card
   (.grocery-group composes .app-card) rather than a plain-vocabulary group on the page --
   the count line reads the shared on-wall subtitle unchanged, and a group's own label
   reads the shared .app-section-label's plain muted ink, correct now that it sits on the
   card rather than the wallpaper. The Kroger action was a pale band of its own; it sits
   directly on the wallpaper now, beside the primary button's own gold fill. */
.groceries[hidden] {
  display: none;
}

/* Every category is its own card now, so the one line left directly on the wallpaper is
   the whole-list empty state. */
#grocery-list > .app-empty {
  color: var(--ink-on-wall-muted);
  text-shadow: var(--text-shadow-on-wall);
}

/* Programmatic only, like .meals-plan-title: focused on every open so the new pane is
   announced, but visually hidden and never in the tab order. */
.groceries-title:focus {
  outline: none;
}

.groceries-failure[hidden] {
  display: none;
}

/* The action line is a reserved slot, not a line that comes and goes: it sits above the
   whole list, so a rule that collapsed it while hidden would push every row down the
   moment Undo appeared and pull them back up when it went -- exactly the shift that made
   a rapid second tap land on the wrong row. It keeps its box when hidden instead, and
   only stops painting. */
.groceries-action[hidden] {
  display: flex;
  visibility: hidden;
}

/* The row that slot draws: an Undo button beside its text, so a flex row rather than the
   plain .app-status block it inherited. The height is the button's own 40px exactly --
   not a floor -- and nothing wraps, so however long an item's name is the slot stays the
   one height it was when it was empty and the list under it never moves. The pane is a
   flex column, so the slot must also opt out of flex-shrink: without it the empty hidden
   slot's zero min-content lets the column squeeze it away and the list still jumps. */
.groceries-action {
  display: flex;
  align-items: center;
  flex-wrap: wrap;
  flex: none;
  gap: 0.5rem;
  min-height: var(--touch-min);
}

/* The slot's text is a status or error line, so it wraps in full rather than losing its tail;
   the slot's height is a floor, not a fixed measure, and Undo drops below the text when the two
   do not fit together. */
.groceries-action-message {
  flex: 1 1 12rem;
  min-width: 0;
  line-height: 1.35;
  overflow-wrap: break-word;
  white-space: normal;
}

/* The button holds its own width and never shrinks out from under the text, past its own
   floor over .app-quiet-button's base 32px -- the house's general 40px tap-target rule
   every other control on this pane already reaches, and the measure the slot above is
   sized to. */
.grocery-removal-undo {
  flex: none;
  min-width: var(--touch-min);
  min-height: var(--touch-min);
}

/* One line per grocery: the checkbox and the name over its quiet source line. Rhythm, not
   cards of their own (.app-divider-list inside each section group's card) -- the same
   "hairline rows inside one bounded card" idiom Today's own card groups use.
   align-items: flex-start, not center: .grocery-main stacks the title over its source
   chips (unlike .today-row's single wrapping line, where centring against the whole row
   already lands on the one line there is), so centring against the whole two-line block
   put the checkbox between the title and the chips rather than beside the title. The
   checkbox's own 22px box (--check-size) is close enough to the title line's own 20.8px
   (1rem/1.3, --leading-detail-tight) that flex-start alone lands it on the title's first
   line with no separate offset needed. */
.grocery-row {
  display: flex;
  flex-wrap: wrap;
  align-items: flex-start;
  gap: 0.5rem;
  min-width: 0;
  padding: 0.5rem 0;
}

/* The pane has no 64px lead rail, so the checkbox sits at the row's own start with the
   rail's gap after it -- exactly what .today-row does with the same control. The negative
   touch slack hands back the space the 44px hit area borrows, so it costs no row height. */
.grocery-row > .agenda-task-checkbox {
  margin: calc(var(--touch-slack) * -1)
    calc(var(--list-lead-gap) - var(--touch-slack))
    calc(var(--touch-slack) * -1)
    calc(var(--touch-slack) * -1);
}

/* The list's one control is the shared .app-check (groceries.js composes it): the same
   24px ring that fills solid --accent with an --on-accent mark when it is chosen, and
   the same 44px hit area from .app-check-target, so it still costs no row height. This
   used to be one of five hand-written copies of that circle. */

/* Grown from a zero basis, like .today-row > .today-open-row: a long grocery name must
   shrink this column rather than wrap onto a line of its own. */
.grocery-main {
  flex: 1 1 0;
  min-width: 0;
}

.grocery-title {
  display: block;
  overflow-wrap: anywhere;
}

/* Which meals an item came from, as one small chip per meal -- the shared marker pill
   with the solid ground (.app-tag.app-tag-solid), the same shape the Library's type
   badge reads with. Who added the line is no longer said here at all: it reads as the
   row's person dot (.grocery-person-dot below), so a manual-only row renders no
   .grocery-source line. A wrapping run, not an ellipsised line: two meals' worth of
   chips is real information a truncated single line would hide. */
.grocery-source {
  display: flex;
  flex-wrap: wrap;
  gap: 0.25rem;
  margin-top: 0.15rem;
}

/* One heading per store section, the same .app-section-label the Today pane's four groups
   use. The dividers stay inside a group, so two sections never share a hairline. */
.grocery-group + .grocery-group {
  margin-top: 0.9rem;
}

.grocery-group .app-section-label {
  margin-bottom: 0.15rem;
}

/* Pending removal, in the app's existing completed-item treatment: the row goes quiet and
   the name itself carries the strike, from .agenda-task-done's own rule above rather than a
   copy. Unlike a completed task the checkbox stays checked rather than disabled, because
   un-ticking within the 3-second window is exactly how the removal is cancelled. A finished
   removal still holding its slot reads the same way (its own class adds nothing here) and
   does seal its checkbox, since there is nothing left to cancel. */
.grocery-row-pending .grocery-source {
  color: var(--muted);
}

/* The title is itself the row's edit control now, so it has to read exactly as the plain
   text it replaced -- and the generic `button` rule above fills a button with --accent, a
   pill radius, title padding and a 44px floor, every one of which would give
   the game away and the last of which would grow every row on the list. Each default is
   reset by hand here; the row keeps its own height and the whole .grocery-main column
   stays the tap area. The font is not reset at all: the size, weight and line height come
   from .app-row-title on the same element, whose class outranks the `button` element
   rule's own font reset. The text cursor is the one pointer change: the words became
   editable, and the pointer should say so before the tap lands. Keyboard focus keeps the
   shared button:focus-visible ring, and the .grocery-title class stays on the element so
   the pending strike above still finds it. */
.grocery-title-button {
  display: block;
  width: 100%;
  min-height: 0;
  padding: 0;
  color: inherit;
  text-align: start;
  background: transparent;
  border: 0;
  border-radius: 0;
  cursor: text;
  overflow-wrap: anywhere;
}

/* Who put the line on the list: one dot at the row's end, named in its title and again in
   the visually-hidden caption inside .grocery-main. The colour never comes from here --
   data-person-key resolves through the shared person table far above, the app's one
   source of a person's colour, exactly as the Calendar's dots do. 9px centred on the
   title's first line: the title's line box is var(--leading-detail-tight) ×
   var(--text-detail-body) -- the tokens themselves, not this element's own em, which
   need not match the title's size -- and half the difference as margin-top lands it
   mid-line. A long title wraps inside .grocery-main (flex 1 1 0, min-width 0), so it
   never pushes the dot off the row; the auto margin-left parks the dot at the row's end. */
.grocery-person-dot {
  flex: none;
  width: 0.5625rem;
  height: 0.5625rem;
  margin: calc((var(--text-detail-body) * var(--leading-detail-tight) - 0.5625rem) / 2) 0 0 auto;
}

/* A line nobody recorded a person against -- a manual add from before attribution, or one
   written with nobody to name -- and a line only a meal contributed: the honest quiet
   hollow ring, clearly not a person's colour. The transparent ground restates
   .person-dot's own var(--person-color, transparent) fallback so an unmapped key can
   never bleed a colour through. */
.grocery-person-dot-unknown {
  background: transparent;
  box-shadow: inset 0 0 0 1.5px var(--muted);
}

/* The open editor stands in for the title inside .grocery-main, which shrinks around it:
   the shared .app-field ground for the draft (composed on the element in groceries.js, not
   restated here), Save and Cancel on the shared 44px .icon-button base -- which the
   generic `button` rule already keeps flex: none, so neither button ever yields to the
   field -- and the editor's own line on a full second row of its own, which is why the
   form wraps. The field is the one thing allowed to shrink (flex-basis 0, min-width 0):
   at 320px the row is checkbox 44 + dot 9 + two buttons 88 + the row's own gaps, and the
   field takes the ~140px left, so nothing overflows and no tap target shrinks. */
.grocery-edit {
  display: flex;
  align-items: center;
  flex-wrap: wrap;
  gap: 0.25rem;
  min-width: 0;
}

.grocery-edit-input {
  flex: 1 1 0;
  min-width: 0;
}

.grocery-edit-error {
  flex-basis: 100%;
  margin: 0;
  overflow-wrap: anywhere;
}

.grocery-edit-error[hidden] {
  display: none;
}

/* "Saving…" is a state, not a failure: the quiet ink for the wait, while .app-failure on
   the same line above carries a save that actually failed. */
.grocery-edit-saving {
  color: var(--muted);
}

/* The Add Meals dialog: the shared .app-dialog frame, one block per planned meal. Its
   footer wraps, like the two other footers that can grow an error line. */
.grocery-meals-footer {
  align-items: center;
  flex-wrap: wrap;
}

.grocery-meals-note {
  flex: 1 1 auto;
  margin: 0;
  color: var(--muted);
  font-size: var(--text-detail-meta);
}

.grocery-meals-error[hidden] {
  display: none;
}

.grocery-meal + .grocery-meal {
  margin-top: 0.9rem;
  padding-top: 0.9rem;
}

.grocery-meal-row {
  display: flex;
  align-items: center;
  gap: 0.5rem;
  min-width: 0;
}

.grocery-meal-row > .agenda-task-checkbox {
  margin: calc(var(--touch-slack) * -1)
    calc(var(--list-lead-gap) - var(--touch-slack))
    calc(var(--touch-slack) * -1)
    calc(var(--touch-slack) * -1);
}

.grocery-meal-title {
  flex: 1 1 0;
  min-width: 0;
  overflow-wrap: anywhere;
}

/* The meal's ingredients, a quiet nested block on the panel surface (.app-inset), inset
   past the checkbox so they read as that meal's children rather than as a second list. */
.grocery-ingredients {
  margin-top: 0.45rem;
  margin-left: calc(24px + var(--list-lead-gap));
  padding: 0.5rem 0.6rem;
}

/* The whole label is the touch target, so the 24px box keeps its own size and is padded
   rather than stretched -- a checkbox is a replaced element. */
.grocery-ingredient {
  display: flex;
  align-items: center;
  gap: 0.55rem;
  min-height: 44px;
  padding: 0.1rem 0;
  color: var(--ink);
  cursor: pointer;
  font-size: var(--text-detail-body);
}

/* The per-ingredient control is the shared .app-check too -- a copy of the circle used
   to live here as well. */

.grocery-ingredient-text {
  min-width: 0;
  overflow-wrap: anywhere;
}

/* An unticked meal contributes nothing, so its ingredient choices go inert and say so --
   while still being remembered, so re-ticking the meal restores exactly what was chosen. */
.grocery-meal-excluded .grocery-ingredients {
  opacity: 0.55;
}

.grocery-meal-excluded .grocery-ingredient,
.grocery-ingredient-check:disabled {
  cursor: default;
}

/* The Stocks pane: a minimal display like Groceries. The section itself is the one scroller
   (.app-scroller .app-scroller-field in index.html, the same shape #groceries has): the
   header, Recent changes, and whichever of the list or the chart is showing scroll together
   as plain blocks, nothing pinned. The shared .app-scroller also owns the bottom clearance
   above the dock and the transparent field, so Recent changes and every ticker card (both
   already .app-card) float on the wallpaper. The --stock-* colour tones are ordinary tokens
   in `:root`. */
.stocks[hidden] {
  display: none;
}

.stocks-title:focus {
  outline: none;
}

/* The sort select lives in the page header beside the title: compact, pushed to the far
   edge. */
#stocks-sort {
  margin-left: auto;
  width: auto;
  min-height: var(--touch-min);
  padding: 0.2rem 0.5rem;
  font-size: var(--text-detail-body);
}

/* No side/bottom margin any more: this used to be a standalone paragraph in the pane
   body, matching #stocks-status's own side insets below. It still lives inside
   .app-page-header-subtitle, which owns the gap to its #stocks-count sibling; the
   header's own insets come from #stocks's margin-inline rule in the page-header
   block above. */
#stocks-source {
  margin: 0;
}

/* Plain blocks at the end of the scroller; the scroller's own bottom padding clears the dock. */
#stocks-status,
#stocks-failure {
  margin: 0.3rem 0 0;
}

#stocks-failure[hidden] {
  display: none;
}

/* An empty live region takes no room. */
#stocks-status:empty {
  margin: 0;
}

/* The 'recent changes' summary card: always present above the ordinary row list (see
   renderRecentChanges in stocks.js), on the shared card surface (.app-card) now that the
   pane it floats on is the wallpaper rather than its own same-toned panel -- a custom
   --panel surface would have read as a near-duplicate of every ticker card beside it, one
   step off in tone for no reason. Its own side margin is the list's own gutter, so it
   lines up with the ticker cards below it rather than running flush to the pane's edges,
   which .stocks no longer hides behind a full-bleed background of its own. */
.stock-changes {
  margin: 0 0 0.6rem;
}

.stock-changes-heading {
  margin-bottom: 0.35rem;
}

.stock-changes-empty {
  margin: 0;
  color: var(--muted);
  font-size: var(--text-detail-meta);
}

/* One line per change: the end-state glyph (colour plus the row's aria-label -- never colour
   alone), the symbol, the day's percent, the date trailing at the far edge. Wraps like .stock-row does rather than clipping on a narrow phone screen. */
.stock-change-row {
  padding: 0.3rem 0;
  display: flex;
  flex-wrap: wrap;
  align-items: baseline;
  column-gap: 0.5rem;
  row-gap: 2px;
  font-size: var(--text-detail-body);
}

.stock-change-symbol {
  flex: 0 0 auto;
  font-variant-numeric: tabular-nums;
  font-weight: var(--w-title);
}

.stock-change-direction {
  flex: 0 0 auto;
}

.stock-change-percent {
  flex: 0 0 auto;
  font-variant-numeric: tabular-nums;
}

.stock-change-date {
  margin-left: auto;
}

.stock-change-up .stock-change-direction {
  color: var(--stock-up-status);
}

.stock-change-down .stock-change-direction {
  color: var(--stock-down-status);
}

.stock-change-flat .stock-change-direction {
  color: var(--stock-row-meta-ink);
}

/* One card per ticker, the idiom for entries that are things in their own right rather than
   lines inside one document -- see "Which list idiom a new list should use". One horizontal
   row now, not three stacked lines: the symbol leads, ADX and the two ATR distances trail,
   and the row wraps only when it must -- see .stock-row-meta-line's margin-left below for how
   the trailing group hugs the edge with only the symbol ahead of it. Text stays the app's
   ordinary --ink on every tint (inherited, no override below), which is what a light wash
   needs and a dark fill never did. */
.stock-row {
  display: flex;
  flex-wrap: wrap;
  align-items: baseline;
  column-gap: 0.6rem;
  row-gap: 2px;
  width: 100%;
  color: inherit;
  text-align: left;
  font: inherit;
  cursor: pointer;
}

/* A light wash needs a hover that reads as darkening, not brightness(1.12)'s wash-toward-
   white -- which is invisible on a pale surface and would erase the tint outright. The
   shared focus ring (border-color plus box-shadow, from the global button:focus-visible
   rule) is untouched; this only adds the same highlight on top of it that hover gets. */
.stock-row:hover,
.stock-row:focus-visible {
  filter: brightness(0.96);
}

.stock-symbol {
  flex: 0 0 auto;
  font-variant-numeric: tabular-nums;
}

/* flex: none, and margin-left: auto rather than justify-content on the row, so the trailing
   group hugs the trailing edge with only the symbol before it. tabular-nums so the ADX and
   ATR figures column-align down the list -- the same declaration the agenda's chip times
   already read -- rather than each row's digits setting their own widths. */
.stock-row-meta-line {
  display: flex;
  flex: 0 0 auto;
  align-items: baseline;
  gap: 0.5rem;
  margin-left: auto;
  font-variant-numeric: tabular-nums;
}

/* Large text or a narrow phone: the figures (ADX, ATR distances, daily change, age) sit on
   their own wrapping line under the symbol rather than sharing the symbol's line. Nothing
   here clips or hides; the direction glyph and signs are unchanged. */
:root[data-text-size="large"] .stock-row-meta-line {
  flex: 1 1 100%;
  flex-wrap: wrap;
  margin-left: 0;
}

:root[data-text-size="large"] .stock-change-date {
  flex: 1 1 100%;
  margin-left: 0;
}

@media (max-width: 374px) {
  .stock-row-meta-line {
    flex: 1 1 100%;
    flex-wrap: wrap;
    margin-left: 0;
  }

  .stock-change-date {
    flex: 1 1 100%;
    margin-left: 0;
  }
}

/* The score's own visible cue, so a row is never told by its tint alone -- the same
   "colour is never the only cue" rule that pairs every person wash with a named dot. A
   small leading glyph before the symbol, aria-hidden because the visually-hidden
   "Up"/"Down"/"Mixed" word renderAvailableRow already draws says the same thing whole to
   a screen reader. */
.stock-direction {
  flex: none;
  color: var(--stock-row-meta-ink);
  font-size: var(--text-detail-meta);
  line-height: 1;
}

.stock-row-up .stock-direction {
  color: var(--stock-up-status);
}

.stock-row-down .stock-direction {
  color: var(--stock-down-status);
}

.stock-row-up {
  background: var(--stock-up-bg);
  border-color: var(--stock-up-border);
}

.stock-row-down {
  background: var(--stock-down-bg);
  border-color: var(--stock-down-border);
}

.stock-row-flat {
  background: var(--stock-flat-bg);
  border-color: var(--stock-flat-border);
}

/* Applies to all three tints at once: the tint is what --muted fails against, and the
   failure is the same failure whichever hue it is. ADX is the same --muted-based
   .app-row-meta text on the same backgrounds, so it needs the same override. */
.stock-row-up .stock-adx,
.stock-row-down .stock-adx,
.stock-row-flat .stock-adx,
.stock-row-up .stock-age,
.stock-row-down .stock-age,
.stock-row-flat .stock-age {
  color: var(--stock-row-meta-ink);
}

/* The two ATR fields are a fixed dark-green/dark-red pair, not tint-relative like ADX above:
   both distances sit on the same row regardless of that row's own score, so "dark green" and
   "dark red" have to read correctly on all three tints at once. They reuse
   --stock-up-status/--stock-down-status precisely because those were already measured against
   every tint (5.5:1 up / 4.6:1 down / 5.1:1 flat for the green; 7.0:1 / 5.9:1 / 6.5:1 for the
   red) rather than introducing a second pair of colors to verify. */
.stock-atr-green {
  color: var(--stock-up-status);
  font-weight: var(--w-title);
}

.stock-atr-red {
  color: var(--stock-down-status);
  font-weight: var(--w-title);
}

/* The daily percent-change figure: the same fixed dark-green/dark-red pair as the two ATR
   fields above, verified against all three tints for the same reason -- this sits on the row
   regardless of that row's own score tint. A flat or missing value is muted like ADX/age
   instead of tinted either colour, so "0.00%" and "—" both read as neutral rather than as a
   loss. Colour is never the only cue: dailyChangeText's own +/− sign carries the direction
   whether or not a person can see colour at all. */
.stock-daily-change-up {
  color: var(--stock-up-status);
  font-weight: var(--w-title);
}

.stock-daily-change-down {
  color: var(--stock-down-status);
  font-weight: var(--w-title);
}

.stock-daily-change-flat {
  color: var(--stock-row-meta-ink);
}

/* "We do not know" gets its own, unmistakably different treatment from a mixed (score 0)
   row: no tint (the card idiom's own pale surface, untouched) and a dashed border rather
   than the solid card-list line, so the distinction survives greyscale and a glance alike.
   Declared after .stock-row above so its cursor wins over the pointer every other row sets. */
.stock-row-unavailable {
  border-style: dashed;
  cursor: default;
}

.stock-unavailable-label {
  font-weight: var(--w-title);
}

.stock-unavailable-reason {
  flex: 1 1 100%;
}

/* The chart view: header, two stacked timeframe panels, then the ticker strip -- the shape
   this household's existing chart already uses. */
.stock-chart-header {
  display: flex;
  align-items: center;
  gap: 0.6rem;
  flex-wrap: wrap;
  margin-bottom: 0.75rem;
}

.stock-chart-symbol {
  font-variant-numeric: tabular-nums;
  font-weight: var(--w-title);
  font-size: var(--text-detail-title);
}

/* Pushed to the trailing edge of the header regardless of how much room the symbol and name
   take, the upper-right corner a close control reads from -- not the leading edge, where a
   reader's eye lands on it before the thing it closes. */
.stock-chart-close {
  margin-left: auto;
}

.stock-chart-price {
  font-variant-numeric: tabular-nums;
}

/* The chart is a modal: the frame holds one scroller, #stocks-chart, which carries its own
   padding. Wide screens take .app-dialog's centred box; phones get a bottom sheet. */
.stocks-chart-frame {
  overflow: hidden;
}

.stocks-chart-dialog .stock-chart {
  min-height: 0;
  padding: 0.9rem;
  overflow: auto;
  overscroll-behavior: contain;
}

@media (max-width: 30rem) {
  .app-dialog.stocks-chart-dialog {
    width: 100%;
    max-width: 100%;
    margin: auto 0 0;
    max-height: 92vh;
    max-height: 92dvh;
    padding-bottom: env(safe-area-inset-bottom, 0px);
    border-radius: var(--radius-overlay) var(--radius-overlay) 0 0;
  }

  .stocks-chart-dialog .stocks-chart-frame {
    max-height: 92vh;
    max-height: 92dvh;
  }
}

/* Page settings: the gear at the right end of a page header and the sheet it opens. The body
   scrolls; rows fill the width. Wide screens take .app-dialog's centred box, phones a bottom
   sheet, like the stocks chart above. */
.page-settings-button {
  flex: none;
  width: var(--touch-min);
  padding: 0;
}

.page-settings-button svg {
  width: 20px;
  height: 20px;
}

.app-page-header-title + .page-settings-button {
  margin-left: auto;
}

.page-settings-button + .page-settings-button {
  margin-left: 0.5rem;
}

/* Groceries' gear follows Add to Kroger Cart and the propose line, not the title. Hidden
   siblings take no space, so with the cart button hidden the gear is the one auto margin and
   sits at the far right; with it shown the button's own margin-left: auto already pushes the
   pair right, so the gear takes no margin of its own (two auto margins would split the free
   space and strand the button mid-row; the header's column-gap already separates them, and an
   extra margin overflows the row at 360px). The propose line is a full flex line of its own, so
   it is ordered after the gear and wraps below the pair instead of pushing the gear down. */
#groceries-settings-open {
  margin-left: auto;
  align-self: center;
}

#kroger-cart-open:not([hidden]) ~ #groceries-settings-open {
  margin-left: 0;
}

#kroger-cart-progress {
  order: 1;
}

/* At 360px the cart button steps down to the caption size the header's other top buttons use,
   so title, button and gear share one row without the label breaking. */
@media (max-width: 23rem) {
  #kroger-cart-open {
    padding-inline: 10px;
    font-size: var(--text-caption);
  }
}

.page-settings-frame {
  overflow: hidden;
}

.page-settings-body {
  min-height: 0;
  overflow: auto;
  overscroll-behavior: contain;
}

@media (max-width: 30rem) {
  .app-dialog.page-settings-dialog {
    width: 100%;
    max-width: 100%;
    margin: auto 0 0;
    max-height: 92vh;
    max-height: 92dvh;
    padding-bottom: env(safe-area-inset-bottom, 0px);
    border-radius: var(--radius-overlay) var(--radius-overlay) 0 0;
  }

  .page-settings-dialog .page-settings-frame {
    max-height: 92vh;
    max-height: 92dvh;
  }
}

.stock-chart-panels {
  display: flex;
  flex-direction: column;
}

.stock-chart-panel {
  position: relative;
  padding: 1.75rem 0.6rem 0.6rem;
  background: var(--chart-surface);
  border-radius: var(--card-radius);
}

.stock-chart-panel + .stock-chart-panel {
  margin-top: 1px;
  /* --line, tuned for a hairline between two pale cards, is essentially invisible against
     this always-dark panel in light mode -- --chart-line is mixed for this surface
     instead. */
  border-top: 1px solid var(--chart-line);
}

.stock-chart-panel-label {
  position: absolute;
  top: 0.5rem;
  left: 0.6rem;
  margin: 0;
  color: var(--chart-ink);
  font-size: var(--text-detail-meta);
  font-weight: var(--w-title);
}

.stock-chart-unavailable {
  margin: 1.5rem 0 0.5rem;
}

/* The chart's own value labels, in HTML rather than inside the SVG: the SVG scales its two
   axes by wildly different factors (preserveAspectRatio="none", see .stock-bar's own
   comment), so text drawn within it would stretch, while a line under the bars reads at the
   pane's own type. --chart-ink is the panel's own label ink (.stock-chart-panel-label
   above), quieter than the bars the chart is about; tabular-nums keeps the figures
   readable as numbers, matching the row meta line. */
.stock-chart-readout {
  margin: 0.45rem 0 0;
  color: var(--chart-ink);
  font-size: var(--text-detail-meta);
  font-variant-numeric: tabular-nums;
}

/* A horizontal reference line at each end of the price range, so the readout's two named
   values have a place on the chart itself. --chart-line, the panel's own divider tone, and
   screen-pixel strokes for the same reason .stock-bar states them. */
.stock-chart-level {
  stroke: var(--chart-line);
  stroke-width: 1;
  stroke-dasharray: 3 4;
  vector-effect: non-scaling-stroke;
}

/* CSS sizes the box; the SVG's own viewBox plus preserveAspectRatio="none" is what lets it
   scale without this pane recomputing anything on resize. */
.stock-chart-svg {
  display: block;
  width: 100%;
  height: 220px;
}

/* `vector-effect: non-scaling-stroke` makes this width resolve in SCREEN PIXELS, not in the
   viewBox's own units -- which is the whole reason it is here. The viewBox is bars across and
   price down, and `preserveAspectRatio="none"` scales those two axes by wildly different
   factors (about 10x horizontally against 1.7x vertically at 1280px), so a width in user units
   would render a vertical bar ten times thicker than a horizontal tick. Read it as px: a value
   under 1 here is a sub-pixel line and renders as nothing at all. */
.stock-bar {
  stroke-width: 1.25;
  fill: none;
  vector-effect: non-scaling-stroke;
}

.stock-bar-up {
  stroke: var(--stock-up);
}

.stock-bar-down {
  stroke: var(--stock-down);
}

/* The reference chart's own third bar color (see squeezeBarColors in stock-signals.ts): an
   extension past the trailing stop, never a "flat" reading -- the ported formula only ever
   resolves to green, red or this. */
.stock-bar-yellow {
  stroke: var(--stock-yellow);
}

/* Screen pixels, for the same reason as `.stock-bar` above. Dark gray now, not the bright
   near-white #d1d5db this used to be: a person reading this chart should see the OHLC bars
   first and the stop level as quieter context behind them, not the other way around. */
.stock-stop-line {
  stroke: var(--stock-stop-line);
  stroke-width: 1.25;
  stroke-opacity: 0.9;
  vector-effect: non-scaling-stroke;
}

.stock-selector {
  display: flex;
  align-items: center;
  gap: 0.5rem;
  margin-top: 0.75rem;
}

.stock-selector-step {
  flex: none;
  min-width: var(--touch-min);
  min-height: var(--touch-min);
}

.stock-selector-chips {
  display: flex;
  flex: 1 1 0;
  min-width: 0;
  gap: 0.4rem;
  overflow-x: auto;
  overscroll-behavior: contain;
}

.stock-selector-chip {
  flex: none;
  min-height: var(--touch-min);
  padding: 0.25rem 0.6rem;
  font-variant-numeric: tabular-nums;
  font-size: var(--text-detail-meta);
}

/* The chosen chip used to be a solid accent fill of its own. It is the shared
   .app-selected now, keyed off the aria-current the chip already sets -- one chosen
   state in the app, and a fill kept for actions rather than spent on a segment. */

/* The Weather pane: a stack of cards on the shared field, one per question the pane
   answers -- now, alerts, the next two hours, the next day, the week, the details -- with
   the radar slot the next slice fills already holding its place in the order. Everything
   here composes the shared primitives (.app-card, .app-row, .app-tag, .app-divider-list,
   .app-field, .app-quiet-button); the rules below only position them and size the few
   things that are genuinely this pane's own: the one big temperature, the two scrollable
   strips, and the two-column desktop read. The section itself is an .app-scroller-field,
   which is where the pane's gutters and its bottom clearance come from -- the same place
   Stocks' and Groceries' cards get theirs, so nothing is restated here. */
.weather-title:focus {
  outline: none;
}

/* On-wall lines between the toolbar and the card stack, matching #stocks-source's own
   zero-margin read inside the header's subtitle. */
#weather-updated {
  margin: 0;
}

#weather-status,
#weather-failure {
  margin: 0.35rem 0 0.25rem;
}

/* The toolbar carries the pane's live controls (the location select, Places, the units
   select, refresh), so it keeps the shared bar's shape -- but it sits inside the padded
   field like Meals' and Stocks' own bars, so it takes the "not a band" zeroing rather
   than drawing a second surface on the wallpaper. It wraps rather than clipping: the
   controls do not fit one row at 360px and should not try to. */
.weather .app-toolbar {
  flex-wrap: wrap;
  background: transparent;
  border-bottom: 0;
}

/* Capped on a desktop, where an unbounded select would stretch across the whole pane; it
   still shrinks on a phone. */
#weather-location {
  flex: 1 1 9rem;
  min-width: 0;
  min-height: var(--touch-min);
  max-width: 20rem;
}

/* A row title that is a <label> (tapping it reaches the control) still lays out as the <p> it replaces. */
label.settings-row-title {
  display: block;
}

/* One rhythm between the cards, the gap the card idioms share. The two column wrappers
   stack on a phone; the desktop block below sets them side by side. */
.weather-body,
.weather-col {
  display: flex;
  flex-direction: column;
  gap: var(--card-gap);
}

.weather-col {
  min-width: 0;
}

/* Timers: the pane's body is one column of the add controls and the two collapsible groups,
   with the shared card gap between rows. The countdown is the row's big number, tabular so
   it does not jitter as digits change each second. A ringing timer or alarm takes an accent
   ring and the subtle panel ground. The switch is a native checkbox (role="switch") with its
   track drawn beside it, so the input keeps its own focus and keyboard behaviour. */
.timers-body,
.timers-add {
  display: flex;
  flex-direction: column;
  gap: var(--card-gap);
}

.timers-messages:empty {
  display: none;
}

/* The toolbar's own add controls: they take the free width and wrap, with Refresh kept at
   the end. An empty slot (a browser that cannot ring) takes no room. */
.timers-toolbar-controls {
  display: flex;
  flex: 1 1 auto;
  flex-wrap: wrap;
  align-items: center;
  gap: 0.4rem;
  min-width: 0;
}

.timers-toolbar-controls:empty {
  display: none;
}

#timers-refresh {
  flex: none;
  margin-left: auto;
}

/* The toolbar is a card inside the field's own gutter, not the shared primitive's
   edge-to-edge band: the field already pads it at --list-gutter, so only the ground's
   square corners and bottom rule are zeroed and the card surface drawn in their place. */
.timers > .app-toolbar {
  padding: var(--card-pad);
  border-bottom: 0;
  border-radius: var(--card-radius);
  box-shadow: var(--shadow-card);
}

/* The page's one capability sentence reads as the toolbar card's caption on the wallpaper,
   at the card's own text inset. */
.timers-body > .app-note {
  margin: 0;
  padding-inline: var(--card-pad-x);
  color: var(--ink-on-wall-muted);
  font-size: var(--text-support);
  text-shadow: var(--text-shadow-on-wall);
}


.timers-quick,
.timers-days,
.timers-row-actions {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: 0.4rem;
}

.timers-form {
  display: flex;
  flex-direction: column;
  gap: 0.6rem;
}

.timers-field {
  display: flex;
  flex-direction: column;
  gap: 0.2rem;
}

/* Presets, repeat days and row actions all meet the touch floor and wrap with the text. */
.timers-quick > *,
.timers-days > *,
.timers-row-actions > * {
  min-height: var(--touch-min);
}

.timers-row {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  justify-content: space-between;
  gap: 0.6rem;
}

.timers-row-text {
  flex: 1 1 10rem;
  min-width: 0;
  overflow-wrap: break-word;
}

.timers-row-ringing {
  background: var(--panel-subtle);
  box-shadow: inset 0 0 0 2px var(--accent);
}

.timers-countdown,
.timers-alarm-time {
  margin: 0;
  font-size: calc(var(--text-detail-title) * 1.5);
  font-weight: var(--w-title);
  font-variant-numeric: tabular-nums;
  line-height: 1.15;
}

.timers-switch {
  position: relative;
  display: inline-flex;
  align-items: center;
  min-width: 44px;
  min-height: 44px;
  justify-content: center;
}

.timers-switch input {
  position: absolute;
  inset: 0;
  margin: 0;
  opacity: 0;
  cursor: pointer;
}

.timers-switch-track {
  position: relative;
  width: 2.4rem;
  height: 1.4rem;
  background: var(--line);
  border-radius: var(--radius-pill);
  transition: background var(--motion-press, 0.12s) ease;
}

.timers-switch-track::after {
  content: "";
  position: absolute;
  top: 0.2rem;
  left: 0.2rem;
  width: 1rem;
  height: 1rem;
  background: var(--muted);
  border-radius: 50%;
  transition: transform var(--motion-press, 0.12s) ease, background var(--motion-press, 0.12s) ease;
}

.timers-switch input:checked + .timers-switch-track {
  background: var(--accent);
}

.timers-switch input:checked + .timers-switch-track::after {
  background: var(--panel);
}

.timers-switch input:checked + .timers-switch-track::after {
  transform: translateX(1rem);
}

.timers-switch input:focus-visible + .timers-switch-track {
  outline: 2px solid var(--accent);
  outline-offset: 2px;
}

@media (prefers-reduced-motion: reduce) {
  .timers-switch-track,
  .timers-switch-track::after {
    transition: none;
  }
}

/* Fixed heights, so the skeletons hold each panel's rough shape while the first read is
   in flight -- a stack of 1rem slivers would say nothing about what is coming. */
.weather-skeleton-now {
  height: 8rem;
}

.weather-skeleton-strip {
  height: 7rem;
}

.weather-skeleton-card {
  height: 7rem;
}

/* NOW: the condition icon, the one big temperature and the place it belongs to on one
   line; everything else reads beneath at the body step. The big figure is the pane's one
   deliberate departure from the three-step scale -- the same px clamp the page title's
   own larger figure uses, sized by the viewport so it fits a phone and leads a desktop
   column. */
/* The card is the containing block for its visually-hidden h2, which is position: absolute;
   without it that heading escapes the #weather scroller and lengthens the document. */
.weather-now {
  position: relative;
}

.weather-now-head {
  display: flex;
  align-items: center;
  gap: 0.75rem;
}

.weather-now-head .weather-icon svg {
  width: 28px;
  height: 28px;
  stroke-width: 1.5;
}

.weather-now-temp {
  flex: 1 1 auto;
  min-width: 0;
}

.weather-now-temperature {
  margin: 0;
  font-family: var(--font-display);
  font-size: clamp(44px, 12vw, 52px);
  font-weight: var(--w-title);
  line-height: var(--leading-detail-tight);
  font-variant-numeric: tabular-nums;
}

.weather-now-unit {
  margin-left: 2px;
  font-size: var(--text-detail-title);
  font-weight: var(--w-meta);
}

.weather-now-condition {
  margin: 0;
  color: var(--ink);
  font-size: var(--text-detail-body);
}

.weather-now-meta {
  margin: 0;
  color: var(--muted);
  font-size: var(--text-detail-meta);
  white-space: normal;
  overflow-wrap: break-word;
}

/* Section headings on this page read one step above the global label. */
.weather-body .app-section-label {
  font-size: var(--text-section);
}

.weather-now-place,
.weather-now-summary,
.weather-now-wind,
.weather-now-fresh {
  margin: 0.25rem 0 0;
}

.weather-now-place,
.weather-now-fresh {
  color: var(--muted);
  font-size: var(--text-detail-meta);
}

/* The two alert states that never earn their own card -- checked and clear, and a country
   the alert service does not cover -- read as one quiet line above the attribution. */
.weather-alerts-quiet {
  margin: 0.2rem 0 0;
  color: var(--ink-on-wall-muted);
  font-size: var(--text-detail-meta);
  text-shadow: var(--text-shadow-on-wall);
}

/* The unsaved-location banner: the sentence and its Retry save button on one wrapping row. */
.weather-save-failed {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: 0.4rem 0.75rem;
}

.weather-save-failed-text {
  flex: 1 1 12rem;
  margin: 0;
}

/* ALERTS: one row per alert, the severity as a word in a tag and the tone as a left
   border -- colour and text together, never one standing in for the other. */
.weather-alert-row {
  display: flex;
  align-items: flex-start;
  gap: 0.5rem;
  padding: 0.5rem 0;
}

.weather-alert-icon {
  display: flex;
  flex: none;
  margin-top: 3px;
  color: var(--warn);
}

.weather-alert-extreme,
.weather-alert-severe {
  border-left: 3px solid var(--error);
  padding-left: 0.6rem;
}

.weather-alert-moderate {
  border-left: 3px solid var(--warn);
  padding-left: 0.6rem;
}

.weather-alert-head {
  display: flex;
  flex-wrap: wrap;
  align-items: baseline;
  gap: 0.2rem 0.4rem;
  margin: 0;
  font-size: var(--text-detail-body);
}

.weather-alert-until {
  color: var(--muted);
  font-size: var(--text-detail-meta);
}

.weather-alert-headline {
  margin: 0.2rem 0 0;
  color: var(--muted);
  font-size: var(--text-detail-meta);
}

.weather-alert-unavailable {
  margin: 0;
  font-size: var(--text-detail-body);
}

.weather-alert-details {
  margin: 0.35rem 0 0;
}

.weather-alert-summary {
  display: flex;
  list-style: none;
  align-items: center;
  min-height: max(48px, var(--touch-min));
  color: var(--muted);
  font-size: var(--text-detail-meta);
  cursor: pointer;
}

/* display: flex drops the native marker, so the indicator is drawn the way
   .weather-near-more-summary draws it. */
.weather-alert-summary::before {
  content: '▸';
  display: inline-block;
  margin-right: 0.4rem;
}

.weather-alert-summary::-webkit-details-marker,
.weather-near-more-summary::-webkit-details-marker {
  display: none;
}

.weather-alert-details[open] > .weather-alert-summary::before {
  transform: rotate(90deg);
}

/* The service's own wording, kept exactly as issued: pre-wrap so its line breaks and
   capitalisation survive being poured into a card. */
.weather-alert-description,
.weather-alert-instruction {
  margin: 0.25rem 0 0;
  white-space: pre-wrap;
}

.weather-alert-source {
  color: var(--accent);
  font-size: var(--text-detail-meta);
}

/* NEAR TERM: the chart glances, the list records. The strip scrolls horizontally on a
   phone with the overscroll contained, so a swipe on the chart does not take the pane
   with it; the text list under it never scrolls, because it wraps. */
.weather-near {
  margin-bottom: 0.5rem;
}

.weather-near-label,
.weather-near-qualifier,
.weather-near-measure,
.weather-near-scale,
.weather-near-missing {
  margin: 0;
  color: var(--muted);
  font-size: var(--text-detail-meta);
}

.weather-near-summary {
  margin: 0;
  color: var(--ink);
  font-size: var(--text-detail-body);
}

.weather-near-resolution {
  margin: 0.25rem 0 0;
  color: var(--muted);
  font-size: var(--text-detail-meta);
}

.weather-near-missing {
  margin-top: 0.25rem;
}

.weather-near-list-title {
  margin: 0.6rem 0 0;
  color: var(--muted);
  font-size: var(--text-detail-meta);
  font-weight: var(--w-title);
}

.weather-near-scroll {
  position: relative;
  overflow-x: auto;
  overscroll-behavior-x: contain;
}

/* The horizontal scrollers fade at the right edge while there is more to scroll to. */
.weather-near-scroll[data-more-right],
.weather-hourly-scroll[data-more-right] {
  -webkit-mask-image: linear-gradient(to right, #000 calc(100% - 28px), transparent);
  mask-image: linear-gradient(to right, #000 calc(100% - 28px), transparent);
}

/* The chart is fixed-geometry HTML, not a scaling graphic: eight columns of at least 38px
   over a 64px plot, so a 360px phone scrolls it instead of squashing it (the script sets
   the minimum width from the column count). */
.weather-near-chart {
  padding-top: 1.1rem;
  color: var(--muted);
  font-size: var(--text-detail-meta);
  font-variant-numeric: tabular-nums;
}

.weather-near-more {
  margin-top: 0.25rem;
}

.weather-near-more-summary {
  display: flex;
  list-style: none;
  align-items: center;
  min-height: max(48px, var(--touch-min));
  color: var(--muted);
  font-size: var(--text-detail-meta);
  cursor: pointer;
}

.weather-near-more-summary::before {
  content: '▸';
  display: inline-block;
  margin-right: 0.4rem;
}

.weather-near-more[open] > .weather-near-more-summary::before {
  transform: rotate(90deg);
}

.weather-near-cols {
  position: relative;
  display: grid;
}

/* The now marker: a 2px line over the plot height, on the left edge above the 'Now' tick. */
.weather-near-now {
  position: absolute;
  top: 0;
  left: 0;
  height: 64px;
  width: 2px;
  background: var(--accent);
  pointer-events: none;
}

.weather-near-now-label {
  position: absolute;
  bottom: 100%;
  left: 50%;
  transform: translateX(-50%);
  color: var(--accent);
  font-size: var(--text-detail-meta);
  line-height: 1;
}

.weather-near-axis {
  position: relative;
  height: 1.2rem;
  margin-top: 0.25rem;
  color: var(--muted);
  font-size: var(--text-detail-meta);
  font-variant-numeric: tabular-nums;
}

.weather-near-tick {
  position: absolute;
  top: 0;
  white-space: nowrap;
}

.weather-near-col {
  display: flex;
  flex-direction: column;
  min-width: 0;
}

/* The bottom border is the baseline, continuous across the columns. */
.weather-near-plot {
  position: relative;
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: flex-end;
  height: 64px;
  border-bottom: 1px solid var(--line-strong);
}

.weather-near-bar {
  width: 60%;
  max-width: 28px;
  background: var(--icon-blue);
}

.weather-near-bar-light {
  background: color-mix(in srgb, var(--icon-blue) 70%, transparent);
}

.weather-near-bar-heavy {
  background: color-mix(in srgb, var(--icon-blue) 75%, var(--ink));
}

/* An unknown interval: a full-height hatched bar, never a bar at zero. */
.weather-near-nodata {
  width: 60%;
  max-width: 28px;
  height: 100%;
  background: repeating-linear-gradient(135deg, color-mix(in srgb, var(--muted) 45%, transparent) 0 2px, transparent 2px 6px);
}

.weather-near-list {
  margin: 0.5rem 0 0;
  padding: 0;
  list-style: none;
  font-variant-numeric: tabular-nums;
}

.weather-near-entry {
  padding: 0.3rem 0;
}

.weather-near-note {
  margin: 0.4rem 0 0;
  color: var(--muted);
  font-size: var(--text-detail-meta);
}

/* The one shared size for a condition glyph, wherever one renders. */
.weather-icon {
  display: flex;
  flex: none;
  color: var(--icon-gold);
}

.weather-icon svg {
  width: 20px;
  height: 20px;
  stroke-width: 1.7;
}

/* TODAY CARD: the weather card between Today's clock and its day. The slot holds the loaded
   card's height whether it holds the skeleton, the card or a failure line, and is flex: none
   because Today is a flex column that would otherwise collapse an empty slot -- so nothing
   below it moves when the forecast arrives. The card is one tap target; text wraps, never
   truncates, so the card may grow past the reserved minimum. */
.today-weather-slot {
  flex: none;
  min-height: 88px;
  margin-bottom: 13px;
}

.today-weather-skeleton {
  height: 88px;
  border-radius: var(--card-radius);
}

:root[data-text-size="large"] .today-weather-slot {
  min-height: 112px;
}

:root[data-text-size="large"] .today-weather-skeleton {
  height: 112px;
}

.today-weather-card {
  display: grid;
  grid-template-columns: auto minmax(0, 1fr) auto;
  align-items: center;
  gap: 12px;
  box-sizing: border-box;
  width: 100%;
  min-height: 88px;
  padding: 12px 14px;
  color: inherit;
  font: inherit;
  text-align: left;
  cursor: pointer;
}

:root[data-text-size="large"] .today-weather-card {
  min-height: 112px;
}

.today-weather-glyph {
  display: flex;
  align-items: center;
  justify-content: center;
  width: 30px;
  height: 30px;
}

.today-weather-glyph .weather-icon svg {
  width: 30px;
  height: 30px;
}

.today-weather-main {
  display: flex;
  flex-direction: column;
  gap: 2px;
  min-width: 0;
}

/* One line of parts joined by a dot that is CSS-only: each .dot-part draws its own leading
   dot in a --dot-sep-wide slot, and the .dot-row is pulled left by that width inside a
   .dot-line that clips horizontally -- so the part that starts a wrapped line has its dot in
   the clipped slot and no separator is ever stranded at a line edge. overflow-x: clip leaves
   the vertical axis alone, so descenders and focus outlines are not cut. */
.dot-line {
  --dot-sep: 0.75rem;
  display: block;
  min-width: 0;
  overflow-x: clip;
}

.dot-row {
  display: flex;
  flex-wrap: wrap;
  align-items: baseline;
  margin-left: calc(-1 * var(--dot-sep));
}

.dot-part {
  display: flex;
  box-sizing: border-box;
  align-items: baseline;
  min-width: 0;
  max-width: 100%;
  white-space: normal;
}

.dot-part::before {
  content: '·';
  flex: none;
  width: var(--dot-sep);
  font-weight: normal;
  line-height: 1;
  text-align: center;
}

.dot-part:empty,
.dot-part[hidden] {
  display: none;
}

.today-weather-now,
.today-weather-meta {
  line-height: 1.25;
}

.today-weather-hl,
.today-weather-freshness {
  display: contents;
}

.today-weather-temperature {
  color: var(--ink);
  font-family: var(--font-display);
  /* Display numeral: px like .weather-now-temperature's, not a new detail-text rem step. */
  font-size: 32px;
  font-weight: var(--w-title);
  line-height: 1.1;
  font-variant-numeric: tabular-nums;
}

:root[data-text-size="small"] .today-weather-temperature {
  font-size: 30px;
}

:root[data-text-size="large"] .today-weather-temperature {
  font-size: 36px;
}

.today-weather-condition,
.today-weather-outlook-text {
  font-size: var(--text-detail-body);
  line-height: 1.25;
  overflow-wrap: anywhere;
}

.today-weather-meta {
  color: var(--muted);
  font-size: var(--text-detail-meta);
}

.today-weather-place {
  overflow-wrap: anywhere;
}

.today-weather-outlook-text {
  flex: 1 1 auto;
}

.today-weather-outlook-until {
  white-space: nowrap;
}

.weather-now-part,
.today-weather-temperature,
.today-weather-part {
  white-space: nowrap;
}

.today-weather-chevron {
  display: flex;
  color: var(--muted);
}

.today-weather-chevron svg {
  width: 18px;
  height: 18px;
}

.today-weather-outlook {
  display: flex;
  grid-column: 2 / -1;
  align-items: flex-start;
  gap: 6px;
  min-width: 0;
}

.today-weather-outlook[hidden] {
  display: none;
}

:root[data-text-size="large"] .today-weather-outlook {
  grid-column: 1 / -1;
}

@media (max-width: 374px) {
  .today-weather-outlook {
    grid-column: 1 / -1;
  }
}

.today-weather-outlook-alert {
  border-left: 3px solid var(--error);
  padding-left: 0.4rem;
}

.today-weather-alert-icon {
  display: flex;
  flex: none;
  color: var(--warn);
}

.today-weather-alert-icon[hidden] {
  display: none;
}

.today-weather-alert-icon svg {
  width: 18px;
  height: 18px;
}

/* HOURLY: a strip the scroller owns, focusable so it is reachable from a keyboard. */
.weather-hourly-scroll {
  position: relative;
  overflow-x: auto;
  overscroll-behavior-x: contain;
}

.weather-hourly-list {
  display: flex;
  width: max-content;
  margin: 0;
  padding: 0.1rem 0.1rem 0.35rem;
  list-style: none;
}

.weather-hour {
  position: relative;
  display: flex;
  flex: none;
  flex-direction: column;
  align-items: center;
  gap: 0.15rem;
  min-width: 3.75rem;
  padding: 0.2rem 0.2rem 0;
  text-align: center;
}

:root[data-text-size="large"] .weather-hour {
  min-width: 5rem;
}

.weather-hour-condition {
  position: relative;
  display: flex;
}

.weather-hour-time {
  color: var(--muted);
  font-size: var(--text-detail-meta);
  font-variant-numeric: tabular-nums;
  white-space: nowrap;
}

.weather-hour-temp {
  font-size: var(--text-detail-body);
  font-variant-numeric: tabular-nums;
}

.weather-hour-pop {
  position: relative;
  display: flex;
  align-items: center;
  gap: 2px;
  min-height: 1rem;
  color: var(--muted);
  font-size: var(--text-detail-meta);
}

.weather-hour-drop {
  display: flex;
  color: var(--icon-blue);
}

.weather-hour-drop svg {
  width: 13px;
  height: 13px;
}

/* DAILY: seven days as ONE grouped surface -- each group is a day's row button over its own
   collapsible detail, the hairline between groups being .app-divider-list's own, no per-row cards. The expand is a plain
   hidden toggle with no transition of its own anywhere, so prefers-reduced-motion needs
   no override here -- nothing moves except the region appearing. The card is a size
   container, so the two-line layout follows the card's width, not the viewport's. */
.weather-daily {
  container-type: inline-size;
}

.weather-day {
  display: grid;
  grid-template-columns: 6.5em 20px minmax(0, 1fr) auto auto;
  align-items: center;
  column-gap: 0.6rem;
  min-height: 48px;
  font-variant-numeric: tabular-nums;
}

.weather-day-name {
  min-width: 0;
  font-weight: var(--w-title);
  white-space: nowrap;
}

.weather-day-short {
  display: none;
}

.weather-day-condition {
  min-width: 0;
  overflow-wrap: break-word;
}

.weather-day-temps {
  white-space: nowrap;
}

.weather-day-high {
  color: var(--ink);
  white-space: nowrap;
}

.weather-day-low {
  color: var(--muted);
  white-space: nowrap;
}

.weather-day-pop {
  position: relative;
  display: inline-flex;
  align-items: center;
  justify-content: flex-end;
  gap: 2px;
  min-width: 3.4em;
  color: var(--muted);
  font-size: var(--text-detail-meta);
  white-space: nowrap;
}

.weather-day-drop {
  display: flex;
  color: var(--icon-blue);
}

.weather-day-drop svg {
  width: 13px;
  height: 13px;
}

/* A narrow card shows the short weekday, which frees the room the condition needs. */
@container (max-width: 30rem) {
  .weather-day-long {
    display: none;
  }

  .weather-day-short {
    display: inline;
  }
}

/* Large text or a very narrow phone: each day reads as two lines -- day and condition on the
   first, high/low and precipitation on the second -- not five squeezed columns. Children are
   name, icon, condition, temps, precipitation. */
:root[data-text-size="large"] .weather-day {
  grid-template-columns: 20px auto minmax(0, 1fr) auto;
  row-gap: 0.15rem;
}

:root[data-text-size="large"] .weather-day > :nth-child(1) { grid-column: 2; grid-row: 1; }
:root[data-text-size="large"] .weather-day > :nth-child(2) { grid-column: 1; grid-row: 1 / span 2; }
:root[data-text-size="large"] .weather-day > :nth-child(3) { grid-column: 3 / -1; grid-row: 1; }
:root[data-text-size="large"] .weather-day > :nth-child(4) { grid-column: 2 / 4; grid-row: 2; }
:root[data-text-size="large"] .weather-day > :nth-child(5) { grid-column: 4; grid-row: 2; }

@container (max-width: 30rem) {
  .weather-day {
    grid-template-columns: 20px auto minmax(0, 1fr) auto;
    row-gap: 0.15rem;
    column-gap: 0.4rem;
  }

  .weather-day > :nth-child(1) { grid-column: 2; grid-row: 1; }
  .weather-day > :nth-child(2) { grid-column: 1; grid-row: 1 / span 2; }
  .weather-day > :nth-child(3) { grid-column: 3 / -1; grid-row: 1; }
  .weather-day > :nth-child(4) { grid-column: 2 / 4; grid-row: 2; }
  .weather-day > :nth-child(5) { grid-column: 4; grid-row: 2; }
}

.weather-day-detail {
  padding: 0.15rem 0.3rem 0.55rem;
}

.weather-day-detail-line {
  margin: 0.15rem 0 0;
  color: var(--muted);
  font-size: var(--text-detail-meta);
  font-variant-numeric: tabular-nums;
}

/* DETAILS: four label-and-value pairs, as many to a row as the card has room for. */
.weather-details-grid {
  display: grid;
  grid-template-columns: repeat(2, minmax(0, 1fr));
  gap: 0.5rem 1rem;
  margin: 0;
}

:root[data-text-size="large"] .weather-details-grid {
  grid-template-columns: minmax(0, 1fr);
}

@media (max-width: 374px) {
  .weather-details-grid {
    grid-template-columns: minmax(0, 1fr);
  }
}

.weather-detail-label {
  margin: 0;
  color: var(--muted);
  font-size: var(--text-detail-meta);
}

.weather-detail-value {
  margin: 0.05rem 0 0;
  overflow-wrap: break-word;
  font-variant-numeric: tabular-nums;
}

/* RADAR: an on-demand card. The map is a square viewport (up to 420px) over a 3x3 block of
   tiles the script sizes and offsets in percentages, so the location sits at its centre at
   any card width; the radar layer is the translucent one over the basemap. Nothing on it
   animates or transitions -- playback is the script stepping frames, never a CSS motion. */
.weather-radar-note {
  margin: 0.3rem 0 0.6rem;
}

.weather-radar-map {
  position: relative;
  width: 100%;
  max-width: 420px;
  aspect-ratio: 1;
  overflow: hidden;
  background: var(--panel);
  border-radius: var(--radius-control);
}

.weather-radar-block {
  position: absolute;
}

.weather-radar-layer {
  position: absolute;
  inset: 0;
}

.weather-radar-layer-radar {
  opacity: 0.75;
}

.weather-radar-tile {
  position: absolute;
  display: block;
  max-width: none;
  user-select: none;
}

.weather-radar-marker {
  position: absolute;
  top: 50%;
  left: 50%;
  width: 14px;
  height: 14px;
  box-sizing: border-box;
  border: 3px solid var(--accent);
  border-radius: 50%;
  background: var(--panel-subtle);
  transform: translate(-50%, -50%);
  pointer-events: none;
}

.weather-radar-controls {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: 0.4rem;
  max-width: 420px;
  margin-top: 0.6rem;
}

.weather-radar-controls .app-quiet-button {
  transition: none;
}

.weather-radar-range {
  flex: 1 1 8rem;
  min-width: 0;
  accent-color: var(--accent);
}

.weather-radar-frame {
  margin: 0.5rem 0 0;
  font-variant-numeric: tabular-nums;
}

.weather-radar-stale {
  margin: 0.3rem 0 0;
}

.weather-radar-stale[hidden] {
  display: none;
}

.weather-radar-attribution {
  margin-top: 0.6rem;
}

/* SIMPLE VIEW: the glance layout. High contrast (--ink on the card, no muted text), the big
   figure larger than the Now card's, controls at least 56px tall, and no motion. One column
   on a phone held upright; two (the figure and place, then everything to act on) wherever
   there is room, which includes a phone turned sideways -- 960x540 must not scroll. */
.weather-simple {
  display: flex;
  flex-direction: column;
  gap: var(--card-gap);
  color: var(--ink);
}

.weather-simple-main,
.weather-simple-side {
  display: flex;
  flex-direction: column;
  align-items: flex-start;
  gap: 0.5rem;
  min-width: 0;
}

.weather-simple-temperature {
  margin: 0;
  font-family: var(--font-display);
  font-size: clamp(56px, 12vw, 120px);
  font-weight: var(--w-title);
  line-height: var(--leading-detail-tight);
  font-variant-numeric: tabular-nums;
}

.weather-simple-head {
  display: flex;
  align-items: center;
  gap: 0.6rem;
}

.weather-simple-head .weather-icon svg {
  width: 40px;
  height: 40px;
}

.weather-simple-condition,
.weather-simple-location,
.weather-simple-summary {
  margin: 0;
  font-size: var(--text-detail-title);
  font-weight: var(--w-title);
}

.weather-simple-line,
.weather-simple-alert {
  margin: 0;
  font-size: var(--text-detail-title);
}

.weather-simple-alerts {
  display: flex;
  flex-direction: column;
  gap: 0.5rem;
  width: 100%;
}

.weather-simple-alert {
  display: flex;
  align-items: center;
  gap: 0.6rem;
  padding: 0.6rem 0.75rem;
  border-left: 4px solid var(--warn);
  border-radius: var(--radius-control);
  background: var(--panel);
  font-weight: var(--w-title);
}

.weather-simple-alert-severe {
  border-left-color: var(--error);
}

.weather-simple-alert .weather-alert-icon svg {
  width: 28px;
  height: 28px;
}

.weather-simple-actions {
  display: flex;
  gap: 0.75rem;
  width: 100%;
}

.weather-simple-button {
  flex: 1 1 0;
  min-height: 56px;
  background: var(--panel);
  font-size: var(--text-detail-title);
  transition: none;
}

.weather-simple-updated {
  margin: 0;
  color: var(--muted);
  font-size: var(--text-detail-meta);
}

/* In the glance layout the page header and most of the toolbar go: the title stays in the
   DOM (focus lands on it on open) but reads as .visually-hidden does, the "Updated" subtitle
   moves into the card, and only the location select and refresh remain. */
.weather-is-simple .weather-title {
  position: absolute;
  width: 1px;
  height: 1px;
  overflow: hidden;
  clip-path: inset(50%);
}

.weather-is-simple .app-page-header-subtitle {
  display: none;
}

@media (min-width: 48rem), (orientation: landscape) and (max-height: 38rem) {
  .weather-simple {
    display: grid;
    grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
    align-items: center;
    gap: 1rem 1.5rem;
  }

  /* The buttons sit under the figure and place in the left column, clear of the alert rows. */
  .weather-simple-main {
    grid-column: 1;
    grid-row: 1;
  }

  .weather-simple-side {
    grid-column: 2;
    grid-row: 1 / span 2;
  }

  .weather-simple-actions {
    grid-column: 1;
    grid-row: 2;
    align-self: start;
  }
}

@media (orientation: landscape) and (max-height: 38rem) {
  .weather-simple {
    gap: 0.5rem 1.5rem;
    padding-block: 0.6rem;
  }

  .weather-simple-main,
  .weather-simple-side {
    gap: 0.3rem;
  }

  .weather-simple-temperature {
    font-size: clamp(56px, 16vh, 120px);
  }

  .weather-simple-head .weather-icon svg {
    width: 32px;
    height: 32px;
  }
}

.weather-attribution {
  margin: 0.2rem 0 0;
  color: var(--muted);
  font-size: var(--text-detail-meta);
}

.weather-attribution-link {
  color: var(--accent);
}

/* SETUP: the first-run card, and the places manager that replaces it once a home exists.
   Both draw the one search-row shape: a field that grows and a Search button that does
   not. */
.weather-setup-note {
  margin: 0.3rem 0 0.75rem;
}

.weather-setup-locate {
  margin-top: 0.75rem;
}

.weather-search-row {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: 0.4rem;
}

.weather-search-input {
  flex: 1 1 8rem;
  min-width: 0;
}

.weather-search-results {
  display: flex;
  flex-direction: column;
  gap: 0.35rem;
  margin-top: 0.5rem;
}

.weather-place-result {
  text-align: left;
}

.weather-places-header {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 0.5rem;
}

.weather-places-list {
  display: flex;
  flex-direction: column;
  margin-bottom: 0.75rem;
}

.weather-place-row {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: 0.4rem;
  padding: 0.35rem 0;
}

.weather-place-name {
  flex: 1 1 auto;
  min-width: 0;
}

.weather-retry {
  display: flex;
  justify-content: center;
}

/* Desktop: the body becomes a two-column grid of the two column wrappers -- now, alerts,
   near and hourly in the wide one, the week, details and radar in the narrow one, both
   starting at the top. The wrappers keep the DOM order the phone stack reads in. The
   places manager, the setup and retry cards and the attribution span both columns. */
@media (min-width: 60rem) {
  .weather-body {
    display: grid;
    grid-template-columns: minmax(0, 1.2fr) minmax(0, 1fr);
    align-items: start;
    width: 100%;
    max-width: 72rem;
    margin-inline: auto;
  }

  .weather-places,
  .weather-setup,
  .weather-retry,
  .weather-simple,
  .weather-save-failed,
  .weather-alerts-quiet,
  .weather-attribution {
    grid-column: 1 / -1;
  }
}

/* Phone: compact enough that the header, the Now card, the next-two-hours chart and the
   start of the hourly strip sit above the fold at 390x844. Type is not shrunk; the room
   comes from padding, gaps and moving lines into shared rows. */
@media (max-width: 47.99rem) {
  .weather {
    --card-pad-y: 10px;
    --card-pad-x: 14px;
    --card-pad: var(--card-pad-y) var(--card-pad-x);
    --card-gap: 8px;
  }

  /* Basis 100% (the shared slot's own) keeps the last-updated line wrapping below the title
     row, so the gear's auto margin has the free space and sits at the right edge. */
  .weather .app-page-header-subtitle {
    flex: 1 1 100%;
    min-width: 0;
    white-space: normal;
    overflow: visible;
    text-overflow: clip;
    line-height: 1.3;
  }

  .weather .app-toolbar {
    flex-wrap: wrap;
    padding-block: 0.2rem;
    padding-inline: 0;
  }

  .weather .app-toolbar-title {
    display: none;
  }

  #weather-location {
    flex: 1 1 8rem;
    min-width: 8rem;
    max-width: none;
  }

  #weather-refresh {
    flex: none;
  }

  #weather-status,
  #weather-failure {
    margin: 0.1rem 0;
  }

  #weather-status:empty {
    display: none;
  }

  .weather .app-section-label {
    margin-bottom: 0.3rem;
  }
}

/* The Library pane: same shell as .groceries and .stocks -- a pale scrolling field over the
   shared chrome, no chrome of its own. */
.library[hidden] {
  display: none;
}

/* .app-card-list sets its own display: flex, which otherwise outranks the browser's default
   [hidden] rule at equal specificity -- the same footgun .groceries[hidden]/.stocks[hidden]
   exist to fix for their own panes. Without this, toggling the list's hidden attribute while
   switching to the detail view leaves it visibly stacked above the detail it was replaced by. */
.library-list[hidden],
.library-detail[hidden] {
  display: none;
}

.library-title:focus {
  outline: none;
}

/* This line sits directly on the wallpaper, below the search/filter card rather than
   inside it -- the on-wall ink pair every caption between cards reads through, not the
   plain --muted this used to read (tuned for a card, not the wallpaper it actually sat
   on). */
.library-note {
  margin: 6px 0 10px;
  padding-inline: var(--card-pad-x);
  color: var(--ink-on-wall-muted);
  text-shadow: var(--text-shadow-on-wall);
  font-size: var(--text-support);
}

/* The empty state's inline mark: the pane's own bookmark glyph at the icon-button size,
   in the empty line's own ink -- decoration beside the sentence, never a substitute
   for it. */
.library-empty-icon {
  display: inline-flex;
  vertical-align: -3px;
  margin-right: 0.35rem;
}

.library-empty-icon svg {
  width: 18px;
  height: 18px;
  fill: none;
  stroke: currentColor;
  stroke-width: 1.8;
  stroke-linecap: round;
  stroke-linejoin: round;
}

/* The search/filter card: a gap below it before the on-wall note, in place of the controls'
   own former bottom margin now that .app-card's own padding is the inset around them. */
.library-controls-card {
  margin-bottom: var(--card-gap);
}

.library-controls {
  display: flex;
  flex-wrap: wrap;
  gap: 0.5rem;
}

.library-search {
  flex: 1 1 14rem;
  min-width: 0;
  min-height: var(--touch-min);
}

.library-type-filter {
  flex: none;
  max-width: 100%;
  min-height: var(--touch-min);
}

.library-status,
.library-failure {
  margin: 0.3rem 0 0;
}

.library-failure[hidden] {
  display: none;
}

/* One card per saved source -- the card idiom, not the divider list, because a saved email
   and a saved photo are each a thing in their own right, exactly the reasoning .stock-row
   already gives. The whole card is the control: a plain reset button, not a link, since it
   opens an in-page detail view rather than navigating anywhere. */
.library-row {
  display: flex;
  align-items: flex-start;
  gap: 0.6rem;
  width: 100%;
  color: inherit;
  font: inherit;
  text-align: left;
  cursor: pointer;
}

.library-row:hover,
.library-row:focus-visible {
  border-color: var(--accent);
}

.library-row-main {
  display: flex;
  flex: 1 1 0;
  flex-direction: column;
  gap: 0.1rem;
  min-width: 0;
  overflow-wrap: break-word;
}

/* Large text: the type badge drops onto its own line above the title instead of narrowing it. */
:root[data-text-size="large"] .library-row {
  flex-wrap: wrap;
}

:root[data-text-size="large"] .library-row-main {
  flex-basis: 100%;
}

.library-type-badge {
  flex: none;
  padding: 0.15rem 0.5rem;
  /* A type badge names a kind, so it reads a step heavier than an ordinary marker. */
  font-weight: var(--w-title);
  white-space: nowrap;
}

.library-type-email { color: var(--accent); }
.library-type-image { color: var(--library-image-ink); }
.library-type-note { color: var(--library-note-ink); }
.library-type-link { color: var(--library-link-ink); }

/* Back and title are the shared .app-back-bar's now; the entry itself is one .app-card. */
/* #library-detail is already the outer .app-card, so the entry inside it adds no second
   inset (or second surface): one level of padding, the outer card's own. */
.library-detail-card {
  padding: 0;
  background: none;
  border: 0;
  box-shadow: none;
  overflow-wrap: anywhere;
}

.library-detail-body {
  margin: 0.6rem 0;
}

.library-field {
  margin: 0 0 0.3rem;
  font-size: var(--text-detail-body);
}

.library-gmail-link,
.library-link-url {
  display: inline-block;
  margin: 0.2rem 0 0.5rem;
}

.library-email-body,
.library-note-text {
  white-space: pre-wrap;
  margin: 0.5rem 0;
}

.library-attachment {
  display: block;
  margin: 0.2rem 0;
}

.library-omitted {
  margin: 0.1rem 0;
}

.library-image {
  max-width: 100%;
}

.library-linked-events {
  margin: 0.3rem 0;
  padding-left: 1.1rem;
  font-size: var(--text-detail-body);
}

.library-remove {
  margin-top: 0.75rem;
  color: var(--library-note-ink);
  border-color: var(--library-remove-border);
}

/* Outcomes */

/* The Outcomes pane: the same shell as the Library above -- the pane is itself the scroller
   on the shared card field, and every text element in it already carries one of the shared
   type classes, so nothing here sets a size. */
.outcomes-status,
.outcomes-failure {
  margin: 0.3rem 0 0.6rem;
}

/* The list itself carries no ground of its own (.app-card-list); an entry that exists gets
   its own card (.outcome-row), but the whole-list empty state has no card to sit in, so it
   is the one thing left directly on the wallpaper -- the same "empty list, on-wall text"
   idiom Meals'/Groceries'/the Library's own list empty states use. */
#outcomes-list > .app-empty {
  color: var(--ink-on-wall-muted);
  text-shadow: var(--text-shadow-on-wall);
}

.outcome-row {
  display: flex;
  flex-direction: column;
  gap: 0.15rem;
  /* Meals'/Groceries' own row rhythm rather than .app-card's full 12px frame: an outcome
     row's content is one line plus a footer, and at the default pad the backlog read as a
     stack of heavy boxes. */
  padding: 0.55rem 0.7rem;
  /* Status, timestamps, next actions and the disclosure's label all wrap. */
  overflow-wrap: break-word;
}

/* The row's detail disclosure -- the same arrow-plus-aria-expanded contract the Meals
   saved-recipes toggle and the review dialog's recipe disclosures follow (chat.js drives
   it through one delegated listener that calls the shared syncDisclosure). Collapsed by
   default: when/summary/the resolvable badge are the scan, and the closed
   domain/action/category/stage vocabulary underneath is the second read. */
.outcome-detail-toggle {
  align-self: flex-start;
  margin-top: 0.1rem;
  min-height: var(--touch-min);
  text-align: left;
  white-space: normal;
}

.outcome-detail[hidden] {
  display: none;
}

.outcome-row-footer {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: 0.5rem;
  margin-top: 0.2rem;
}

/* The one tag in the app that names a person's own next step rather than a state: the
   accent ink and its own border tint set it apart from "Needs a developer". */
.outcome-resolvable-self {
  color: var(--accent-ink);
  border-color: color-mix(in srgb, var(--accent-ink) 45%, var(--line));
}

.outcomes-load-more {
  align-self: center;
  margin: var(--card-gap) auto 0;
}

/* Music */

/* The Music pane: the Outcomes shell again -- the pane is the scroller on the shared card
   field and every text element carries a shared type class, so nothing here sets a size.
   The transport buttons reuse .podcast-control, the app's one transport circle. The
   gold-and-blue card round drops the section markers' shared destination tint with
   Podcasts (SURFACES: "cards are equal") -- Up next, Playlists and Played all sit directly
   on the wallpaper as labels for the card below them now, so they read in the shared
   on-wall ink pair instead. #music-state is deliberately untouched by this: its text is
   connection/setup status ("Not connected", "Needs attention"), not decoration, and reads
   from the shared subtitle rule's own on-wall pair already. */
#music .app-section-label {
  color: var(--ink-heading);
  text-shadow: var(--text-shadow-on-wall);
}

/* Shuffle and Play, the open playlist's title-row controls: compact square icon buttons
   at the touch floor rather than the shared button's 32px. */
.music-play,
.music-shuffle {
  display: grid;
  place-items: center;
  min-width: var(--touch-min);
  min-height: var(--touch-min);
  padding: 0;
}

.music-status,
.music-failure {
  margin: 0.3rem 0 0.6rem;
}

/* The disconnected state is the one state that asks for something (tap Connect), so its
   status sentence reads in ink rather than the shared status line's muted caption -- the
   same one-declaration step up .app-notice takes over .app-inset content. */
.music[data-state="disconnected"] .music-status {
  color: var(--ink);
}

.music-setup {
  margin: 0 0 var(--card-gap);
}

.music-queue,
.music-list {
  margin: 0;
  padding: 0;
  list-style: none;
}

.music-queue-item {
  padding: 0.3rem 0;
}

/* The actions slot of the shared Up next row (audio-ui.js upNextRow): the remove control sits
   at the row end. The sub-heading over the shuffle rows is a quiet label, not a row. */
.audio-up-next-actions {
  display: flex;
  flex: none;
  align-items: center;
}

.music-queue-subhead {
  margin: 0.5rem 0 0.1rem;
}

/* An editable row is one line: handle, the tappable text, remove. The handle and dragging
   look come from the .podcast-queue-* rules. */
.music-queue-main {
  flex: 1 1 auto;
  min-width: 0;
  padding: 0;
  color: inherit;
  background: none;
  border: 0;
  font: inherit;
  text-align: start;
  cursor: pointer;
}

.music-queue-main > span {
  display: block;
}

.music-row {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: 0.6rem;
  padding: 0.5rem 0;
}

.music-art {
  flex: 0 0 48px;
  width: 48px;
  height: 48px;
  border-radius: var(--radius-control);
  object-fit: cover;
}

.music-row-text {
  flex: 1 1 10rem;
  min-width: 0;
}

/* A track's title wraps (it is the primary text); its meta line is a secondary line and
   clamps to two, with the full text still in the DOM. At Large the meta wraps in full and
   the row's actions drop beneath the text (.music-row already wraps). */
.music-row-text .app-row-title {
  overflow-wrap: break-word;
}

.music-row-text .app-row-meta {
  display: -webkit-box;
  -webkit-box-orient: vertical;
  -webkit-line-clamp: 2;
  line-clamp: 2;
  overflow: hidden;
  overflow-wrap: break-word;
}

:root[data-text-size="large"] .music-row-text {
  flex-basis: 100%;
}

:root[data-text-size="large"] .music-row-text .app-row-meta {
  display: block;
  overflow: visible;
}

.music-row-actions {
  display: flex;
  flex-wrap: wrap;
  gap: 0.4rem;
}

/* Shuffle and Play's icon faces: sized and coloured like every other inline-SVG button in
   the app (.icon-button svg, .message-clear svg), scoped to these two classes rather than
   added to .icon-button itself -- these stay .app-primary-button/.app-quiet-button, not
   the transparent .icon-button family, so their selected/hover/focus/disabled styling is
   exactly what it always was. */
.music-play svg,
.music-shuffle svg {
  width: 18px;
  height: 18px;
  fill: none;
  stroke: currentColor;
  stroke-width: 1.8;
  stroke-linecap: round;
  stroke-linejoin: round;
}

/* A playlist track's overflow menu: Email Review's own "…" pattern (a quiet toggle with
   aria-haspopup/aria-expanded, an .app-inset-shaped menu hanging below its trigger)
   adapted to this row, so the one destructive action is demoted out of the row's face
   without a second overflow component being invented. */
.music-overflow {
  position: relative;
  display: flex;
  flex: none;
}

.music-overflow-toggle {
  min-width: var(--control-min);
  min-height: var(--control-min);
  line-height: 1;
}

.music-overflow-menu {
  position: absolute;
  top: calc(100% + 0.15rem);
  right: 0;
  z-index: 5;
  min-width: 11rem;
  /* Tighter than the shared inset, for the same reason the review menu is: a column of
     full-width buttons that bring their own padding. */
  padding: 0.2rem;
  border: 1px solid var(--line);
}

.music-overflow-menu[hidden] {
  display: none;
}

.music-overflow-menu button {
  display: block;
  width: 100%;
  color: var(--ink);
  text-align: left;
}

.music-setup[hidden] {
  display: none;
}

/* The two library sections at the foot of the Music pane -- Agent Kit's own Playlists and
   Played, read-and-play only -- and the disclosure rows the Playlists list is built from.
   The lists are .app-divider-list rows inside an .app-card; only the genuinely new shapes
   are styled here. */

.music-library-section {
  margin: 0 0 var(--card-gap);
}

/* The last section ends the list; the scroller's shared end padding is its bottom gap. */
.music-library-section:last-child {
  margin-bottom: 0;
}

.music-library-sync-toggle,
.weather-simple-toggle {
  display: flex;
  align-items: center;
  gap: 0.6rem;
}

/* A playlist row's title/meta pair, as the navigation control it is: an .app-row button
   that opens the playlist's own detail view. .app-row supplies the pressable row face; this
   only stacks the pair, shares the line with the row's controls and drops the row padding
   (the li carries it). */
.music-row-toggle {
  flex: 1 1 10rem;
  min-width: 0;
  flex-direction: column;
  align-items: flex-start;
  justify-content: center;
  gap: 0.1rem;
  min-height: 44px;
  padding: 0;
}

/* The open playlist's title row: the heading on the wall, with its Shuffle/Play controls
   on the same line. */
.music-detail-header {
  display: flex;
  align-items: center;
  gap: 0.5rem;
  margin: 0 0 var(--card-gap);
}

.music-detail-header > .app-toolbar-title {
  color: var(--ink-heading);
  font-size: var(--text-section);
  text-shadow: var(--text-shadow-on-wall);
}

.music-detail-actions {
  display: flex;
  flex: none;
  gap: 0.4rem;
}

/* Kroger: the Settings pane's own section, styled after the Music pane's account block
   above rather than after the Settings rows -- it is a connection with its own boxes and
   buttons, not one row with a trailing control. The section id carries data-state (the
   same place music.js puts it), so the state-keyed rule reads the id. */

.kroger-settings {
  margin-top: var(--card-gap);
}

/* The disconnected state is the one state that asks for something, the same step up
   .music[data-state="disconnected"] .music-status takes. */
#kroger[data-state="disconnected"] .kroger-status {
  color: var(--ink);
}

.kroger-setup,
.kroger-account {
  margin: 0 0 var(--card-gap);
}

.kroger-account {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: 0.5rem;
}

.kroger-store-search {
  display: flex;
  align-items: center;
  gap: 0.5rem;
  margin: 0.5rem 0;
}

.kroger-store-results {
  list-style: none;
  margin: 0;
  padding: 0;
  display: flex;
  flex-direction: column;
  gap: 0.25rem;
}

/* The two fulfillment segments reuse the Appearance control's own theme-option classes,
   but not its joined bar: outside .theme-options (which is what draws that bar's outline
   and hairlines, per .theme-option's own rule) a segment with no box of its own would
   read as plain text, so each one here takes back the quiet button's own border. The
   chosen segment's accent border is then .app-selected's documented standalone form. */
.kroger-modality-options {
  display: flex;
  flex-wrap: wrap;
  gap: 0.5rem;
  margin-top: 0.5rem;
}

.kroger-modality-options .theme-option {
  flex: 0 1 auto;
  border: 1px solid var(--line);
  border-radius: var(--radius-nested);
}

.android-setup {
  margin-top: var(--card-gap);
}

.android-setup-unsupported,
.android-setup-unpublished,
.android-setup-account,
.android-setup-device {
  margin: 0 0 var(--card-gap);
}

.android-setup-account {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: 0.5rem;
}

/* The event detail's link back to the source it was extracted from -- present only when
   the event carries one. Sits with the detail's other fields, not beside the title. */
.agenda-event-source {
  margin: 0.3rem 0;
}

/* Photos */

/* The Photos pane: the .library shell exactly -- the pane is itself the scroller on the
   shared card field, and the viewer is a second view swapped in for the grid the way
   .library-detail swaps in for .library-list. */
.photos[hidden] {
  display: none;
}

.photos-title:focus {
  outline: none;
}

/* This line sits directly on the wallpaper, below the filter row -- the on-wall ink pair
   every caption between cards reads through, not the plain --muted this used to read
   (tuned for a card, not the wallpaper it actually sat on). */
.photos-health {
  margin: 0 0 0.4rem;
  color: var(--ink-on-wall-muted);
  text-shadow: var(--text-shadow-on-wall);
  font-size: var(--text-detail-meta);
}

.photos-controls {
  display: flex;
  flex-wrap: wrap;
  gap: 0.5rem;
  margin: 0.2rem 0 0.6rem;
}

/* Both filters take their share of the row: unlike the Library's pair (a search that
   leads and a type filter that yields), neither of these is the primary one. Compact
   controls directly on the wallpaper, not a card of their own (the brief's own call for
   this pane) -- so each takes .app-top-button's own translucent on-wall ground rather
   than .app-field's card-tuned --field-fill, which read as a washed-out, barely-there box
   straight on the marble. The field shape (border-radius, font-size, the 40px floor) stays
   .app-field's; only the ground and ink move to the on-wall pair. */
.photos-album,
.photos-source {
  flex: 1 1 9rem;
  min-width: 0;
  min-height: var(--touch-min);
  color: var(--ink-heading);
  background: var(--top-button-ground);
  border-color: transparent;
  box-shadow: var(--shadow-inset-highlight);
  font-size: var(--text-control);
}

/* Large text: the two filters stack, each at the full width, so a chosen value is not cut. */
:root[data-text-size="large"] .photos-album,
:root[data-text-size="large"] .photos-source {
  flex-basis: 100%;
}

/* The onboarding empty state: shown only for a genuinely empty library (see loadPhotos
   in chat.js), so it reads as a household whose phone has not finished its first upload
   rather than as a failure. Tighter than the grid below -- this is reading copy to
   scan once, not tiles to browse, and the phone-first setup block shares it. */
.photos-empty {
  display: flex;
  flex-direction: column;
  gap: 0.4rem;
  padding: 4px var(--list-gutter) 0.5rem;
}

/* .photos-empty names a display of its own, which outranks the browser's [hidden] rule
   at equal specificity -- the same footgun the .photos-grid[hidden] comment below
   exists for. */
.photos-empty[hidden] {
  display: none;
}

.photos-empty-lead {
  margin: 0;
  font-size: var(--text-detail-body);
}

.photos-empty-links {
  display: flex;
  flex-wrap: wrap;
  gap: 0.5rem;
}

.photos-empty-steps {
  margin: 0;
  padding-left: 1.25rem;
  color: var(--muted);
  font-size: var(--text-detail-meta);
  line-height: 1.35;
}

/* The phone-first setup block's own pieces (#photos-setup in index.html): a title, the
   household's server address to type into the phone app, and one quiet trailing line. */
.photos-setup-title {
  margin: 0;
  font-size: var(--text-detail-body);
  font-weight: var(--w-title);
}

/* The one prominent thing in the block: the address a person must read off this pane and
   type into the Immich app. Monospace so it reads as a thing to copy, one-tap selectable
   in full (user-select: all), and wrapping anywhere rather than overflowing a phone
   screen. It reads at the scale's own top step rather than a rem of its own
   (detail-typography.test.ts owns that rule), and no display declaration of its own, so
   the browser's [hidden] rule hides it unaided. */
.photos-setup-address {
  margin: 0;
  padding: 0.5rem 0.75rem;
  font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
  font-size: var(--text-detail-title);
  font-weight: var(--w-title);
  color: var(--ink);
  background: var(--panel);
  border: 1px solid var(--line);
  border-radius: var(--radius-control);
  user-select: all;
  -webkit-user-select: all;
  overflow-wrap: anywhere;
}

.photos-setup-status {
  margin: 0;
  color: var(--muted);
  font-size: var(--text-detail-meta);
}

/* The thumbnail grid: .podcast-grid's idiom at a 104px floor rather than 140px, because
   a thumbnail is a smaller thing than a show card and three columns have to fit a 390px
   phone (390 - 2 x 12 gutter - 2 x 8 gap = 350; 350 / 3 = 116 >= 104). auto-fill is the
   whole responsive story -- no breakpoint, no hard-coded count. */
.photos-grid {
  display: grid;
  gap: var(--card-gap);
  grid-template-columns: repeat(auto-fill, minmax(104px, 1fr));
  padding: 10px var(--list-gutter) 1rem;
}

/* The grid and the viewer are the pane's two views, toggled against each other exactly
   as Library toggles .library-list/.library-detail. Both set a display of their own,
   which outranks the browser's [hidden] rule at equal specificity -- the footgun the
   .library-list comment above exists for. The pane's other hidden elements,
   #photos-failure and #photos-more, get no such guard on purpose: neither they nor
   .app-failure/.app-quiet-button name a display, so the browser's own [hidden] rule
   hides them unaided. */
.photos-grid[hidden],
.photos-viewer[hidden] {
  display: none;
}

/* One square tappable tile: a plain button rather than an .app-card, because a tile is a
   frame for the photograph, not a bounded entry. padding: 0 is written out because the
   bare `button` rule supplies its own, and leaving it unset would make the tile larger,
   not neutral. --panel is the one-step-off-the-field tone in both themes (darker than
   --panel-subtle in light, lighter in dark, the direction each theme's elevation runs),
   so a tile whose image has not loaded yet reads as a slot in the field. The 104px
   column floor makes the square at least 104px tall -- the touch target is the tile
   itself and never drops below the 44px the bare button rule already asks for. */
.photos-tile {
  position: relative;
  aspect-ratio: 1 / 1;
  padding: 0;
  overflow: hidden;
  color: inherit;
  font: inherit;
  background: var(--panel);
  border: 0;
  border-radius: var(--card-radius);
}

.photos-tile-image {
  display: block;
  width: 100%;
  height: 100%;
  object-fit: cover;
}

/* A video's duration pill sits on an arbitrary photograph, so it carries its own ground
   and ink rather than inheriting the pane's -- a --muted on an unknown image is not a
   contrast anyone can reason about. The theme's ink at 70% over whatever is beneath,
   with the theme's own ground as the text, keeps the app's ink-on-surface relationship
   in both themes; even the worst case (the wash over a pure-white photograph, light
   theme) still clears 4.5:1. */
.photos-tile-duration {
  position: absolute;
  right: 4px;
  bottom: 4px;
  padding: 0.1rem 0.35rem;
  color: var(--panel-subtle);
  background: color-mix(in srgb, var(--ink) 70%, transparent);
  border-radius: var(--radius-control);
  font-size: var(--text-detail-meta);
}

/* The grid's initial-fetch state: the same square tile shape the real grid draws, filled
   by the shared skeleton primitive, replaced wholesale once the first page answers. A
   plain div, not a button -- there is nothing to press yet. */
.photos-skeleton-tile {
  aspect-ratio: 1 / 1;
}

/* The viewer: a centred column at 720px rather than the old Now Playing 520px, because a photograph is
   the widest thing this app shows. The stage, the nav, the metadata panel and the
   download anchor are stretched children of this column, so its width, cap and centring
   reach them without any of them restating those measures. */
.photos-viewer {
  display: flex;
  flex-direction: column;
  gap: 16px;
  width: 100%;
  max-width: 720px;
  margin-inline: auto;
}

.photos-viewer-stage {
  display: flex;
  justify-content: center;
  max-height: 70vh;
}

.photos-viewer-stage img,
.photos-viewer-stage video {
  max-width: 100%;
  max-height: 70vh;
  object-fit: contain;
  display: block;
  margin-inline: auto;
}

.photos-viewer-nav {
  display: flex;
  flex-wrap: wrap;
  gap: 0.5rem;
  justify-content: space-between;
}

.photos-viewer-nav > * {
  min-width: var(--touch-min);
  min-height: var(--touch-min);
}

/* The viewer's facts read one step quieter than the photograph above them -- meta-sized
   lines with a muted label and an ink value -- so the stage keeps clear visual dominance
   over its own caption block rather than sharing the page with it at full size. */
.photos-meta-row {
  margin: 0;
  font-size: var(--text-detail-meta);
  line-height: var(--leading-detail-tight);
}

.photos-meta-label {
  color: var(--muted);
}

/* #photos-metadata and #photos-download carry no rule of their own: the metadata panel's
   rhythm is .app-divider-list's, and the download anchor is an .app-quiet-button whole. */

/* The wall's photo slideshow (/wall?view=photos): a fixed full-bleed field over every
   pane. z-index 40 is .command-bar's own -- the topmost overlay this stylesheet stacks,
   the same number rather than a new one, and the wall already puts the command bar
   itself away. --ink is the closest existing token to the dark field wanted here: no
   token is dark in BOTH themes (--page is the light theme's off-white canvas,
   --chart-surface belongs to the stock chart), so the light theme takes --ink's navy
   and the dark override below takes that theme's own darkest ground -- a photograph on
   a TV in a dark room must not be framed in white in either theme. */
.photos-wall {
  position: fixed;
  inset: 0;
  z-index: 40;
  display: flex;
  align-items: center;
  justify-content: center;
  background: var(--ink);
}

/* The rule above sets display: flex, which outranks the browser's [hidden] rule at equal
   specificity -- the same footgun .library-list[hidden] above exists to fix. */
.photos-wall[hidden] {
  display: none;
}

:root[data-theme="dark"] .photos-wall {
  background: var(--page);
}

/* contain, never cover: a kiosk must not crop a photograph. The empty alt is deliberate
   -- decorative full-screen display with nobody at a keyboard, and a date read aloud
   every twenty seconds is noise, not access. */
.photos-wall-image {
  max-width: 100%;
  max-height: 100%;
  object-fit: contain;
}

/* Only ever the one quiet sentence saying the slideshow has nothing to show. */
.photos-wall-status {
  color: var(--muted);
  font-size: var(--text-detail-meta);
  text-align: center;
}

/* Podcasts */

/* The pane is itself the scroller, the .library shape: three stacked views of which
   exactly one is ever showing. The pale field belongs to this scroller, not to the grid
   inside it -- a field that stopped at the last card would leave the pane half tinted
   and half white, the same seam the .stock-list comment exists to avoid. (The mini player
   bar no longer lives in this scroll at all -- see .podcast-mini's own comment in
   styles.css for where it moved and why.) */
.podcasts {
  /* The shared scroller on the card field, with no inset of its own: three sub-views
     share this scroll, and each carries its own gutter. */
  padding: 0;
}

.podcasts[hidden] {
  display: none;
}

/* The one result line for Mark played and its Undo, shared by the followed view and a show
   page. It sticks to the top of the scroller so it is seen wherever the list is scrolled to,
   on the card ground because the pane itself is transparent over the wallpaper. */
.podcasts-action {
  position: sticky;
  top: 0;
  z-index: 6;
  display: flex;
  flex: none;
  flex-wrap: wrap;
  align-items: center;
  gap: 0.25rem 0.75rem;
  padding: 0.25rem var(--list-gutter);
  background: var(--panel-subtle);
  font-size: var(--text-detail-body);
}

.podcasts-action[hidden],
#podcasts-catch-up-status[hidden],
#podcasts-downloads-body[hidden] {
  display: none;
}

#podcasts-catch-up-status {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: 0.25rem 0.75rem;
}

.podcasts-action-text {
  margin: 0;
}

/* Podcasts' own violet subtitle/section-marker tint is gone: the gold-and-blue card round
   drops every per-destination wash (SURFACES: "cards are equal") in favour of artwork
   carrying the pane's identity (see the brief's "let followed-show and episode artwork
   dominate"). Continue, What's New and What's Next now sit directly on the wallpaper as
   labels for the card below them, so they read in the shared on-wall ink pair instead of a
   plain muted or accent tone tuned for a card. */
#podcasts .app-section-label {
  margin: 18px 0 8px;
  color: var(--ink-heading);
  text-shadow: var(--text-shadow-on-wall);
}

/* The sections that hold a card (Up Next, What's New, a show's episode list) take the shared
   phone gutter themselves, because .podcasts zeroes the scroller's own padding: the card edge
   and its label sit at --list-gutter, the same x as Today's cards and Music's headings. The
   page header and the back bar carry their own inset already, and the grid its own, so none
   of those are listed here (a second inset would double). */
.podcasts-up-next,
.podcasts-whats-new,
.podcasts-shows > .podcasts-collapsible-heading,
.podcasts-queue {
  padding-inline: var(--list-gutter);
}

/* A show's card sits straight in its view, so the gutter is a margin on the card. */
.podcasts-show > .app-card {
  margin-inline: var(--list-gutter);
}

/* The followed grid sits under a What's New card with the same air a card gets from the next
   card, rather than the 10px it has on a bare page. */
.podcasts-whats-new:not([hidden]) ~ .podcasts-shows {
  padding-top: 18px;
}

/* The two collapsible boxes (What's New, Podcasts): the heading is one full-width toggle
   button in the section-label typography, and the chevron turns over while it is open.
   Padding is written out on the button, because the base `button` rule underneath would
   otherwise supply its own; 44px tall for a thumb. */
.podcasts-collapsible-heading,
.books-collapsible-heading,
.timers-collapsible-heading {
  margin: 0;
}

.timers-collapsible-toggle,
.books-collapsible-toggle,
.podcasts-collapsible-toggle {
  display: flex;
  width: 100%;
  min-height: 44px;
  align-items: center;
  gap: 0.4rem;
  padding: 0;
  color: var(--ink-heading);
  text-align: left;
  text-shadow: var(--text-shadow-on-wall);
  background: transparent;
  border: 0;
  border-radius: var(--radius-control);
  font-size: var(--text-detail-meta);
  font-weight: var(--w-title);
  letter-spacing: 0.05em;
}

.podcasts-collapsible-count,
.books-collapsible-count,
.timers-collapsible-count {
  flex: 1 1 auto;
  min-width: 0;
  font-variant-numeric: tabular-nums;
}

.podcasts-collapsible-chevron,
.books-collapsible-chevron,
.timers-collapsible-chevron {
  flex: 0 0 auto;
  width: 0.5rem;
  height: 0.5rem;
  margin-right: 0.25rem;
  border-right: 2px solid currentColor;
  border-bottom: 2px solid currentColor;
  transform: rotate(45deg);
  transition: transform var(--motion-press, 0.12s) ease;
}

.podcasts-collapsible-toggle[aria-expanded="true"] .podcasts-collapsible-chevron,
.books-collapsible-toggle[aria-expanded="true"] .books-collapsible-chevron,
.timers-collapsible-toggle[aria-expanded="true"] .timers-collapsible-chevron {
  transform: rotate(225deg);
}

@media (prefers-reduced-motion: reduce) {
  .podcasts-collapsible-chevron,
  .books-collapsible-chevron,
  .timers-collapsible-chevron {
    transition: none;
  }
}

/* The artwork grid: shows are things in their own right, so the card idiom on the shared
   pale field (.app-scroller). auto-fill at a 140px floor is the whole responsive story --
   two columns at 390px, as many as fit on a desktop -- with no breakpoint and no
   hard-coded count. */
.podcast-grid {
  display: grid;
  gap: var(--card-gap);
  grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
  /* No bottom padding: the shared scroller end padding is the gap under the last card. */
  padding: 10px var(--list-gutter) 0;
}

/* Large text on a phone: one show per row, so a title gets the card's whole width. */
@media (max-width: 47.99rem) {
  :root[data-text-size="large"] .podcast-grid {
    grid-template-columns: 1fr;
  }
}

/* One show per card: an .app-card that is also a button. Only the button chrome the
   card does not already cover is cleared here (font, colour, alignment); the surface,
   border, radius and padding are .app-card's own. The art-to-text gap is a half-step of
   air rather than the 6px it was, so a two-line title breathes under a full-width cover
   instead of reading as a caption glued to it. */
.podcast-card {
  display: flex;
  flex-direction: column;
  gap: 0.45rem;
  align-items: stretch;
  min-height: var(--touch-min);
  color: inherit;
  font: inherit;
  text-align: left;
  cursor: pointer;
}

.podcast-card:hover,
.podcast-card:focus-visible {
  border-color: var(--accent);
}

/* Square by podcast convention, and cropped rather than tall: a non-square image must
   not be allowed to change the card's height, or one odd cover breaks the whole row's
   rhythm. */
.podcast-card-art {
  aspect-ratio: 1 / 1;
  width: 100%;
}

/* The initials fallback: the same square box, centred, so a show with no artwork still
   occupies an identical cell and the grid keeps its rhythm. It carries .app-media too,
   so the ground and the corner are the artwork's own rather than a second set that has
   to be kept in step with it. */
.podcast-card-art-empty {
  display: flex;
  align-items: center;
  justify-content: center;
  color: var(--muted);
  font-size: var(--text-detail-title);
  font-weight: var(--w-title);
}

/* Two lines, then clamped: a long show title must not make one card taller than its
   neighbours in a grid whose rows size to their tallest cell. */
.podcast-card-title {
  margin: 0;
  font-size: var(--text-detail-body);
  font-weight: var(--w-title);
  line-height: var(--leading-detail-tight);
  overflow-wrap: break-word;
}

/* The unplayed count: the .app-tag pill's measures on a class of its own, held to its
   own width so the column's stretch does not make a pill of it. */
.podcast-card-count {
  align-self: flex-start;
  margin: 0;
}

/* A lock and nothing else: inline, meta-sized, no surface of any kind -- it must never
   grow into anything that could carry a URL. */
.podcast-lock {
  display: inline;
  color: var(--muted);
  font-size: var(--text-detail-meta);
}

/* "This feed failed" reads differently from "this show has nothing new" by border style
   alone, the same dashed-not-solid distinction .stock-row-unavailable makes; the muted
   colour is an addition to that cue, never the cue itself. */
.podcast-card-broken {
  border-style: dashed;
  border-color: var(--muted);
}

.podcast-card-failure {
  margin: 0;
  color: var(--muted);
  font-size: var(--text-detail-meta);
  line-height: var(--leading-detail-tight);
}

/* The card is a button, so the Always queue star cannot live inside it: the cell holds both,
   the card fills it, and the star sits over the card's top-right corner. */
.podcast-card-cell {
  position: relative;
  display: grid;
}

.podcast-card-cell > .podcast-card {
  width: 100%;
}

/* The 44px hit target is the button; the visible disc inside it is smaller so the corner of
   the artwork stays readable. Padding is explicit because the base `button` rule would
   otherwise supply its own. The disc has its own ground so the star holds contrast over any
   cover, and the pressed star takes the accent ink. */
.podcast-always-queue {
  position: absolute;
  top: 0;
  right: 0;
  display: inline-flex;
  width: var(--touch-min);
  height: var(--touch-min);
  align-items: center;
  justify-content: center;
  padding: 0;
  color: var(--muted);
  font-size: var(--text-detail-title);
  line-height: 1;
  background: transparent;
  border: 0;
  border-radius: var(--radius-control);
  cursor: pointer;
}

.podcast-always-queue > span {
  display: inline-flex;
  width: 1.9rem;
  height: 1.9rem;
  align-items: center;
  justify-content: center;
  background: var(--panel);
  border: 1px solid var(--line);
  border-radius: 50%;
}

.podcast-always-queue[aria-pressed="true"] {
  color: var(--accent-ink);
}

.podcast-always-queue:focus-visible {
  outline: 2px solid var(--accent);
  outline-offset: -2px;
}

/* On a show's own page the star is inline beside its label, not over a card. */
.podcast-show-policy {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: 0.25rem 0.6rem;
  margin: 0 var(--list-gutter) 10px;
}

.podcast-show-policy .podcast-always-queue {
  position: static;
  flex: none;
}

.podcast-show-policy-label {
  color: var(--ink-heading);
  font-size: var(--text-detail-meta);
  font-weight: var(--w-title);
  text-shadow: var(--text-shadow-on-wall);
}

.podcast-keep-policy,
#podcasts-downloads-body select {
  min-height: var(--touch-min);
  max-width: 100%;
  font: inherit;
}

.podcast-show-policy-hint {
  flex: 1 1 100%;
  margin: 0;
  color: var(--ink-heading);
  font-size: var(--text-detail-meta);
  line-height: var(--leading-detail-tight);
  text-shadow: var(--text-shadow-on-wall);
}

/* A download's state, in the row's own meta line: the meta ink (--muted already clears
   4.5:1 on the card) for streaming and downloading, and the accent ink for a finished one,
   which is a card-ground colour used the same way elsewhere. */
.podcast-offline-state {
  flex: none;
  font-variant-numeric: tabular-nums;
}

.podcast-offline-state[data-state="downloaded"] {
  color: var(--accent-ink);
}

.podcast-offline-state[data-state="downloading"] {
  color: var(--muted);
}

.podcast-show-policy[hidden] {
  display: none;
}

/* One show's recent episodes: .app-divider-list already gives the rhythm; every caller now
   wraps this in its own .app-card (Continue, What's New, a show's own list, the queue), so
   this needs no field gutter of its own any more -- the card's own padding is the inset. */
.podcast-episodes {
  padding: 0;
  container-type: inline-size;
}

/* An episode row: artwork, a two-line text column and a compact action area, about 64px tall.
   The text column takes the slack and truncates (show name and title each on one line), so
   the actions stay beside it and need no wrapping under the text. The card already pads the
   list, so the first and last rows take no extra vertical padding. */
.podcast-episode {
  display: flex;
  align-items: center;
  column-gap: var(--list-lead-gap);
  row-gap: 0;
  padding: 8px 0;
  border-radius: 0;
}

.podcast-episodes > :first-child {
  padding-top: 0;
}

.podcast-episodes > :last-child {
  padding-bottom: 0;
}

/* The card is the bounded surface; a wash on the row inside it reads as a defect (the
   same call .agenda-event-toggle makes), and the main button carries the press cue. */
.podcast-episode:hover,
.podcast-episode:focus-visible,
.podcast-group:hover,
.podcast-group:focus-visible {
  background: transparent;
}

.podcast-episode:active,
.podcast-group:active {
  transform: none;
}

/* A series header: art and title share the first line, and the parts (and the two text
   buttons while open) each take a line of their own under the title column. */
.podcast-group {
  flex-wrap: wrap;
  column-gap: var(--list-lead-gap);
  padding: 12px 0;
  border-radius: 0;
}

.podcast-group-resume,
.podcast-group-queue-all {
  flex: 0 0 calc(100% - 48px - var(--list-lead-gap));
  margin-left: calc(48px + var(--list-lead-gap));
  padding-left: 0;
  text-align: left;
}

.podcast-episode-actions {
  display: flex;
  flex: none;
  align-items: center;
  margin-left: auto;
  /* The glyph, not the 44px hit area, lines up with the card's inner edge. */
  margin-right: -12px;
}

/* The "…" overflow on a followed-show card and an episode row: music.js's track menu shape
   (a quiet toggle, an .app-inset menu hanging below it) under podcast-* names. On a card it
   sits beside the Always queue star; in an episode row it sits in the action area. */
.podcast-overflow {
  position: relative;
  display: flex;
  flex: none;
}

/* On a card the container steps out of the way: the toggle sits left of the Always queue star
   in the same 44px hit target and the same disc, and the menu is placed against the cell, not
   the toggle, so it spans the card and can never run past its edges. */
.podcast-card-cell > .podcast-overflow {
  position: static;
  display: contents;
}

.podcast-card-cell .podcast-overflow-toggle {
  position: absolute;
  top: 0;
  right: var(--touch-min);
  display: inline-flex;
  width: var(--touch-min);
  height: var(--touch-min);
  align-items: center;
  justify-content: center;
  padding: 0;
  color: var(--muted);
  font-size: var(--text-detail-title);
  line-height: 1;
  background: radial-gradient(circle closest-side, var(--panel) calc(0.95rem - 1px), var(--line) calc(0.95rem - 1px) 0.95rem, transparent calc(0.95rem + 0.5px));
  border: 0;
  border-radius: var(--radius-control);
  cursor: pointer;
}

.podcast-card-cell .podcast-overflow-toggle:focus-visible {
  outline: 2px solid var(--accent);
  outline-offset: -2px;
}

.podcast-card-cell .podcast-overflow-menu {
  top: var(--touch-min);
  right: 0;
  left: 0;
  z-index: 10;
  min-width: 0;
}

.podcast-overflow-toggle {
  min-width: var(--control-min);
  min-height: var(--control-min);
  padding: 0;
  line-height: 1;
}

.podcast-overflow-menu {
  position: absolute;
  top: calc(100% + 0.15rem);
  right: 0;
  z-index: 5;
  min-width: 14rem;
  padding: 0.2rem;
  border: 1px solid var(--line);
}

.podcast-overflow-menu[hidden] {
  display: none;
}

.podcast-overflow-menu button {
  display: flex;
  flex-direction: column;
  align-items: flex-start;
  width: 100%;
  color: var(--ink);
  text-align: left;
}

.podcast-overflow-note {
  white-space: normal;
}

/* A quiet icon: no ring or fill at rest, a subtle ground on hover/press, and a visible
   ring for the keyboard. .icon-button supplies the 44px box, the muted ink and the 20px
   stroked glyph. */
.podcast-action {
  border: 0;
  border-radius: var(--radius-control);
  cursor: pointer;
}

.podcast-action:active {
  background: var(--panel);
}

.podcast-action:focus-visible {
  outline: 2px solid var(--accent);
  outline-offset: -2px;
}

.podcast-action:disabled {
  opacity: 0.5;
  cursor: default;
}

/* An episode/group/queue row's own artwork, at the row's left edge as the first flex
   child -- .podcast-mini-art's own square and either/or, at the same 48px .music-art
   already uses for a track row. Built fresh per row (see episodeArt in podcasts.js)
   rather than wired once, since these rows are rebuilt wholesale on every render. */
.podcast-episode-art {
  position: relative;
  display: flex;
  align-items: center;
  justify-content: center;
  flex: 0 0 48px;
  width: 48px;
  height: 48px;
  overflow: hidden;
}

.podcast-episode-art-image {
  display: block;
  width: 100%;
  height: 100%;
  object-fit: cover;
}

.podcast-episode-art-image[hidden],
.podcast-episode-art-fallback[hidden] {
  display: none;
}

/* The initials stand-in: a show with no artwork, or whose artwork failed to load, still
   occupies the same square, so the row reads as a cover that is missing rather than a
   broken one. Decorative -- the title beside it already names the show. */
.podcast-episode-art-fallback {
  color: var(--muted);
  font-size: var(--text-detail-meta);
  font-weight: var(--w-title);
}

/* A part's series header and an episode's main button share one shape: no chrome, a
   left-aligned column, and the 44px touch target every control in this app carries. */
.podcast-episode-main,
.podcast-group-main {
  flex: 1 1 0;
  min-width: 0;
  min-height: 48px;
  display: flex;
  flex-direction: column;
  justify-content: flex-start;
  gap: 2px;
  padding: 0;
  color: inherit;
  background: transparent;
  border: 0;
  font: inherit;
  text-align: left;
}

/* The episode row's text block: exactly two lines -- the show line (name, then icons) and the
   title line (title, then length) -- centred beside the 48px art and never taller than it. */
.podcast-episode-main {
  min-height: 0;
  justify-content: center;
  gap: 0;
}

.podcast-episode-show,
.podcast-episode-line {
  display: flex;
  align-items: baseline;
  column-gap: 6px;
  min-width: 0;
  margin: 0;
  line-height: 1.25;
}

.podcast-episode-show {
  font-size: var(--text-support);
}

.podcast-episode-show-name,
.podcast-episode-title {
  min-width: 0;
  overflow-wrap: break-word;
}

/* Two lines then clamped, so a long title does not stretch a row; Large text wraps in full
   and lets the line's length and actions drop beneath the title. */
.podcast-episode-title {
  display: -webkit-box;
  -webkit-box-orient: vertical;
  -webkit-line-clamp: 2;
  line-clamp: 2;
  overflow: hidden;
}

.podcast-episode-show-name {
  flex: 0 1 auto;
}

.podcast-episode-title {
  flex: 0 1 auto;
  line-height: 1.25;
}

:root[data-text-size="large"] .podcast-episode-show,
:root[data-text-size="large"] .podcast-episode-line {
  flex-wrap: wrap;
}

:root[data-text-size="large"] .podcast-episode-title {
  display: block;
  overflow: visible;
}

.podcast-episode-length {
  flex: none;
  font-size: var(--text-support);
  white-space: nowrap;
  font-variant-numeric: tabular-nums;
}

.podcast-episode-age {
  flex: none;
  font-size: var(--text-support);
  white-space: nowrap;
}

.podcast-episode-length + .podcast-episode-age::before {
  content: "· ";
}

.podcast-pick-mark {
  flex: none;
  color: var(--accent-ink);
}

/* A series' parts read as subordinate to it: a modest indent (the 76px rail left a part's
   title a sliver of the row), with their own hairline dividers so the part list is a
   list inside the entry, not a continuation of the entry list. */
.podcast-group-parts {
  flex: 0 0 100%;
  margin: 0;
  padding: 0 0 0.2rem 16px;
  list-style: none;
}

.podcast-group-parts[hidden] {
  display: none;
}

/* The now-playing row at the top of Up Next: a 48px cover, a two-line text column (show,
   then title and time left; the label is visually hidden) with a 2px read-only progress
   strip along its bottom edge, and the play/pause disk at the end. The strip's track and
   fill are the scrubber's own colours (--line, --accent). */
.podcast-now-art {
  flex: 0 0 48px;
  width: 48px;
  height: 48px;
}

.podcast-up-next-now {
  align-items: center;
  border-radius: var(--radius-control);
  transition: background-color 0.3s ease;
}

.podcast-up-next-now[hidden] {
  display: none;
}

@media (prefers-reduced-motion: reduce) {
  .podcast-up-next-now {
    transition: none;
  }
}

.podcast-up-next-text {
  position: relative;
  display: flex;
  flex: 1 1 auto;
  flex-direction: column;
  justify-content: center;
  gap: 0;
  min-width: 0;
}

.podcast-up-next-text > p {
  margin: 0;
  overflow-wrap: break-word;
}

/* The episode title takes its own line and wraps in full, never ellipsised, like the Music and
   Books Now playing rows; the one meta line under it is "show · time left", the time dropped
   to the next line only when it does not fit. */
.podcast-up-next-text > .podcast-episode-title {
  display: block;
  overflow: visible;
}

.podcast-now-meta {
  min-width: 0;
  margin: 0;
  line-height: 1.25;
  overflow-wrap: break-word;
}

.podcast-now-meta .podcast-episode-show {
  display: inline;
}

/* The dot trails the show name, so a narrow row breaks after it and "48m left" stays whole. */
.podcast-now-meta .podcast-episode-length:not(:empty)::before {
  content: "\00a0·\0020";
  white-space: normal;
}

.podcast-up-next-progress,
.book-progress {
  position: absolute;
  inset: auto 0 -3px;
  height: 2px;
  overflow: hidden;
  border-radius: var(--radius-pill);
  background: var(--line);
}

.podcast-up-next-progress > span,
.book-progress > span {
  display: block;
  width: 0;
  height: 100%;
  background: var(--accent);
}

/* Books: a row is a 48px cover and a text column, the .podcast-episode shape. The title and
   the meta line wrap in full; a started book carries the same 2px
   strip along the bottom of its text column as Up Next's now-playing row. */
.book-row {
  display: flex;
  align-items: center;
  column-gap: var(--list-lead-gap);
  padding: 8px 0;
}

/* The browser's default <ul> padding and margins would indent every cover and pad every card;
   .podcast-episodes zeroes the same things. */
.books-list {
  padding: 0;
  margin: 0;
  list-style: none;
}

.books-list > :first-child {
  padding-top: 0;
}

.books-list > :last-child {
  padding-bottom: 0;
}

.book-cover {
  flex: 0 0 48px;
  width: 48px;
  height: 48px;
  object-fit: cover;
}

.book-cover-initials {
  display: flex;
  align-items: center;
  justify-content: center;
  color: var(--muted);
  font-size: var(--text-detail-meta);
  font-weight: var(--w-title);
}

.book-main {
  position: relative;
  display: flex;
  flex: 1 1 0;
  flex-direction: column;
  justify-content: center;
  gap: 2px;
  min-width: 0;
  min-height: 48px;
  padding: 0;
  color: inherit;
  background: transparent;
  border: 0;
  font: inherit;
  text-align: left;
  text-decoration: none;
}

/* Title, author/chapter line and the "Open in Audible" hint all wrap: a book's name and
   progress are primary text. The cover stays 48px at every text size. */
.book-title,
.book-meta,
.book-open {
  min-width: 0;
  overflow-wrap: break-word;
}

.books-search-row {
  margin-bottom: 0.75rem;
}

.books-author {
  margin: 14px 0 6px;
  color: var(--muted);
  font-size: var(--text-detail-meta);
  font-weight: var(--w-title);
}

/* The Now playing / Up next section at the top of Podcasts, Music and Books: a Now playing
   card holding one state-only row (48px cover, title and subtitle, then Playing or Paused),
   and under it each page's own Up next card when something will actually play next. The
   state word sits in the row's meta ink, which reads on the card; there is no tint here. */
.audio-now {
  margin-bottom: var(--card-gap);
}

.audio-now[hidden],
.audio-now > [hidden] {
  display: none;
}

.audio-now-row {
  align-items: center;
}

.audio-now-art {
  display: flex;
  flex: 0 0 48px;
  align-items: center;
  justify-content: center;
  width: 48px;
  height: 48px;
  overflow: hidden;
}

.audio-now-text {
  flex: 1 1 auto;
  min-width: 0;
}

.audio-now-state {
  flex: none;
  margin: 0;
}

/* The player bar's progress line: the input is a 28px-tall, invisible hit area along the
   bottom edge of the surface, above the transport and the opener (z-index 2). The real track
   is the 28px hit area (Chromium starts a drag only on the track or thumb, never on the
   input's padding), and the drawn line is the 3px at its foot, painted by background layers
   that are transparent elsewhere. touch-action: none keeps a horizontal touch drag on the
   line from being taken as a scroll. The thumb is a 28px square and not drawn: it does not
   offset the track, and Chromium starts a touch drag only on the thumb (a press on the
   bare track is a tap that seeks), so the square is the touch target for a drag. Chromium paints the accent share from --fill (podcasts.js writes it),
   Firefox has its own ::-moz-range-progress; both engines' pseudo-elements are styled
   because each ignores the other's. */
.podcast-scrub {
  position: absolute;
  z-index: 2;
  left: 0;
  right: 0;
  bottom: 0;
  width: 100%;
  height: 28px;
  margin: 0;
  padding: 0;
  background: transparent;
  cursor: pointer;
  touch-action: none;
  -webkit-appearance: none;
  appearance: none;
}

.podcast-scrub::-webkit-slider-runnable-track {
  height: 28px;
  border-radius: 0;
  background:
    linear-gradient(var(--accent), var(--accent)) 0 100% / var(--fill, 0%) 3px no-repeat,
    linear-gradient(var(--player-progress-track), var(--player-progress-track)) 0 100% / 100% 3px no-repeat;
}

.podcast-scrub::-webkit-slider-thumb {
  -webkit-appearance: none;
  appearance: none;
  width: 28px;
  height: 28px;
  border: 0;
  background: transparent;
}

.podcast-scrub::-moz-range-track {
  height: 28px;
  border-radius: 0;
  background: linear-gradient(var(--player-progress-track), var(--player-progress-track)) 0 100% / 100% 3px no-repeat;
}

.podcast-scrub::-moz-range-progress {
  height: 28px;
  background: linear-gradient(var(--accent), var(--accent)) 0 100% / 100% 3px no-repeat;
}

.podcast-scrub::-moz-range-thumb {
  width: 28px;
  height: 28px;
  border: 0;
  background: transparent;
}

/* A source that cannot seek shows the same line read-only. */
.podcast-scrub:disabled {
  cursor: default;
}

/* Range inputs are not covered by the global button:focus-visible ring, so this view
   gives its own, the .library-search shape. */
.podcast-scrub:focus-visible {
  outline: 2px solid var(--accent);
  outline-offset: -2px;
}

/* The transport controls: quiet 44px hit areas with no outline of their own -- a transparent
   ground, a subtle hover/active ground and a visible focus ring -- and 22px inline-SVG
   glyphs. Only play/pause is accented (rule 01: one accent per region). Padding is 0
   explicitly, because the base button rule sets 0.7rem 1.2rem and an absent declaration
   here means that, not nothing. */
.podcast-control {
  display: grid;
  place-items: center;
  width: 44px;
  min-width: 44px;
  min-height: 44px;
  padding: 0;
  color: var(--ink);
  background: transparent;
  border: 0;
  border-radius: 50%;
  font-size: var(--text-detail-body);
  line-height: 1;
}

.podcast-control svg {
  width: 22px;
  height: 22px;
  fill: none;
  stroke: currentColor;
  stroke-width: 1.8;
  stroke-linecap: round;
  stroke-linejoin: round;
}

.podcast-control svg .icon-solid {
  fill: currentColor;
}

.podcast-control:hover {
  background: var(--code);
}

.podcast-control:focus-visible {
  background: var(--code);
  outline: 2px solid var(--accent);
  outline-offset: -2px;
}

.podcast-control:active {
  background: var(--line-strong);
  transform: scale(0.94);
}

@media (prefers-reduced-motion: reduce) {
  .podcast-control:active {
    transform: none;
  }
}

/* Play/pause is the one a person reaches for without looking, and the region's one
   accented control: a 46px gold disk with a filled 22px symbol. Both symbols are in the
   markup and data-state picks the one drawn, so the swap needs no DOM rebuild and the
   button keeps focus across a tap. */
.podcast-control-main {
  width: 46px;
  min-width: 46px;
  min-height: 46px;
  color: var(--on-accent);
  background: var(--accent);
}

.podcast-control-main:hover,
.podcast-control-main:focus-visible {
  background: var(--accent-hover);
}

.podcast-control-main:focus-visible {
  outline-color: var(--ink);
  outline-offset: 2px;
}

/* A chat command's play() the browser refused: the button to tap is ringed (static, so
   nothing to tone down for prefers-reduced-motion) until the next play or a tap on it. */
.podcast-control.playback-attention {
  box-shadow: 0 0 0 2px var(--page), 0 0 0 4px var(--ink);
}

.podcast-control-main svg {
  fill: currentColor;
  stroke-width: 1;
}

.podcast-control-main .icon-pause,
.podcast-control-main[data-state="playing"] .icon-play {
  display: none;
}

.podcast-control-main[data-state="playing"] .icon-pause {
  display: inline;
}

/* What's Next: the ordered queue below Now Playing. .podcast-episodes already gives the
   list its gutters and .app-divider-list the hairline rhythm between rows -- both reused
   as-is (see index.html); this only adds what a <ul> needs that a <div> already had for
   free (list-style, margin) and the row's own three-part layout. */
.podcast-queue-list {
  list-style: none;
  margin: 0;
}

/* A leading handle before the artwork: a fixed-size control (via .icon-button, reused
   whole) that stays on the first line, so the wrapped action area drops under the title
   exactly as it does in the other rows. */
.podcast-queue-row,
.music-queue-row {
  align-items: center;
}

.podcast-queue-handle,
.music-queue-handle {
  align-self: center;
  /* A narrow visible column: the glyph sits in a 32px-wide, 44px-tall box whose left 8px
     hang into the card's padding (-8px margin), so the column costs the row 24px, and the
     -4px right margin makes the art follow the grip by 8px instead of the full 12px gap. */
  width: 32px;
  min-width: 32px;
  margin-left: -8px;
  margin-right: -4px;
  position: relative;
}

/* The glyph column stays 32px; the overlay widens the hit area to --control-min (44px) by
   reaching into the row's own padding on the left and the gap on the right, never as far
   as the art that follows. */
.podcast-queue-handle::after,
.music-queue-handle::after {
  content: '';
  position: absolute;
  top: 0;
  bottom: 0;
  left: -6px;
  right: -6px;
}

/* touch-action: none is scoped to the handle alone, not the row: grabbing the handle must
   not fight the page's own vertical scroll, but the rest of the row (and every other list
   in the app) still scrolls exactly as before. cursor: grab signals what the control does
   before a person ever touches it; chat.js swaps no cursor while dragging (a pressed state
   would need a body-level class this feature does not need). */
.podcast-queue-handle,
.music-queue-handle {
  touch-action: none;
  cursor: grab;
}

/* The dragged row while a pointer holds it: lifted just enough to read as "this one is
   moving", never so far it looks broken. No transform/position change -- the row is
   physically reordered in the DOM as the pointer crosses each neighbour's midpoint (see
   chat.js), so this is the only visual feedback the drag needs. */
.podcast-queue-row-dragging,
.music-queue-row-dragging {
  opacity: 0.6;
  background: var(--panel-subtle);
}

/* Status and failure lines on the followed and show views, placed the way .stocks places its
   own. The Up Next lines sit inside a section that already carries the gutter, so they take
   no side margin of their own. */
#podcasts-status,
#podcasts-failure,
#podcasts-show-status {
  margin: 0.3rem var(--list-gutter) 0.6rem;
  color: var(--ink-heading);
  text-shadow: var(--text-shadow-on-wall);
}

#podcasts-now-status {
  margin: 0.3rem 0 0;
  color: var(--ink-heading);
  text-shadow: var(--text-shadow-on-wall);
}

#podcasts-failure[hidden],
#podcasts-now-failure[hidden] {
  display: none;
}

/* An empty status line is a live region waiting for text, not a row: its margin would trail
   the last card by ~30px and make this pane the odd one out at maximum scroll. Kept
   rendered rather than display: none so a screen reader still announces the text that
   arrives. */
#podcasts-status:empty,
#podcasts-show-status:empty,
#podcasts-now-status:empty {
  margin: 0;
}

/* The persistent player, its own control above the composer pill (apps/chat/public/index.html
   puts #podcasts-mini and #music-mini first inside <form id="composer">, before the thread
   zone, the status rail and .composer-row). It is a separate, fully rounded surface,
   --stack-gap above the connected surface the zone, rail and pill make, so the player and
   the message box read as two distinct controls. The outer .podcast-mini is only the size
   container: it takes the composer's own gutters (100% of its content box), carries that
   gap as its bottom margin, and [hidden] (below) is what makes it disappear cleanly, gap
   and reserved height with it, when nothing is playing. The surface -- the inner
   .podcast-mini-surface, because an element cannot query its own container size -- keeps
   the --panel-subtle fill with --radius-surface on all four corners and a slight card lift.
   Music and Podcasts share this geometry.

   One row at every width, about 56px: the art, a one-line title taking the free width, and
   the transport at the right, with Podcasts' 3px progress line along the bottom edge
   (.podcast-scrub). The bar's height is still inside the composer, which chat.js's
   ResizeObserver on #composer re-measures into --command-bar-space, so every pane's bottom
   reservation grows with it. It lives in the composer and not inside Podcasts' or Music's
   own pane scroller (it was once position: sticky there) because the bar would vanish the
   moment the person left that one pane even though playback itself survives navigation
   (#podcast-audio lives at body level, outside every pane). */
.podcast-mini {
  container: mini / inline-size;
  flex: none;
  width: 100%;
  margin-bottom: var(--stack-gap);
}

.podcast-mini[hidden] {
  display: none;
}

/* No border: 6px + 44px transport + 6px make the 56px row. overflow: hidden lets the progress
   line follow the rounded corners, and the line's hit strip lies over the controls' lowest
   pixels (see .podcast-scrub). */
.podcast-mini-surface {
  position: relative;
  display: flex;
  align-items: center;
  gap: 8px;
  padding: 6px 4px 6px 8px;
  overflow: hidden;
  color: var(--ink);
  background: var(--panel-subtle);
  border: 0;
  border-radius: var(--radius-surface);
  box-shadow: var(--shadow-inset-highlight), var(--shadow-card);
}

/* The artwork and the title. In all three bars the opener button is this, so the whole of it
   opens the source's page and no transport hit area lies inside it. Padding and radius are
   set explicitly rather than omitted: deleting a padding override makes a button BIGGER, not
   smaller, because the base `button` rule underneath would supply its own. */
.podcast-mini-open {
  display: flex;
  flex: 1 1 auto;
  align-items: center;
  gap: 10px;
  min-width: 0;
  min-height: var(--touch-min);
  padding: 0;
  border-radius: var(--radius-control);
}

.podcast-mini-open {
  cursor: pointer;
}

/* The playing show's artwork: a fixed 40px square rather than a share of the free width,
   so the title's ellipsis stays the only thing that gives way on a narrow screen and one
   oddly shaped cover cannot change the bar's height. It carries .app-media for the
   ground, border and corner every other piece of art in this app already draws, so this
   rule owns only the square itself and the centring of whichever child is showing. */
.podcast-mini-art {
  position: relative;
  display: flex;
  align-items: center;
  justify-content: center;
  flex: 0 0 40px;
  width: 40px;
  height: 40px;
  overflow: hidden;
}

/* The image fills the square and crops rather than distorts. .app-media's object-fit
   landed on the outer span, where a span can do nothing with it, so the img states its
   own. */
.podcast-mini-art-image {
  display: block;
  width: 100%;
  height: 100%;
  object-fit: cover;
}

/* Both children of the art slot set display, so each hands [hidden] back itself: the
   UA's own display: none is outranked by any author rule naming display, and exactly one
   of these two may ever be showing. */
.podcast-mini-art-image[hidden],
.podcast-mini-art-fallback[hidden] {
  display: none;
}

/* The initials stand-in: a show with no artwork still occupies the same square, so the
   slot reads as a cover that is missing its image rather than as a broken one. Decorative
   -- the title beside it says the show's name. */
.podcast-mini-art-fallback {
  color: var(--muted);
  font-size: var(--text-detail-body);
  font-weight: var(--w-title);
}

/* The title, taking the row's whole remaining width. The element is a <span> inside the
   Podcasts opener (a button may hold no block content) and a <p> in Music's plain block, so
   the block display is stated here rather than inherited. */
.podcast-mini-text {
  display: block;
  flex: 1 1 auto;
  min-width: 0;
  text-align: left;
}

.podcast-mini-title {
  display: block;
  min-width: 0;
  margin: 0;
  overflow: hidden;
  color: var(--ink);
  font-size: var(--text-detail-body);
  font-weight: var(--w-title);
  line-height: 1.3;
  /* Up to two lines, then a cap: the full title is in the opener's accessible name. */
  display: -webkit-box;
  -webkit-line-clamp: 2;
  -webkit-box-orient: vertical;
  overflow-wrap: anywhere;
}

/* Two rows: the opener (art + title) takes the full width and the transport sits on its own
   row beneath, centred, every target at least 44px. At Large text always; otherwise when
   the bar itself is narrow. An @container query cannot read :root's attribute, so the
   Large variant is its own selector. The progress line stays absolutely positioned along
   the bottom edge either way. */
:root[data-text-size="large"] .podcast-mini-surface {
  flex-wrap: wrap;
}

:root[data-text-size="large"] .podcast-mini-open {
  flex: 1 0 100%;
}

:root[data-text-size="large"] .podcast-mini-controls {
  flex: 1 0 100%;
  justify-content: space-evenly;
}

@container mini (max-width: 380px) {
  .podcast-mini-surface {
    flex-wrap: wrap;
  }

  .podcast-mini-open {
    flex: 1 0 100%;
  }

  .podcast-mini-controls {
    flex: 1 0 100%;
    justify-content: space-evenly;
  }
}

/* The transport: three 44px targets that never wrap. The progress line's 12px strip
   (z-index 2) lies over the lowest 6px of the buttons; the glyphs are well above it. Inside the bar the glyphs are 20px and play/pause is 44px, not the 46px of Up Next. */
.podcast-mini-controls {
  position: relative;
  z-index: 1;
  display: flex;
  flex: none;
  align-items: center;
  gap: 0;
}

.podcast-mini-open {
  position: relative;
  z-index: 1;
}

.podcast-mini-controls .podcast-control svg {
  width: 20px;
  height: 20px;
}

.podcast-mini-controls .podcast-control-main {
  width: 44px;
  min-width: 44px;
  min-height: 44px;
}

/* The Screen pane: the same scroller shape as .library and .settings -- the display
   band's one content scroller while it shows. Like every other pane now (see
   .app-scroller-page), it draws no ground of its own: the wallpaper shows through, and
   the drafted document below is the one card on it -- reading like a page on the
   wallpaper rather than another panel stacked on panels. What it scrolls is not this
   app's own list markup but whatever the view renderer draws into #view-body, so the
   pane itself owns only the chrome: the toolbar, the two lines under it, and the body. */
.view[hidden] {
  display: none;
}


/* Programmatic only, like .settings-title: focused on every open so the new pane is
   announced, but visually hidden and never in the tab order. */
.view-title:focus {
  outline: none;
}

.view-status,
.view-failure {
  margin: 0.3rem 0 0;
}

/* Where the renderer's output begins. The pane stops here and the drafted document
   owns everything below. */
.view-body {
  padding-top: 0.35rem;
}

/* The renderer's own vocabulary (view.js). A drafted page is a detail surface like any
   other, so every size comes from the shared scale: the page keeps no rem of its own,
   and a document the view agent wrote reads at the same three steps Today and Calendar
   do -- with one deliberate exception, the page's own title just below. The looks come
   from the shared classes the renderer already puts on its nodes -- .app-card,
   .app-inset, .app-card-list, .app-divider-list -- so nothing here restates a surface. */

.view-page {
  display: flex;
  flex-direction: column;
  /* A bounded preview with its own scroll, the same bound .app-dialog-body gives a long
     dialog body: a long generated page previews as a page rather than stretching the
     pane's own scroller into an endless column. */
  max-height: min(82vh, 46rem);
  overflow-y: auto;
  overscroll-behavior: contain;
}

/* The page's one h1. The global h1 rule styles the panes' hidden h1s at 0.82rem; this one
   is content, and the approved design gives it its own display hierarchy rather than the
   shared list-heading step every other pane's title reads at -- the Screen is the one
   surface meant to read like a page, not a list. clamp keeps it responsive without a
   breakpoint: 28px is the floor a phone never shrinks below, 42px is the ceiling a wide
   screen never grows past, and 4vw is what moves between them. */
.view-page-title {
  margin: 0 0 0.55rem;
  font-size: var(--text-page-title);
  font-weight: var(--w-title);
  line-height: var(--leading-detail-tight);
  overflow-wrap: anywhere;
}

@media (min-width: 48rem) {
  .view-page-title {
    font-size: clamp(28px, 4vw, 42px);
  }
}

.view-page-body {
  display: flex;
  flex-direction: column;
  gap: 0.9rem;
}

/* The shared .app-inset carries a trailing margin for the prose flows it was written
   for; inside a page the rhythm belongs to the gap of whatever holds the node, so the
   margin is neutralized once here rather than once per container. */
.view-page .app-inset {
  margin: 0;
}

/* section, stack, row and grid are the shared .app-section / .app-stack /
   .app-row-group / .app-grid now -- view.js composes them onto its own semantic
   classes, and the four rules that used to live here (including the phone collapse
   for a grid's column count) are stated once in the shared vocabulary for every
   caller. `heading`, `text` and `notice` are .app-heading / .app-text / .app-notice
   for the same reason. What stays below is only what is genuinely the Screen's: the
   weights and tracking a page reads at, which the rest of the app does not. */

/* A projection row's own box: a bare column, title over meta over text. The title and
   meta lines carry the shared .app-row-title and .app-row-meta classes for their size,
   weight and colour, so nothing of that is restated here. */
.view-item {
  display: flex;
  flex-direction: column;
  gap: 0.1rem;
  min-width: 0;
}

/* .app-row-title already gives the title everything it needs (margin: 0 included), so
   this restates only that margin -- naming the class explicitly rather than leaving it
   with no rule of its own, so a future override (the way .view-grocery-list's meta line
   below needed one) has somewhere to land beside a title-specific declaration, not only
   inside the shared row vocabulary. */
.view-item-title {
  margin: 0;
}

/* The row's third field, under its title: a sentence, not a second title, so it reads
   at the body step and regular weight where the title above it is semibold. */
.view-item-text {
  margin: 0;
  font-size: var(--text-detail-body);
  line-height: var(--leading-detail-tight);
  overflow-wrap: anywhere;
}

/* The grocery block is the one divider list built directly from .view-item rows, with
   no semantic row shape of its own the way the other three blocks have (see
   renderAgendaRow and its siblings in view.js) -- a shopping list is lines inside one
   document. Its own chip look follows below. */

/* ------------------------------------------------------------------------------------
   The Screen's own visual language used to run on a private warm, olive-accented token
   set (--screen-canvas, --screen-accent and eight more) declared here and scoped to
   .view, with color-scheme pinned to light so the pane could not follow the app's theme.
   That palette is retired: the Screen now reads in the same shared vocabulary every
   other pane does (--page/--panel-subtle, --ink/--muted, --accent, --warn/--ok), and
   pins no colour scheme of its own, so it follows the live theme like everywhere else.
   What still makes the Screen its own thing is composition and type -- the display-sized
   title, the 18px rounded surfaces, the per-block treatments below -- not a palette of
   its own. Every rule below still only ever targets an element view.js actually draws,
   so no other pane's computed style moves. */

/* #view-state's own look is the shared .app-page-header-status primitive now (declared
   in the shared primitive vocabulary above): it moved back out of the pane's
   .app-page-header and into .app-toolbar, beside the Publish and Unpublish actions it
   describes, keeping the same id and the same write path (chat.js sets
   viewStateLine.textContent, untouched) and the same draft/published colour switch, keyed
   off the one state chat.js already maintains -- #view-unpublish's disabled attribute --
   rather than a class this file would otherwise have to keep in sync by hand. --warn is
   the primitive's own default: a draft is a page still needing attention before anyone
   outside the household can see it, and that is exactly what the status colour means
   everywhere else in the app -- not decoration, not the Screen's own accent. Published is
   --ok, the same token every other "done" state in the app reads, so the pill answers
   "is this live" on its own terms rather than borrowing the meaning #view-publish's
   accent carries beside it in the toolbar. :has() stays scoped to .view, the pane, so it
   keeps reading the pill and the button wherever in the pane each sits. */
.view:has(#view-unpublish:not(:disabled)) .app-page-header-status {
  color: var(--ok);
  border-color: color-mix(in srgb, var(--ok) 35%, transparent);
}

.view:has(#view-unpublish:not(:disabled)) .app-page-header-status::before {
  background: var(--ok);
}

/* Publish and Unpublish both read as .app-top-button now (index.html) -- the brief's own
   call for this row: a clear, separate control row directly on the wallpaper, not a
   primary/secondary pair built from card tokens that no longer apply once the toolbar
   band above them is gone. The status pill beside them already carries which state is
   current; the two actions read alike, at the touch floor (44px) the brief calls for
   rather than .app-top-button's own plain 42px. */
#view-publish,
#view-unpublish {
  min-height: var(--touch-min);
}

/* The canvas: only once the renderer has actually drawn a page, so an empty or failed
   Screen never shows a box with nothing in it -- the pane's own status line above
   already says so in the app's plain chrome. The gold-and-blue card round moves this off
   --page (the wallpaper's own flat colour, which read as an old blue-grey document panel
   floating on the wallpaper it was already the same colour as) onto --panel-subtle, the
   one opaque card surface the rest of the app floats on the wallpaper -- plus the shared
   card shadow, so the drafted page reads as the same kind of object as every other card
   in the app, just at page scale (--radius-surface, not --card-radius -- see the radius
   vocabulary's own comment on why a grouped block reads at the larger of the two). */
.view-body:has(> .view-page) {
  padding: 1rem;
  background: var(--panel-subtle);
  color: var(--ink);
  border-radius: var(--radius-surface);
  box-shadow: var(--shadow-card);
}

/* Strong typography within the scale the rest of the app keeps -- heavier weight and
   tighter tracking, never a size of its own. detail-typography.test.ts still enforces
   that every font-size in this file is one of the three shared steps, and the Screen
   names none here either. The weight is var(--w-display) (700), not the 800 this used to
   carry: the Screen reads as a page through its type scale and composition, not through a
   fifth weight. */
.view-page-title {
  font-weight: var(--w-display);
  letter-spacing: -0.02em;
}

h2.view-heading {
  font-weight: var(--w-display);
  letter-spacing: -0.01em;
}

h3.view-heading {
  font-weight: var(--w-display);
}

/* A section's own optional title reads as a strong heading here, not the app's quiet
   uppercase caption -- the Screen is the one surface meant to read like a page rather
   than a list. The shared .app-section-label rule other panes read stays untouched. */
.view-section > .app-section-label {
  color: var(--ink);
  font-weight: var(--w-display);
  letter-spacing: normal;
  text-transform: none;
}

/* A container inside a drafted page groups at the page's own rounding rather than the
   app's list-card rounding: a page's blocks are grouped surfaces (--radius-surface),
   not entries in a list (--card-radius). This is the one shape difference between a
   generated page and a pane, and it is a difference of scale, not of vocabulary --
   both read the same token. The card's hairline drops to the plain --line, because a
   page's grouped block sits lighter than the app's list cards. */
.view-body .app-card {
  border-color: var(--line);
  border-radius: var(--radius-surface);
}

.view-body .app-inset {
  border-radius: var(--radius-surface);
}

/* A notice is the one node the design calls out on its own: the shared --warn mixes
   Part I already established for a wash and its border, on whichever of its three looks
   the document names, so the Screen's own warning reads as the same family as a category
   wash rather than a fourth invented tone. */
.view-notice.app-inset {
  color: var(--ink);
  background: color-mix(in srgb, var(--warn) 12%, var(--panel-subtle));
  border-color: color-mix(in srgb, var(--warn) 35%, var(--line));
}

.view-notice.app-card {
  color: var(--ink);
  border-color: color-mix(in srgb, var(--warn) 35%, var(--line));
}

.view-notice:not(.app-inset):not(.app-card) {
  color: var(--muted);
}

/* The five live-data blocks group as their own rounded surface, so a plain generated
   page -- one that names no quiet or outlined wrapper around a block, which the model
   is not guaranteed to do -- still reads grouped the way the approved design does.
   That surface is the shared .app-surface now: view.js puts the class on each block,
   and nothing here restates a padding, a ground, a border or a radius.

   A block already inside a container that drew a surface sits inside that one instead
   of nesting a second visibly boxed surface around the same rows. The de-boxing rule
   still lives here rather than on .app-surface itself, because it is about what the
   SCREEN nests -- a pane that deliberately puts one surface inside another is making
   a different decision and should say so itself. */
.app-card > .app-surface,
.app-inset > .app-surface {
  padding: 0;
  background: transparent;
  border: 0;
  border-radius: 0;
}

/* Semantic agenda timeline rows inside the surface above: a line on a timeline, not a
   card of its own. The bar is decorative chrome the renderer adds itself (renderAgendaRow
   in view.js), never a colour or position the document names -- and it reads --muted, not
   the accent: #view-publish above is the pane's one primary action, and a bar repeated
   once per row would spend the accent many times over in the same region. */
.view-agenda {
  display: flex;
  flex-direction: column;
}

.view-agenda-row {
  display: flex;
  align-items: flex-start;
  gap: 0.65rem;
  padding: 0.5rem 0;
}

.view-agenda-row:first-child {
  padding-top: 0;
}

/* The row's leading column: a fixed-width gutter so the bar and every row's own content
   line up when that particular row carries a time. renderAgendaRow (view.js) still leaves
   it empty rather than guessing when there is nothing to put in it -- but marks it with
   the --empty modifier below, so an empty column gives its width back to the title
   instead of reserving a 3.4rem hole beside nothing. */
.view-agenda-meta {
  flex: 0 0 3.4rem;
  min-width: 0;
}

/* The empty rail collapses: zero basis, with the row's own gap keeping a small leading
   indent ahead of the bar so a time-less row still reads as part of the timeline, and the
   title/location column taking the space back. */
.view-agenda-meta--empty {
  flex-basis: 0;
}

.view-agenda-time {
  margin: 0;
}

/* The rest of a joined meta string, drawn under the title when the projection carried
   more than the time (renderAgendaRow in view.js). The same margin reset the grocery
   list's meta line needed: .view-item is a flex column with its own gap, and
   app-row-meta's top margin would double-space inside it. */
.view-agenda-context {
  margin: 0;
}

.view-agenda-bar {
  flex: none;
  align-self: stretch;
  width: 4px;
  border-radius: var(--radius-control);
  background: var(--muted);
}

/* Task checkbox rows: the box is decorative -- this feature has no per-task "done" state
   to project, so it states only what is already true, that this is an open task. */
.view-task-list {
  display: flex;
  flex-direction: column;
}

.view-task-row {
  display: flex;
  align-items: center;
  gap: 0.65rem;
  padding: 0.5rem 0;
}

.view-task-row:first-child {
  padding-top: 0;
}

/* The content column grows to fill the row so a trailing Later button sits at the far
   edge, the same "growing opener, fixed trailing control" shape .today-row and
   .agenda-task already use. */
.view-task-row > .view-item {
  flex: 1 1 0;
  min-width: 0;
}

.view-task-later {
  flex: none;
}

.view-meal-plan {
  display: flex;
  flex-direction: column;
  gap: 0.75rem;
}

/* A meal row is just its content -- title/meta/text like every other block -- plus an
   optional "Mark eaten" button underneath, so a plain column with a small gap is all the
   layout it needs. */
.view-meal-row {
  display: flex;
  flex-direction: column;
  align-items: flex-start;
  gap: 0.35rem;
}

/* Grocery chips: the wrapper keeps the contract's own divider-list idiom (see view.js --
   a shopping list is still one document, not things in their own right), but a line on a
   page is not how a household scans a list of things to buy, so it wraps into pills
   instead of stacking with dividers between them. */
.view-grocery-list.app-divider-list {
  display: flex;
  flex-wrap: wrap;
  gap: 0.5rem;
}

.view-grocery-list.app-divider-list > * + * {
  border-top: 0;
}

.view-grocery-list .view-item {
  flex-direction: row;
  align-items: baseline;
  gap: 0.35rem;
  padding: 0.4rem 0.7rem;
  background: var(--panel-subtle);
  border: 1px solid var(--line);
  border-radius: var(--radius-control);
}

.view-grocery-list .view-item-meta {
  margin: 0;
}

/* Email rows: a bare divider list of .view-item rows -- no chip wrap, no semantic wrapper of
   its own the way agenda/task-list/meal-plan each have (see view.js) -- so the shared
   .app-divider-list rhythm above is the only line between messages, each row reading title
   over meta, subject over sender, exactly the two fields Today's own email cards already
   show. display: flex here matches the other three list blocks' own identity rule; unlike
   grocery-list it stays a column, since a mail summary is read top to bottom, not scanned as
   pills. */
.view-email-review {
  display: flex;
  flex-direction: column;
}

/* The wall route (see ARCHITECTURE.md, "The wall route") is a TV in a shared room with nobody
   at a keyboard, reached here through a published Screen taking over Calendar -- the one
   destination WALL_MODE (chat.js) pins the wall to. One person's pending mail has no business
   on a shared screen, so this block draws nothing there, the same CSS-only treatment
   body.wall .chat-header / #command-bar above already gives the header and command bar: no
   new flag, just the one WALL_MODE already sets on <body>. */
body.wall .view-email-review {
  display: none;
}

/* -----------------------------------------------------------------------------------
   SLICE Q: the Screen's controls. The wall is excluded from all of them not by a rule
   here but by chat.js's own render() call, which hands view.js no helpers at all on
   the wall (WALL_MODE) -- with no helper, view.js draws none of these elements in the
   first place, so there is nothing here for the wall to hide. The rules below style
   what each control looks like once view.js has decided to draw it; they draw nothing
   on their own and choose no behaviour. */

/* The Screen's checkboxes -- the task list's, the grocery list's, and the generic
   `checkbox` primitive's -- are all the shared .app-check at its small size, composed
   by view.js. The decorative, always-open ring a task list draws when it has no
   done-state to project is the same class on a <span>, which simply never matches
   :checked. Three copies of the ringed circle, and two prefers-reduced-motion twins,
   used to live here. */

/* The wrapper renderGroceryRow (view.js) draws around a checkbox and its pill: plain
   placement only, so the pill's own chip look (.view-grocery-list .view-item, below
   and unchanged) still reads exactly as it did before this row ever grew a leading
   control. */
.view-grocery-row {
  display: flex;
  align-items: center;
  gap: 0.35rem;
}

/* renderEmailRow (view.js) wraps a mail row in this element only once a dismissItem
   helper and an id are present, so it is the interactive shape's own wrapper --
   the plain, uninteractive shape stays the bare .view-item the block always drew,
   with no wrapper of its own. Padding and the divider between rows both move here
   from the bare .view-item they used to sit directly on, so the rhythm looks
   identical either way: .app-divider-list's own `> * + *` rule (styles.css, Part I)
   draws the same line under this wrapper that it always drew under a bare row. */
.view-email-row {
  padding: 0.5rem 0;
}

.view-email-review > .view-email-row:first-child {
  padding-top: 0;
}

.view-email-actions {
  display: flex;
  align-items: center;
  gap: 0.35rem;
  margin-top: 0.3rem;
}

/* Dismiss reads as the same quiet row action Email Review's own header already
   uses (.review-quiet-action, Part I) -- this class only places it under the row
   instead of beside a header this block has none of. */
.view-email-dismiss {
  flex: none;
}

/* The meal-plan "Mark eaten" button, added solely when a markMealEaten helper and an
   id are present, sits under the row's content -- .view-meal-row's own column flex
   and gap (above) already places it there. */
.view-meal-eaten {
  flex: none;
}

/* -----------------------------------------------------------------------------------
   The registered-capability primitives (list, list-row, checkbox, button, image, audio;
   see agents/view/catalog.ts and view.js's own header comment). Unlike the five legacy
   blocks above, none of these gets a default grouped surface: a `list` already has a
   `variant` slot to ask for one explicitly, the same way section/stack/row/grid do, so
   nothing here would be free to override. Every control still reuses the Screen's one
   existing look per kind -- the ringed-circle checkbox, .app-quiet-button -- rather than
   drawing a second one, exactly as SLICE Q's own controls above already do.
   ----------------------------------------------------------------------------------- */

/* A list's own rows touch with a divider between them (view.js gives the wrapper
   .app-divider-list alongside this class), the same rhythm email-review's bare rows
   already read at -- a document-composed list is lines inside one page, not cards. */
.view-list {
  display: flex;
  flex-direction: column;
}

.view-list-row {
  display: flex;
  align-items: center;
  gap: 0.65rem;
  padding: 0.5rem 0;
}

.view-list-row:first-child {
  padding-top: 0;
}

/* A generic checkbox sits beside the one label the node itself carries: this
   primitive has no projection row of its own to draw a title from, unlike a task-list
   or grocery-list item, so the label IS the row's whole content. */
.view-checkbox-row {
  display: flex;
  align-items: center;
  gap: 0.65rem;
}

.view-checkbox-label {
  color: var(--ink);
  font-size: var(--text-detail-body);
  overflow-wrap: anywhere;
}

/* A registered-command button reads at the start of whatever row or list-row holds it,
   never stretched to fill it -- the same self-sizing every other button on the Screen
   already keeps. */
.view-button {
  align-self: flex-start;
}

.view-image {
  max-width: 100%;
}

/* audio's own row: the same title/meta content column every other block reads, plus a
   play control that sits beside it rather than under it. */
.view-audio-row {
  display: flex;
  align-items: center;
  gap: 0.65rem;
  padding: 0.5rem 0;
}

.view-audio-row:first-child {
  padding-top: 0;
}

.view-audio-row .view-item {
  flex: 1 1 auto;
  min-width: 0;
}

.view-audio-play {
  flex: none;
}

/* The icon face's sizing, scoped to this class rather than added to .icon-button: this
   stays an .app-quiet-button pill, matching Music's own Play button (.music-play svg). */
.view-audio-play svg {
  width: 18px;
  height: 18px;
}

/* The browser panel needs real width for a page to be legible in it -- the shared
   .app-dialog cap (40rem) is right for a review list, not a browser viewport. A view turn
   goes further: the page itself scrolls inside the remote browser, so the dialog is a tall
   column whose body refuses the scroll the remote page already owns. */
.browser-panel {
  width: min(64rem, calc(100% - 1.5rem));
  height: min(92vh, 60rem);
  height: min(92dvh, 60rem);
  max-height: min(92vh, 60rem);
  max-height: min(92dvh, 60rem);
}

.browser-panel .browser-panel-frame {
  display: flex;
  flex-direction: column;
  height: 100%;
  max-height: none;
}

.browser-panel-body {
  display: flex;
  flex-direction: column;
  flex: 1 1 auto;
  min-height: 0;
  gap: 0.75rem;
  overflow: hidden;
}

.browser-panel-status {
  margin: 0;
  color: var(--muted);
}

/* One compact header in every mode: [back][forward][reload] title … [external link] on a
   row, the status line small and single-line beneath. The nav buttons are quiet 44px icon
   buttons, not the gold primary pill. */
.browser-panel-header {
  flex-wrap: wrap;
  justify-content: flex-start;
  gap: 0.25rem;
  padding: 0.35rem 0.5rem;
}

/* App typography cannot resize text inside the rendered remote-page image; only the app's own
   title, status, address and actions here follow the text-size preset and wrap. */
.browser-panel-header h2 {
  flex: 1 1 0;
  min-width: 0;
  overflow-wrap: break-word;
  padding-left: 0.25rem;
}

.browser-nav-button {
  flex: none;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  width: 44px;
  min-width: 44px;
  height: 44px;
  min-height: 44px;
  padding: 0;
  background: transparent;
  border-color: transparent;
  border-radius: var(--radius-pill);
  color: var(--ink);
}

.browser-nav-button:hover,
.browser-nav-button:focus-visible {
  background: var(--panel-subtle);
}

.browser-nav-button[hidden] {
  display: none;
}

.browser-nav-button svg,
.browser-open-external svg {
  width: 20px;
  height: 20px;
  stroke-width: 2;
}

.browser-panel-header .browser-panel-status {
  flex: 1 0 100%;
  order: 1;
  min-width: 0;
  overflow-wrap: anywhere;
  padding: 0 0.25rem 0.15rem;
  font-size: var(--text-support);
}

.browser-open-external {
  flex: none;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  min-width: 44px;
  min-height: 44px;
  color: var(--muted);
}

.browser-open-external:hover,
.browser-open-external:focus-visible {
  color: var(--ink);
}

.browser-open-external[hidden],
.browser-address-form[hidden],
.browser-type-form[hidden],
.browser-panel-footer[hidden] {
  display: none;
}

.browser-address-form,
.browser-type-form {
  display: flex;
  gap: 0.5rem;
}

.browser-address-input,
.browser-type-input {
  flex: 1 1 auto;
  min-width: 0;
  min-height: var(--touch-min);
}

.browser-address-go,
.browser-type-send {
  flex: none;
}

/* The key bar: an overlay on the focused field of the remote page, laid out by browser-panel.js
   (left/top/width/height in the wrap's own px). Opaque, so the page image under it cannot show
   through the person's typing; it rides up with the frame when the keyboard needs the room. The
   input's 16px is on purpose: an iOS Safari input below 16px zooms the page on focus. */
.browser-key-bar {
  position: absolute;
  z-index: 2;
  box-sizing: border-box;
  min-height: 36px;
  transform: translateY(calc(-1 * var(--browser-frame-shift, 0px)));
}

.browser-key-bar[hidden] {
  display: none;
}

.browser-key-input {
  display: block;
  box-sizing: border-box;
  width: 100%;
  height: 100%;
  min-height: 36px;
  margin: 0;
  padding-block: 0;
  padding-inline: 8px;
  border: 2px solid var(--accent);
  border-radius: var(--radius-control);
  background: var(--panel);
  color: var(--ink);
  font: inherit;
  font-size: 16px;
}

.browser-job-note {
  margin: 0;
  padding: 0.5rem 0.25rem;
  color: var(--ink);
  font-size: var(--text-detail-body);
  line-height: 1.4;
  white-space: pre-line;
  overflow-wrap: anywhere;
  max-height: 8.5rem;
  overflow-y: auto;
  flex: none;
}

.browser-job-note.browser-job-note-alone {
  max-height: none;
}

.browser-job-note[hidden] {
  display: none;
}

.browser-view-wrap[hidden] {
  display: none;
}

.browser-view-wrap {
  position: relative;
  flex: 1 1 auto;
  min-height: 0;
  border: 1px solid var(--line);
  border-radius: var(--radius-surface);
  background: var(--panel);
  overflow: hidden;
}

.browser-view-wrap[aria-busy="true"]::after {
  content: 'Loading page…';
  position: absolute;
  inset: 0;
  display: flex;
  align-items: center;
  justify-content: center;
  background: var(--panel);
  color: var(--muted);
}

.browser-frame {
  display: block;
  width: 100%;
  height: auto;
  object-fit: contain;
  transform: translateY(calc(-1 * var(--browser-frame-shift, 0px)));
  background: var(--panel-subtle);
  touch-action: none;
  user-select: none;
  -webkit-user-drag: none;
  -webkit-touch-callout: none;
}

.browser-frame:not([src]) {
  visibility: hidden;
}

.browser-frame-disabled {
  pointer-events: none;
  opacity: 0.6;
}

.browser-panel-error {
  margin: 0;
  color: var(--error);
}

/* The Browser page: browser-panel.js moves the panel's frame out of its dialog into this
   host, so none of the .browser-panel rules above reach it. The pane itself neither
   scrolls nor pads for the composer -- .chat-lower already reserves that clearance -- so the
   host takes whatever height is left and the frame fills it, with only the remote page
   scrolling inside. The frame is its own opaque surface, as it is in the dialog. */
.browser-page {
  display: flex;
  flex-direction: column;
  flex: 1 1 0;
  min-height: 0;
  padding: 0 var(--list-gutter);
  overflow: hidden;
}

.browser-page[hidden] {
  display: none;
}

.browser-page-host {
  display: flex;
  flex: 1 1 auto;
  min-height: 0;
  padding-bottom: 0.5rem;
}

.browser-page-host .browser-panel-frame {
  position: static;
  flex: 1 1 auto;
  width: 100%;
  height: 100%;
  max-height: none;
  min-height: 0;
  border: 1px solid var(--line);
  border-radius: var(--radius-surface);
  background: var(--panel);
  overflow: hidden;
}

/* The composer above a tall task list, or a header that wraps to three rows on a phone, can
   leave the frame almost no height. It keeps a usable minimum and the body scrolls instead. */
.browser-page-host .browser-panel-body {
  overflow-y: auto;
  overscroll-behavior: contain;
}

.browser-page-host .browser-view-wrap {
  flex: 1 1 12rem;
  min-height: 12rem;
}

/* The page header hands the pane's inset back, as every scroller pane's does, so the title
   lands at --page-gutter like the other destinations. */
.browser-page .app-page-header {
  margin-inline: calc(var(--list-gutter) * -1);
}

@media (max-width: 44.99rem) {
  .browser-page-host .browser-panel-body {
    padding: 0.5rem;
    gap: 0.5rem;
  }
}

/* On a phone the panel stops being a dialog at all -- it is the screen, edge to edge
   behind the notches, with only the remote page scrolling inside it. */
@media (max-width: 44.99rem) {
  .browser-panel {
    width: 100%;
    max-width: 100%;
    height: 100vh;
    height: 100dvh;
    max-height: 100vh;
    max-height: 100dvh;
    margin: 0;
    border: none;
    border-radius: 0;
  }

  .browser-panel .browser-panel-frame {
    padding-top: env(safe-area-inset-top, 0px);
    padding-bottom: calc(env(safe-area-inset-bottom, 0px) + var(--browser-keyboard-inset, 0px));
    padding-left: env(safe-area-inset-left, 0px);
    padding-right: env(safe-area-inset-right, 0px);
  }

  .browser-panel-body {
    padding: 0.5rem;
    gap: 0.5rem;
  }
}

/* A handoff: a paused job at a sign-in wall (browser-panel.js puts browser-panel-handoff on the
   frame, wherever it is mounted, and browser-panel-signin on the dialog when it is the modal).
   The header is one row -- site name, Check sign-in, Close -- and one line beneath; the page is
   edge to edge below it, with nothing else on screen. The page image keeps its own pixels; only
   the app's title, line and note scale. */
.browser-signin-control[hidden],
.browser-signin-line[hidden],
.browser-signin-note[hidden] {
  display: none;
}

/* Two classes, so it outranks .app-dialog-header button (44px). */
.browser-panel-header .browser-signin-control {
  flex: none;
  min-block-size: 48px;
  min-inline-size: 48px;
  padding: 0 0.75rem;
}

.browser-signin-line,
.browser-signin-note {
  flex: 1 0 100%;
  order: 1;
  min-width: 0;
  margin: 0;
  padding: 0 0.25rem 0.15rem;
  overflow-wrap: anywhere;
  font-size: var(--text-support);
  color: var(--muted);
}

.browser-signin-note {
  color: var(--ink);
}

.browser-key-input.browser-key-secret {
  -webkit-text-security: disc;
}

.browser-panel-handoff .browser-panel-header {
  gap: 0;
  padding: 0.25rem 0.5rem;
}

.browser-panel-handoff .browser-panel-header h2 {
  flex: 1 1 0;
  min-width: 0;
  overflow: hidden;
  white-space: nowrap;
  text-overflow: ellipsis;
}

.browser-panel-handoff .browser-panel-body {
  padding: 0;
  gap: 0;
}

.browser-panel-handoff .browser-view-wrap {
  border: none;
  border-radius: 0;
}

.browser-panel-handoff .browser-panel-error {
  padding: 0.5rem;
}

/* The modal handoff on a wide screen: a bottom sheet, anchored to the bottom of the VISIBLE
   viewport (--browser-visual-height and --browser-keyboard-inset are set from visualViewport
   while it is open) with the app still showing above it. */
.browser-panel.browser-panel-signin {
  width: min(100%, 36rem);
  max-width: min(100%, 36rem);
  height: min(calc(var(--browser-visual-height, 100dvh) - 2rem), 56rem);
  max-height: min(calc(var(--browser-visual-height, 100dvh) - 2rem), 56rem);
  margin-block: auto var(--browser-keyboard-inset, 0px);
  margin-inline: auto;
  padding-bottom: max(env(safe-area-inset-bottom, 0px), 0.5rem);
  border-radius: var(--radius-overlay) var(--radius-overlay) 0 0;
  border-bottom: none;
}

.browser-panel.browser-panel-signin .browser-panel-frame {
  height: 100%;
  max-height: none;
}

.browser-panel.browser-panel-signin .browser-view-wrap {
  flex: 1 1 auto;
  min-height: 0;
}

/* On a phone the modal handoff is the whole visible screen: the page edge to edge, the safe-area
   insets kept clear. Declared after the sheet rules so its width, height and margins win. */
@media (max-width: 44.99rem) {
  .browser-panel.browser-panel-signin {
    width: 100%;
    max-width: 100%;
    height: var(--browser-visual-height, 100dvh);
    max-height: var(--browser-visual-height, 100dvh);
    margin: 0;
    padding: 0;
    border: none;
    border-radius: 0;
  }

  /* The phone block above already clears the top and side insets; the dialog is as tall as the
     visible viewport, so the bottom needs the home indicator only, never the keyboard inset too. */
  .browser-panel.browser-panel-signin .browser-panel-frame {
    padding-bottom: env(safe-area-inset-bottom, 0px);
  }

  .browser-panel.browser-panel-signin.browser-panel-keyboard .browser-panel-frame {
    padding-bottom: 0;
  }
}

@media (prefers-reduced-motion: no-preference) {
  .browser-panel.browser-panel-signin[open] {
    animation: browser-sheet-in 160ms ease-out;
  }
}

@keyframes browser-sheet-in {
  from {
    transform: translateY(1rem);
  }
  to {
    transform: translateY(0);
  }
}

/* The web shell freshness fallback notice (web-shell-freshness.js): the "Updating Rundell
   OS screen…" line that must paint over the page for the 150 ms before the reload takes it
   away. chat.js never passes its own announce, so this is the notice. Fixed and centred at
   the top over everything -- 40 (.command-bar) is the topmost overlay this stylesheet
   stacks -- on the raised surface with a visible border, the two tokens a floating chip
   already reads as. The "Update ready" chip below shares this placement and surface. */
.web-shell-notice,
.web-shell-update-chip {
  position: fixed;
  top: calc(env(safe-area-inset-top, 0px) + 0.5rem);
  left: 50%;
  transform: translateX(-50%);
  z-index: 50;
  margin: 0;
  padding: var(--control-pad-y) var(--control-pad-x);
  border: 1px solid var(--line-strong);
  border-radius: var(--radius-control);
  background: var(--surface-raised);
  color: var(--ink);
}

/* The "Update ready" chip: placed and surfaced by the shared rule above, plus what a button
   adds -- tapping it is the person's explicit choice to reload now. No display set, so
   [hidden] still hides it. */
.web-shell-update-chip {
  min-height: 44px;
  font: inherit;
  cursor: pointer;
}

/* The "Couldn't load" notice (chat.js syncModuleFailureNotice): shown on a display whose
   script failed to start. Same placement vocabulary as the chip above, lower so it sits
   under the header rather than over it. No display set, so [hidden] still hides it. */
.module-failure-notice {
  position: fixed;
  top: 5rem;
  left: 50%;
  transform: translateX(-50%);
  z-index: 50;
  margin: 0;
  padding: var(--control-pad-y) var(--control-pad-x);
  border: 1px solid var(--line-strong);
  border-radius: var(--radius-control);
  background: var(--surface-raised);
  color: var(--ink);
}

.module-failure-notice button {
  min-height: 44px;
  padding: 0 0.5rem;
  border: 0;
  background: none;
  color: inherit;
  font: inherit;
  text-decoration: underline;
  cursor: pointer;
}

.browser-logins-list {
  list-style: none;
  margin: 0;
  padding: 0;
}

/* Settings -> AI usage: the month's total, two breakdown lists and a 30-day sparkline
   (ai-usage.js draws all of it). The sparkline stroke is in screen pixels
   (non-scaling-stroke), so its width is too, not viewBox units. */
.ai-usage-total {
  margin: 0;
  color: var(--ink);
  font-size: var(--text-page-title);
  font-variant-numeric: tabular-nums;
}

.ai-usage-row {
  display: flex;
  align-items: baseline;
  justify-content: space-between;
  gap: 12px;
  padding-block: 10px;
}

.ai-usage-row .settings-row-main {
  display: flex;
  min-width: 0;
  flex-direction: column;
  gap: 2px;
  overflow-wrap: anywhere;
}

.ai-usage-amount {
  flex: none;
  font-variant-numeric: tabular-nums;
}

.ai-usage-trend {
  display: block;
  width: 100%;
  height: 3rem;
}

.ai-usage-trend polyline {
  fill: none;
  stroke: var(--chart-ink);
  stroke-width: 1.5;
  vector-effect: non-scaling-stroke;
}

/* Settings -> Devices: rows, icons and dialogs (devices.js builds all of them). */
.devices-add-row {
  display: flex;
  margin-bottom: 0.75rem;
}

.devices-row {
  display: grid;
  grid-template-columns: 40px minmax(0, 1fr) auto;
  column-gap: 12px;
  row-gap: 8px;
  align-items: center;
  padding-block: 10px;
}

.devices-row .settings-row-main {
  display: flex;
  min-width: 0;
  flex-direction: column;
  gap: 2px;
}

.devices-row .app-row-title {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: 0.4rem;
}

.devices-name {
  min-width: 0;
  overflow-wrap: anywhere;
}

.devices-row .app-row-meta {
  overflow-wrap: anywhere;
}

.devices-row-actions {
  display: flex;
  gap: 8px;
}

:root[data-text-size="large"] .devices-row-actions {
  grid-column: 2 / -1;
  justify-self: start;
}

@media (max-width: 30rem) {
  .devices-row-actions {
    grid-column: 2 / -1;
    justify-self: start;
  }
}

.devices-icon {
  display: grid;
  flex: none;
  width: 40px;
  height: 40px;
  place-items: center;
  border-radius: var(--radius-tile);
  background: var(--panel);
  color: var(--muted);
}

.devices-icon svg {
  display: block;
  width: 22px;
  height: 22px;
}

.devices-dialog .app-dialog-body {
  overflow-y: auto;
  overflow-wrap: anywhere;
}

.devices-dialog .app-dialog-body > p:first-child {
  margin-top: 0;
}

.devices-qr {
  box-sizing: border-box;
  width: min(16rem, 100%);
  padding: 12px;
  margin: 0.75rem auto;
  border-radius: var(--radius-nested);
  /* A QR needs a white quiet zone in every theme. */
  background: var(--qr-ground);
}

.devices-qr:empty {
  display: none;
}

.devices-qr svg {
  display: block;
  width: 100%;
  height: auto;
}

.devices-code {
  margin: 0;
  font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
  font-variant-numeric: tabular-nums;
  font-size: var(--text-page-title);
  letter-spacing: 0.12em;
  user-select: all;
  overflow-wrap: anywhere;
}

.devices-code-pending {
  color: var(--muted);
}

.devices-pending-identity {
  display: flex;
  align-items: center;
  gap: 12px;
  margin: 0 0 0.75rem;
}

.devices-pending-identity .settings-row-main {
  display: flex;
  min-width: 0;
  flex-direction: column;
}

.devices-pending-identity .app-row-title,
.devices-pending-identity .app-row-meta {
  overflow-wrap: anywhere;
}

.devices-remove-confirm,
.devices-remove-confirm:hover,
.devices-remove-confirm:focus-visible {
  background: var(--error);
  border-color: var(--error);
  color: var(--on-accent);
}

.devices-remove-confirm:hover,
.devices-remove-confirm:focus-visible {
  background: color-mix(in srgb, var(--error) 85%, var(--ink));
}

#devices-code-form .app-row-meta {
  margin-top: 6px;
}

#devices-code-input {
  box-sizing: border-box;
  width: 100%;
  margin-top: 6px;
}

#devices-code-form .android-setup-account {
  margin-top: 12px;
}
