/* ==========================================================================
   DBQ Cams reskin  ·  2026-07-30
   Visual layer only: page structure and IA are unchanged. Two goals:
     1. Get the chrome out of the way so camera footage is the brightest,
        largest thing on screen.
     2. Make a grid of 45 near-identical thumbnails actually scannable.
   Loaded after style.css and effects.css, so it wins on equal specificity.
   ========================================================================== */

/* --- 1. Tokens ----------------------------------------------------------- */
:root {
    /* Surfaces step up as they come forward, so cards read without borders. */
    --bg: #080D17;
    --surface: #101827;
    --surface-hover: #1B2740;
    --border: #22304A;
    --border-strong: #2E3E5C;
    --text: #F2F6FC;
    --text-muted: #97A6BF;

    /* Status colours. Green means "fresh frame", not "this is a video". */
    --ok: #34D399;
    --stale: #FBBF24;
    --down: #64748B;

    --font-ui: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
    --font-mono: 'JetBrains Mono', ui-monospace, SFMono-Regular, Menlo, monospace;

    --radius: 14px;
    --shadow-sm: 0 1px 2px rgba(0, 0, 0, .35);
    --shadow-md: 0 6px 18px -6px rgba(0, 0, 0, .55);
    --shadow-lg: 0 18px 44px -12px rgba(0, 0, 0, .7);
    --route-us151: #F472B6;

    /* --- Spacing -------------------------------------------------------
       A 4px scale. Everything that separates two things picks from this,
       so the page has one rhythm instead of a dozen hand-typed rems. */
    --space-1: 4px;
    --space-2: 8px;
    --space-3: 12px;
    --space-4: 16px;
    --space-5: 24px;
    --space-6: 32px;
    --space-7: 48px;
    --space-8: 64px;

    /* --- Type ----------------------------------------------------------
       Six steps. The label step is the small uppercase one the section
       headings and status chips use; body is the floor for anything a
       person reads in a sentence. */
    --text-label: 0.72rem;   /* uppercase, tracked, for section and status */
    --text-xs: 0.78rem;      /* meta under a camera name, captions */
    --text-sm: 0.86rem;      /* secondary UI, sidebar rows */
    --text-base: 0.95rem;    /* body */
    --text-lg: 1.15rem;      /* card and panel titles */
    --text-xl: 1.5rem;       /* view titles, river reading */
    --text-2xl: 2rem;        /* the one number a card exists to show */

    --leading-tight: 1.25;
    --leading-normal: 1.5;
    --tracking-label: 0.09em;
    --tracking-tight: -0.012em;

    /* --- Shape ---------------------------------------------------------
       Three radii, not five. sm for chips and inputs, md for cards and
       panels, pill for anything round-ended. */
    --radius-sm: 8px;
    --radius-md: 14px;
    --radius-pill: 999px;

    /* --- Motion --------------------------------------------------------
       Two durations. Anything slower than 220ms on a utility people check
       for thirty seconds is in the way. */
    --dur-fast: 120ms;
    --dur-base: 200ms;
    --ease: cubic-bezier(.2, .7, .3, 1);

    /* --- Focus ---------------------------------------------------------
       One ring, used everywhere, so keyboard focus is never a surprise. */
    --focus-ring: 0 0 0 2px var(--bg), 0 0 0 4px var(--accent);

    /* --- Layers --------------------------------------------------------
       A named scale beats a scattering of 9999s. */
    --z-raised: 10;
    --z-sticky: 20;
    --z-overlay: 30;
    --z-modal: 50;
    --z-toast: 60;
}

@media (prefers-reduced-motion: reduce) {
    :root {
        --dur-fast: 1ms;
        --dur-base: 1ms;
    }
}

[data-theme="light"] {
    --bg: #F4F6FA;
    --surface: #FFFFFF;
    --surface-hover: #EDF1F7;
    --border: #DCE3ED;
    --border-strong: #C3CEDE;
    --text: #0D1526;
    --text-muted: #4E5D75;      /* 7.1:1 on white; the old #78716c was 4.4:1 */
    --ok: #059669;
    --stale: #B45309;
    --down: #64748B;
    --shadow-sm: 0 1px 2px rgba(16, 24, 39, .06);
    --shadow-md: 0 6px 18px -8px rgba(16, 24, 39, .18);
    --shadow-lg: 0 18px 44px -14px rgba(16, 24, 39, .24);
    --route-us151: #DB2777;
}

body {
    font-family: var(--font-ui);
    background-image: none;              /* drop the amber vignette */
    letter-spacing: -0.011em;            /* Inter sets tighter than Poppins */
}

/* Values that tick must not reflow as their digits change width. */
.cam-status-age,
.status-timer,
.road-temp {
    font-variant-numeric: tabular-nums;
    font-feature-settings: "tnum";
}

/* --- 2. Header: reclaim the space above the first camera ------------------ */
header {
    padding: 0.7rem 1.5rem;
    background-image: none;
}

.branding h1 {
    font-size: 1.35rem;
    letter-spacing: -0.02em;
}

.brand-link {
    padding-left: 0;
    gap: 0.6rem;
}

.tagline {
    font-size: 0.78rem;
    line-height: 1.3;
}

@media (max-width: 768px) {
    header { padding: 0.55rem 1rem; }
    .branding h1 { font-size: 1.2rem; }

    /* The tagline duplicates the page title and costs a whole row on a
       phone. Keep it for assistive tech and search, drop it visually. */
    .tagline {
        position: absolute;
        width: 1px;
        height: 1px;
        overflow: hidden;
        clip: rect(0 0 0 0);
        white-space: nowrap;
    }
    .mobile-br { display: none; }
}


/* --- 4. Section headings: quieter label, count on the right --------------- */
section { margin-bottom: 1.6rem; }

section h2 {
    font-size: 0.74rem;
    font-weight: 700;
    letter-spacing: 0.11em;
    text-transform: uppercase;
    display: flex;
    align-items: center;
    gap: 0.5rem;
}

section h2 .cam-count {
    font-size: 0.7rem;
    font-weight: 500;
    color: var(--text-muted);
    letter-spacing: 0.01em;
    text-transform: none;
    white-space: nowrap;
    margin-left: auto;
    margin-right: 0.4rem;
}

/* --- 5. The camera card -------------------------------------------------- */
/* Desktop only. Phones keep the existing 2-up grid from style.css: one
   full-width card per row turns 45 cameras into an endless scroll. */
@media (min-width: 769px) {
    .camera-grid {
        grid-template-columns: repeat(auto-fill, minmax(min(300px, 100%), 1fr));
        gap: 0.85rem;
    }
}

.camera-card {
    background: var(--surface);
    border: 1px solid var(--border);
    border-radius: var(--radius);
    box-shadow: var(--shadow-sm);
    display: flex;
    flex-direction: column;
    overflow: hidden;
    isolation: isolate;
}

.camera-card:hover,
.camera-card:focus-visible {
    border-color: var(--border-strong);
    box-shadow: var(--shadow-md);
    transform: none !important;   /* lift the footage, not the card */
}

.camera-thumb { transition: transform .35s cubic-bezier(.2, .7, .3, 1); }
.camera-card:hover .camera-thumb { transform: scale(1.035); }

.camera-thumb-container { background: #05080F; }

/* Offline cards recede so the working ones carry the grid. */
.camera-card.is-offline { opacity: .62; }
.camera-card.is-offline:hover { opacity: 1; }

.camera-thumb-error {
    flex-direction: column;
    gap: 0.5rem;
    background: repeating-linear-gradient(45deg, #0A0F1A 0 10px, #0C121F 10px 20px);
    color: var(--down);
    font-size: 0.72rem;
    font-weight: 500;
}

.camera-thumb-error svg {
    width: 22px;
    height: 22px;
    opacity: .8;
}

[data-theme="light"] .camera-thumb-error {
    background: repeating-linear-gradient(45deg, #EDF1F7 0 10px, #E6EBF3 10px 20px);
}

/* Status chip replaces the old red LIVE pill. Every camera was "live", so a
   red alert-coloured badge on all 45 conveyed nothing and covered the road.
   This says the one thing that varies: how fresh the frame is. */
.cam-status {
    position: absolute;
    left: 8px;
    bottom: 8px;
    z-index: 3;
    display: inline-flex;
    align-items: center;
    gap: 5px;
    padding: 3px 8px 3px 7px;
    border-radius: 999px;
    background: rgba(6, 10, 18, .72);
    -webkit-backdrop-filter: blur(6px);
    backdrop-filter: blur(6px);
    color: #E8EEF8;
    font-size: 0.63rem;
    font-weight: 600;
    letter-spacing: 0.04em;
    text-transform: uppercase;
    line-height: 1;
    pointer-events: none;
}

.cam-status-dot {
    width: 6px;
    height: 6px;
    border-radius: 50%;
    background: var(--ok);
    box-shadow: 0 0 0 3px rgba(52, 211, 153, .18);
    flex-shrink: 0;
}

.cam-status[data-kind="snapshot"] .cam-status-dot {
    background: var(--stale);
    box-shadow: 0 0 0 3px rgba(251, 191, 36, .18);
}

.cam-status-age {
    font-family: var(--font-mono);
    font-size: 0.62rem;
    font-weight: 500;
    letter-spacing: 0;
    text-transform: none;
    color: #9FB0C9;
}

.cam-status-age:not(:empty)::before {
    content: "\00b7";
    margin-right: 4px;
    color: #5D6E88;
}

.camera-card.is-offline .cam-status-dot {
    background: var(--down);
    box-shadow: none;
}

.camera-card.is-offline .cam-status {
    background: rgba(6, 10, 18, .6);
    color: #B7C3D6;
}

.camera-card.is-offline .cam-status-age { display: none; }

@media (prefers-reduced-motion: no-preference) {
    .camera-card:not(.is-offline) .cam-status[data-kind="live"] .cam-status-dot {
        animation: cam-pulse 2.4s ease-in-out infinite;
    }
}

@keyframes cam-pulse {
    0%, 100% { box-shadow: 0 0 0 3px rgba(52, 211, 153, .18); }
    50%      { box-shadow: 0 0 0 5px rgba(52, 211, 153, .05); }
}

/* Favourite: an SVG star that stays out of frame until reached for, and
   stays put once set. Was a grey block parked on the roadway. */
.fav-btn {
    top: 6px;
    right: 6px;
    left: auto;
    width: 34px;
    height: 34px;
    min-width: 0;
    min-height: 0;
    padding: 0;
    border-radius: 50%;
    background: rgba(6, 10, 18, .55);
    -webkit-backdrop-filter: blur(6px);
    backdrop-filter: blur(6px);
    color: #DCE5F2;
    font-size: 0;
    opacity: 0;
    transition: opacity .18s ease, color .18s ease, background-color .18s ease;
}

/* Outline until set, solid once set: a filled star on every card reads as
   "all favourited". */
.fav-btn svg {
    width: 18px;
    height: 18px;
    fill: none;
    stroke: currentColor;
    stroke-width: 1.7;
    stroke-linejoin: round;
}

.fav-btn.fav-active svg { fill: currentColor; }

.camera-card:hover .fav-btn,
.camera-card:focus-within .fav-btn,
.fav-btn:focus-visible { opacity: 1; }

.fav-btn:hover {
    background: rgba(6, 10, 18, .8);
    color: var(--accent-bright);
}

.fav-btn.fav-active {
    opacity: 1;
    color: #FBBF24;
}

/* Touch has no hover state, so the control can never be hidden there. Drop
   the pill background instead: 45 dark discs sitting on the footage is the
   same obstruction the old grey square caused, just rounder. Hit area stays
   at 44px, visual weight does not. */
@media (hover: none) {
    .fav-btn {
        opacity: 1;
        width: 44px;
        height: 44px;
        background: none;
        -webkit-backdrop-filter: none;
        backdrop-filter: none;
        color: rgba(255, 255, 255, .85);
        filter: drop-shadow(0 1px 2px rgba(0, 0, 0, .85));
    }
    .fav-btn:hover { background: none; }
    .fav-btn.fav-active { color: #FBBF24; }
}

/* Card text. Clamping the title to two lines keeps every grid row level;
   ragged card heights were leaving dead white space between rows. */
.camera-info {
    padding: 0.6rem 0.75rem 0.7rem;
    display: flex;
    flex-direction: column;
    gap: 0.15rem;
    flex: 1;
}

.camera-name {
    font-size: 0.9rem;
    font-weight: 600;
    line-height: 1.25;
    letter-spacing: -0.012em;
    color: var(--text);
    display: -webkit-box;
    -webkit-line-clamp: 2;
    -webkit-box-orient: vertical;
    overflow: hidden;
}

.camera-meta {
    font-size: 0.73rem;
    font-weight: 500;
    color: var(--text-muted);
    line-height: 1.3;
}

/* List view: thumbnail beside the label rather than a full-width stack. */
@media (min-width: 640px) {
    .camera-grid.list-view .camera-card {
        flex-direction: row;
        align-items: stretch;
    }
    .camera-grid.list-view .camera-thumb-container {
        width: 210px;
        flex: 0 0 210px;
    }
    .camera-grid.list-view .camera-info { justify-content: center; }
}

/* --- 6. Nav -------------------------------------------------------------- */
.nav-link {
    padding: 0.7rem 1rem;
    font-size: 0.85rem;
    font-weight: 500;
}

.nav-link.active { text-shadow: none; }

/* --- 7. Footer links column ---------------------------------------------- */
.footer-links {
    display: flex;
    flex-direction: column;
    gap: 0.45rem;
}

.footer-links a {
    color: var(--text-muted);
    text-decoration: none;
    font-size: 0.85rem;
    transition: color .18s ease;
}

.footer-links a:hover { color: var(--accent); }

/* --- 8. Phone sizing ------------------------------------------------------ */
@media (max-width: 768px) {
    /* The floating bottom nav sits over the page, so the last row of cards
       needs clearance or it can never be fully scrolled into view. */
    body { padding-bottom: 5.5rem; }

    /* style.css drops these to 0.7rem, which is unreadable at arm's length
       in a moving-vehicle context. */
    .camera-card .camera-name {
        font-size: 0.78rem;
        line-height: 1.25;
    }
    .camera-meta { font-size: 0.68rem; }
    .camera-card .camera-info { padding: 0.45rem 0.55rem 0.55rem; }

    .cam-status {
        left: 6px;
        bottom: 6px;
        padding: 3px 7px 3px 6px;
        font-size: 0.58rem;
    }
    .cam-status-age { font-size: 0.58rem; }

    /* Touch targets: every control the thumb actually aims for gets 44px.
       Section headings double as collapse toggles, so they count too. */
    .fav-btn { width: 44px; height: 44px; }
    /* effects.css pins this to 40px with !important. */
    #backToTop.back-to-top { width: 44px !important; height: 44px !important; }
    .consent-accept { min-height: 44px; padding-inline: 1.1rem; }
    section h2 {
        min-height: 44px;
        padding-top: 0.35rem;
        padding-bottom: 0.35rem;
    }
    .brand-link { padding-block: 0.4rem; }

    /* Tighten the run-up to the first camera: search was ~115px tall and the
       gaps around it added another ~90px of nothing. */
    #camera-search,
    .camera-search-wrap input,
    .search-container input {
        min-height: 44px;
        height: 44px;
        font-size: 0.9rem;
    }
    .search-container,
    .camera-search-wrap { padding-block: 0.55rem; }
    #main-content { padding-top: 0.5rem; }
    section h2 { margin-bottom: 0.5rem; }
}

/* --- 9. Accessibility ---------------------------------------------------- */
:focus-visible {
    outline: 2px solid var(--accent);
    outline-offset: 2px;
    border-radius: 4px;
}

.camera-card:focus-visible { outline-offset: 3px; }

@media (prefers-reduced-motion: reduce) {
    *,
    *::before,
    *::after {
        animation-duration: .001ms !important;
        animation-iteration-count: 1 !important;
        transition-duration: .001ms !important;
        scroll-behavior: auto !important;
    }
}


/* ==========================================================================
   LAYOUT PASS  ·  2026-07-30 (second review)
   Fixes information-hierarchy problems the first reskin left alone.
   ========================================================================== */

/* --- A. Sidebar priority ---------------------------------------------------
   Was: Local Weather (510px, the tallest block on the page and a duplicate of
   the header status bar) > Road Surface > Closings (70px, the smallest).
   On a road-conditions site, closures and pavement state outrank a forecast.
   .sidebar is a column flexbox, so this reorders without touching the DOM or
   the clone-into-other-tabs logic. */
.sidebar > * { order: 50; }
#sidebar-closings        { order: 1; }
#sidebar-road-conditions { order: 2; }
#sidebar-river           { order: 3; }
#sidebar-weather         { order: 4; }
.sidebar > .sidebar-ad   { order: 20; }
.sidebar > .sidebar-contact   { order: 60; }
.sidebar > .sidebar-advertise { order: 61; }

/* The header already prints temp, wind and humidity. Shrink the duplicated
   hero reading so the 5-day forecast (the part that is NOT duplicated)
   carries the card. */
.sidebar-weather-temp { font-size: 1.6rem !important; line-height: 1.15; }
.sidebar-weather-icon .wx-icon { width: 1.7rem; height: 1.7rem; }

/* Closings shortcut deserves presence rather than being the smallest card. */
#sidebar-closings .sidebar-closings-shortcut { display: block; }

/* --- B. Road surface rows -------------------------------------------------- */
.road-group-label {
    font-size: 0.62rem;
    font-weight: 700;
    letter-spacing: 0.11em;
    text-transform: uppercase;
    color: var(--text-muted);
    margin: 0.75rem 0 0.35rem;
}
.road-group-label:first-child { margin-top: 0; }

.road-sensor-route {
    display: flex;
    flex-direction: column;
    line-height: 1.25;
}
.road-sensor-county {
    font-size: 0.66rem;
    font-weight: 500;
    color: var(--text-muted);
}
.road-note {
    margin-top: 0.6rem;
    font-size: 0.68rem;
    line-height: 1.45;
    color: var(--text-muted);
}

/* --- C. Search folded into the filter row ---------------------------------
   A 1026px-wide input to filter 45 items was its own full-width band. It now
   shares the sticky filter row, buying back roughly 90px above the fold. */
@media (min-width: 769px) {
    /* The old full-width search band is empty once the input moves. */
    .search-band-empty { display: none !important; }
}

/* --- D. Density on wide screens -------------------------------------------
   3 columns at 1440 left the grid at 333px per card. A camera wall should
   show more per screen once there is room for it. */
@media (min-width: 1500px) {
    .camera-grid {
        grid-template-columns: repeat(auto-fill, minmax(min(270px, 100%), 1fr));
    }
}

/* --- E. Section heading: keep the count next to the title ------------------
   Title far left and "22 cams" ~800px away at far right read as unrelated. */
section h2 .cam-count {
    margin-left: 0.15rem;
    margin-right: auto;
    opacity: 0.85;
}
section h2 .section-chevron { margin-left: auto; }

/* --- F. Flatten the box-in-box --------------------------------------------
   page background > section panel > card was three near-identical surfaces. */
#main-content section {
    background: transparent !important;
    border-color: transparent !important;
    box-shadow: none !important;
    padding-left: 0;
    padding-right: 0;
    /* The panels are gone, but their 1.25rem outward bleed (effects.css, the
       tint block) survived this flattening. It fits inside the 24px desktop
       gutter; the phone gutter is 12px, so every card, heading and section
       toggle hung 8px off both screen edges. html { overflow-x: hidden } hid
       that instead of reporting it. No panel, no bleed. */
    margin-left: 0;
    margin-right: 0;
}


/* --- G. Offline cards fall to the end of their section --------------------
   The catalog sort handles this at first paint, but a camera's real state is
   only known once its thumbnail resolves, and a live one can drop out at any
   time. Grid items honour `order`, so let the layout react to the live class
   instead of freezing the order at load. Result: the first slot on the page
   is never a dead camera. */
.camera-card.is-offline { order: 1; }
#favorites-grid .camera-card.is-offline { order: 0; }  /* respect user order */

/* --- H. Use the screen on wide displays -----------------------------------
   The layout was capped at 1400px, so a 1680px display still showed only 3
   columns of cameras with dead margin either side. */
@media (min-width: 1500px) {
    .page-layout { max-width: 1600px; }
    .camera-grid {
        grid-template-columns: repeat(auto-fill, minmax(min(255px, 100%), 1fr));
    }
}
@media (min-width: 1800px) {
    .page-layout { max-width: 1800px; }
}

/* --- I. Road station name and county on separate lines -------------------- */
.road-sensor-route { display: block; }
.road-sensor-county { display: block; margin-top: 1px; }


/* --- J. Compact search inside the filter row ------------------------------
   The base input styling is scoped to `.camera-search-bar input`, so once the
   field moves into the filter bar it matches nothing and loses its padding,
   which left the magnifier sitting on top of the placeholder. Restyle it for
   its new home rather than widening the original selector. */
@media (min-width: 769px) {
    .cam-search-inline .camera-search-wrap { position: relative; width: 100%; }

    .cam-search-inline #camera-search {
        width: 100%;
        height: 34px;
        padding: 0 0.85rem 0 2.2rem;
        background: rgba(255, 255, 255, .06);
        border: 1px solid var(--border);
        border-radius: 999px;
        color: var(--text);
        font-family: inherit;
        font-size: 0.8rem;
        outline: none;
        box-shadow: none;
        transition: border-color .2s, background-color .2s;
    }
    .cam-search-inline #camera-search::placeholder { color: #93A3BC; }
    .cam-search-inline #camera-search:focus {
        border-color: var(--accent);
        background: rgba(255, 255, 255, .10);
    }
    .cam-search-inline .search-icon {
        left: 0.65rem;
        width: 15px;
        height: 15px;
        color: #93A3BC;
    }

    [data-theme="light"] .cam-search-inline #camera-search {
        background: rgba(255, 255, 255, .10);
        color: #F2F6FC;
    }
    [data-theme="light"] .cam-search-inline #camera-search:focus {
        background: rgba(255, 255, 255, .16);
    }
}

/* --- K. Sidebar cards: even rhythm now that order changed ----------------- */
#sidebar-closings .sidebar-closings-shortcut { text-decoration: none; color: inherit; }


/* ==========================================================================
   ACCESSIBILITY PASS  ·  2026-07-30
   Driven by an axe-core sweep across 5 views x 2 themes x 2 viewports.
   ========================================================================== */

/* --- L. Card is a container; one stretched button carries the action ------
   Replaces role="button" + tabindex on the <article>, which nested the
   favourite button inside another control. */
.camera-card { position: relative; }

.cam-open {
    position: absolute;
    inset: 0;
    z-index: 2;
    width: 100%;
    height: 100%;
    padding: 0;
    border: 0;
    background: none;
    cursor: pointer;
    border-radius: inherit;
    -webkit-appearance: none;
    appearance: none;
}

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

/* Favourite must sit above the stretched button to stay clickable. */
.fav-btn { z-index: 6; }
.cam-status { z-index: 4; }

/* --- M. Section heading toggle -------------------------------------------
   role="button" is invalid on a heading and hides it from the outline. The
   heading keeps its semantics; a real button inside does the collapsing. */
.section-toggle {
    display: flex;
    align-items: center;
    gap: inherit;
    width: 100%;
    padding: 0;
    background: none;
    border: 0;
    color: inherit;
    font: inherit;
    letter-spacing: inherit;
    text-transform: inherit;
    text-align: left;
    cursor: pointer;
}
section h2 { gap: 0; }
.section-toggle { gap: 0.5rem; }
.section-toggle:focus-visible { outline: 2px solid var(--accent); outline-offset: 3px; }

/* --- N. Contrast ----------------------------------------------------------
   These were all a muted colour with opacity stacked on top, landing at
   ~4.05-4.14:1 against the card surface. WCAG AA needs 4.5:1. */
.footer-disclaimer {
    opacity: 1;
    color: #8FA0BA;
}


.feed-source {
    opacity: 1;
    color: #8FA0BA;
}
[data-theme="light"] .feed-source { color: #566578; }

/* "No reading" / unknown road state: 4.05:1 to 7.4:1 */
.road-unknown {
    background: #2A3446;
    color: #C3CEDE;
}
[data-theme="light"] .road-unknown {
    background: #E5E9F0;
    color: #414D5F;
}

.condition-gray,
.score-result-T {
    color: #C3CEDE;
}
[data-theme="light"] .condition-gray,
[data-theme="light"] .score-result-T {
    color: #414D5F;
}

/* --- O. Landmarks ---------------------------------------------------------
   #main-content used to BE the <main>. It is now a plain div inside the
   page-level <main id="app-main">, so carry its layout rules across. */
#main-content {
    padding: 1rem 0;
    min-width: 0;
}
main#app-main { padding: 0; min-width: 0; }

/* --- P. Keyboard-reachable horizontal scrollers --------------------------- */
.weather-hourly-scroll:focus-visible {
    outline: 2px solid var(--accent);
    outline-offset: 2px;
}


/* --- Q. Last contrast offenders -------------------------------------------
   All the same pattern: a muted token with `opacity` layered on top, which
   multiplies down to ~3.6-4.1:1. Set an explicit colour instead. */
section h2 .cam-count { opacity: 1; }

.airport-station-info {
    opacity: 1;
    color: #8FA0BA;
}
[data-theme="light"] .airport-station-info { color: #566578; }


/* --- R. Accent used as text on a light surface ----------------------------
   The brand amber (#D97706) is 3.18:1 against white, which is fine for a
   fill but fails AA for text. Introduce a darker ink for accent-coloured
   TEXT and leave the fill colour alone so pills, borders and the wordmark
   keep the brand tone. #B45309 measures 4.87:1 on white. */
/* :root must come first: both selectors have the same specificity, so
   whichever is declared last wins, and a later :root would clobber the
   light-mode value. */
:root {
    --accent-ink: var(--accent);
}
[data-theme="light"] {
    --accent-ink: #A24E06;
}

[data-theme="light"] .sidebar-links a,
[data-theme="light"] .footer-resources a,
[data-theme="light"] .weather-hour-precip,
[data-theme="light"] .footer-links a:hover,
[data-theme="light"] a.sidebar-closings-shortcut,
[data-theme="light"] .consent-inner a {
    color: var(--accent-ink);
}


/* --- S. The footer follows the theme --------------------------------------
   This used to re-point --text and --text-muted for the footer's subtree,
   because the footer stayed navy in light mode while the rest of the page went
   light. Now that the footer is a light surface like every other surface, the
   dark-surface values it was pinning landed at 1.48:1 to 2.56:1 on white. The
   footer inherits the theme's own tokens. */

/* Accent-coloured button text on a white card fails AA at 3.18:1. */
[data-theme="light"] .advertise-btn,
[data-theme="light"] .contact-btn,
[data-theme="light"] #open-advertise,
[data-theme="light"] #open-contact {
    color: var(--accent-ink);
}

[data-theme="light"] .scores-schedule-link {
    color: var(--accent-ink);
}

/* The footer's contact button is the one element down there with a LIGHT
   background (it inherits --surface-hover), so it needs the dark ink even
   though everything around it needs the bright one. */
[data-theme="light"] .footer-contact-btn,
[data-theme="light"] #open-contact-footer {
    color: #A24E06;
}

/* ------------------------------------------------------------------ QA pass
   Added 2026-07-31 during the full test/repair pass. Kept in this sheet so the
   whole set stays revertible by dropping one <link>, same as the reskin. */

/* Inline failure state for the contact and advertise forms. A rejected submit
   used to fall through to the success message, so there was nowhere to say
   what went wrong. Mirrors .form-success in style.css. */
.form-error {
    background: rgba(239, 68, 68, 0.1);
    border: 1px solid var(--danger, #ef4444);
    border-radius: 6px;
    padding: 0.85rem 1rem;
    text-align: center;
    color: var(--danger, #ef4444);
    font-weight: 600;
    font-size: 0.9rem;
    margin-top: 0.75rem;
}
.form-error a {
    color: inherit;
    text-decoration: underline;
}

/* A submit that is in flight must look like it. */
.advertise-form button[type="submit"][disabled],
.advertise-form button[type="submit"][aria-busy="true"] {
    opacity: 0.65;
    cursor: progress;
}

/* Explanation left behind when a whole corridor section has nothing to show. */
.section-unavailable {
    margin: 0.5rem 0 1.25rem;
    padding: 0.85rem 1rem;
    border: 1px dashed var(--border, rgba(148, 163, 184, 0.35));
    border-radius: 8px;
    color: var(--text-muted);
    font-size: 0.9rem;
    line-height: 1.5;
}

/* Footer navigation was a 30px tap target. WCAG 2.5.8 asks for 24px minimum
   and the platform guidance is 44; only widen it where the pointer is coarse
   so the desktop footer keeps its density. */
@media (pointer: coarse) {
    .footer-links a,
    .footer-nav a,
    #footer-cameras-link,
    #footer-feeds-link,
    #footer-weather-link,
    #footer-closings-link,
    #footer-scores-link {
        min-height: 44px;
        display: flex;
        align-items: center;
    }
}

/* The scrollable forecast strips are keyboard-reachable now (tabindex=0), so
   they need a visible focus ring like every other focusable thing. */
#weather-daily:focus-visible,
#weather-hourly:focus-visible {
    outline: 2px solid var(--accent, #f59e0b);
    outline-offset: 3px;
    border-radius: 8px;
}

/* The map popup's "Watch Live" control is a real <button> now (it used to be a
   div with an inline onclick), so it needs the button chrome reset away. */
.camera-map-popup-link {
    appearance: none;
    background: none;
    border: 0;
    padding: 0;
    font: inherit;
    color: inherit;
    text-align: left;
    width: 100%;
    cursor: pointer;
}
.camera-map-popup-link:focus-visible {
    outline: 2px solid var(--accent, #f59e0b);
    outline-offset: 2px;
}

/* Tile providers require attribution (OSM/ODbL, CARTO, Esri, OpenTopoMap
   CC-BY-SA, RainViewer). It was switched off on every map; this keeps the
   credit legible but out of the way in both themes. */
.leaflet-control-attribution {
    background: rgba(8, 13, 23, 0.72) !important;
    color: #cbd5e1 !important;
    font-size: 0.62rem !important;
    padding: 2px 6px !important;
    backdrop-filter: blur(4px);
}
.leaflet-control-attribution a {
    color: #93c5fd !important;
}
[data-theme="light"] .leaflet-control-attribution {
    background: rgba(255, 255, 255, 0.82) !important;
    color: #3f4756 !important;
}
[data-theme="light"] .leaflet-control-attribution a {
    color: #1d4ed8 !important;
}

/* Inline retry inside the closings error state. */
.btn-inline-retry {
    appearance: none;
    background: none;
    border: 0;
    padding: 0;
    margin-left: 0.35rem;
    font: inherit;
    color: var(--accent-ink, var(--accent, #f59e0b));
    text-decoration: underline;
    cursor: pointer;
}

/* Placeholder while the map library is fetched on first use. */
.map-loading {
    display: flex;
    align-items: center;
    justify-content: center;
    min-height: 220px;
    color: var(--text-muted);
    font-size: 0.9rem;
}

/* The stream's weather/road strip is a full-width overlay pinned to the top of
   the video, and the player controls are pinned to its top-right corner, so the
   two sat on top of each other: measured ~10,000px of overlap at every size,
   and on a phone the controls covered 246 of the 390px strip, hiding the wind
   reading. The controls are always visible on touch, so this is permanent
   there. Keep the strip's content clear of the corner. */
.stream-weather-bar {
    justify-content: flex-start;
    /* Clear the LIVE chip on the left and the control cluster on the right.
       Centring the content simply moved the collision from one corner to the
       other, so reserve both. */
    padding-left: 116px;
    padding-right: 264px;
}

@media (max-width: 768px) {
    /* Too narrow to reserve a column: drop the strip below both the LIVE chip
       and the control row instead, and give the width back. */
    .stream-weather-bar {
        justify-content: flex-start;
        padding-left: 0.5rem;
        padding-right: 0.5rem;
        /* Clears the 44px control row plus its 8px offset. */
        padding-top: 3.5rem;
    }
}

/* --- Camera page header ---------------------------------------------------
   Only rendered on /camera/<slug>, where somebody has arrived from a search
   result or a shared link rather than from the grid. It names what they asked
   for before the cameras finish loading, and answers the footage question that
   otherwise arrives as an email. */
.camera-seo-header {
    max-width: 1400px;
    margin: 0 auto;
    padding: 0.9rem 1.5rem 0;
}

.camera-seo-header h1 {
    margin: 0 0 0.3rem;
    font-size: 1.35rem;
    font-weight: 700;
    letter-spacing: -0.02em;
    color: var(--text);
}

.camera-seo-header p {
    margin: 0 0 0.35rem;
    font-size: 0.85rem;
    line-height: 1.5;
    color: var(--text-muted);
    max-width: 70ch;
}

.camera-seo-header a {
    font-size: 0.82rem;
    font-weight: 600;
    color: var(--accent);
}

@media (max-width: 768px) {
    .camera-seo-header {
        padding: 0.75rem 0.75rem 0;
    }
    .camera-seo-header h1 { font-size: 1.1rem; }
}

/* Applied by the inline script in the head, before first paint, so hiding the
   map is not a layout shift on every load for the people who hid it. */
html.map-panel-closed .inline-map-view.map-compact {
    display: none;
}



/* --- Map picker -----------------------------------------------------------
   Collapsed by default: one row that says what it opens and how many cameras
   are on it. The cameras are why people came, so the map has to earn the space
   before it takes it. Expanded, it sits above the grid at a height that still
   leaves cameras on screen, and the map view toggle expands this same panel
   rather than swapping in a second one. */
.map-panel-head {
    display: flex;
    align-items: center;
    gap: 0.6rem;
    width: 100%;
    min-height: 48px;
    margin: 0 0 1.1rem;
    padding: 0 0.9rem;
    background: var(--surface);
    border: 1px solid var(--border);
    border-radius: var(--radius);
    color: var(--text);
    font: inherit;
    text-align: left;
    cursor: pointer;
    transition: border-color .18s ease, background-color .18s ease;
}

.map-panel-head:hover {
    background: var(--surface-hover);
    border-color: var(--border-strong);
}

.map-panel-head:focus-visible {
    outline: 2px solid var(--accent);
    outline-offset: 2px;
}

.map-panel-pin {
    flex-shrink: 0;
    color: var(--accent);
}

.map-panel-title {
    font-size: 0.86rem;
    font-weight: 600;
    letter-spacing: -0.011em;
}

.map-panel-count {
    font-size: 0.78rem;
    color: var(--text-muted);
    font-variant-numeric: tabular-nums;
}

.map-panel-chevron {
    margin-left: auto;
    flex-shrink: 0;
    color: var(--text-muted);
    transition: transform .2s ease;
}

.map-panel-head[aria-expanded="true"] .map-panel-chevron {
    transform: rotate(180deg);
}

/* Open: the row becomes the map's header, joined to it. */
.map-panel-head[aria-expanded="true"] {
    margin-bottom: 0;
    border-bottom-left-radius: 0;
    border-bottom-right-radius: 0;
    border-bottom-color: transparent;
}

.inline-map-view.map-compact {
    margin-bottom: 1.6rem;
    padding: 0 0.9rem 0.9rem;
    background: var(--surface);
    border: 1px solid var(--border);
    border-top: 0;
    border-radius: 0 0 var(--radius) var(--radius);
}

.inline-map-view.map-compact .inline-camera-map {
    height: 340px;
    min-height: 340px;
}

/* In compact mode the map is a picker inside the page, so "Back to Grid" has
   nothing to go back to. */
.inline-map-view.map-compact .map-back-btn {
    display: none;
}

.map-panel-hint {
    margin: 0.5rem 0 0;
    font-size: 0.76rem;
    color: var(--text-muted);
}

.inline-map-view:not(.map-compact) .map-panel-hint {
    display: none;
}

@media (max-width: 768px) {
    .inline-map-view.map-compact .inline-camera-map {
        height: 240px;
        min-height: 240px;
    }
    .map-panel-head {
        margin-bottom: 0.9rem;
    }
    .map-panel-title { font-size: 0.82rem; }
    /* Street/Satellite/Terrain is a second row of chrome above a 240px map.
       The picker only needs the street map; the full map view still has it. */
    .inline-map-view.map-compact #map-layer-bar {
        display: none;
    }
    .map-panel-hint {
        margin-top: 0.35rem;
        font-size: 0.74rem;
    }
}

/* --- Page width: one number, everywhere -----------------------------------
   Section H above widened .page-layout to 1600/1800 on big screens, but the
   header, nav, filter bar, footer and the tab sections stayed pinned to 1400.
   Above ~1450px they drifted apart: at 1920 the header started at x=260 while
   the camera grid started at x=84, leaving a 176px band of dead space at the
   top left of the page. They all read the same variable now, so they cannot
   drift again. */
:root {
    --page-max: 1400px;
}

@media (min-width: 1500px) {
    :root { --page-max: 1600px; }
}

@media (min-width: 1800px) {
    :root { --page-max: 1800px; }
}

.page-layout,
.header-content,
.footer-content,
.closings-tab-section,
.weather-tab-section,
.feeds-tab-section,
.scores-tab-section,
.incident-banner,
.camera-map-section,
.consent-inner {
    max-width: var(--page-max);
}

/* The nav centres itself with padding rather than a max-width. The jump row
   used to share this because it was chrome welded under the nav; it sits
   inside the page layout now and takes that gutter. */
nav {
    padding-inline: max(1.5rem, calc((100% - var(--page-max)) / 2 + 1.5rem));
}

/* The admin bar is only ever seen by Adam, but it is the very top row of the
   page and lining it up with everything else costs nothing. */
.admin-bar-inner {
    max-width: var(--page-max);
    margin: 0 auto;
}

/* The header centred an inner box inside its own 1.5rem padding, which left it
   sitting 20-24px to the left of the nav and the grid at most widths. Give the
   header the same padding formula the nav uses and let the inner box fill it,
   so the logo starts exactly where the first camera card does. */
header {
    padding-inline: max(1.5rem, calc((100% - var(--page-max)) / 2 + 1.5rem));
}

.header-content {
    max-width: none;
    margin: 0;
}


/* --- Group colours ---------------------------------------------------------
   GENERATED from SECTIONS in app.js. Do not hand-edit: the ten sections, the
   filter pills, the map markers and these rules all come from that one list,
   and tests/smoke.spec.js fails if they drift apart.

   The camera sections were split by place on 2026-08-18: two bridges,
   downtown, four points along US 20 west, and two along US 61 south. The old
   per-route rules in effects.css and style.css are keyed on #us20 / #us61 /
   #us151, which no longer exist, so the colour treatment lives here. */
#bridge-julien h2 { color: #60a5fa; }
#bridge-wisconsin h2 { color: #34d399; }
#downtown h2 { color: #a78bfa; }
#us20-jfk h2 { color: #facc15; }
#us20-nw h2 { color: #f59e0b; }
#us20-sw h2 { color: #fb923c; }
#us20-west h2 { color: #f87171; }
#us61-south h2 { color: #f472b6; }
#us61-151 h2 { color: #c084fc; }
#other h2 { color: #94a3b8; }
#rwis h2 { color: #22d3ee; }

#bridge-julien h2::before,
#bridge-wisconsin h2::before,
#downtown h2::before,
#us20-jfk h2::before,
#us20-nw h2::before,
#us20-sw h2::before,
#us20-west h2::before,
#us61-south h2::before,
#us61-151 h2::before,
#other h2::before,
#rwis h2::before,
#favorites h2::before {
    content: "";
    width: 7px;
    height: 7px;
    border-radius: 50%;
    background: currentColor;
    flex-shrink: 0;
    margin-right: 0.15rem;
}

#bridge-julien .camera-card { border-left: 2px solid rgba(96, 165, 250, 0.35); }
#bridge-wisconsin .camera-card { border-left: 2px solid rgba(52, 211, 153, 0.35); }
#downtown .camera-card { border-left: 2px solid rgba(167, 139, 250, 0.35); }
#us20-jfk .camera-card { border-left: 2px solid rgba(250, 204, 21, 0.35); }
#us20-nw .camera-card { border-left: 2px solid rgba(245, 158, 11, 0.35); }
#us20-sw .camera-card { border-left: 2px solid rgba(251, 146, 60, 0.35); }
#us20-west .camera-card { border-left: 2px solid rgba(248, 113, 113, 0.35); }
#us61-south .camera-card { border-left: 2px solid rgba(244, 114, 182, 0.35); }
#us61-151 .camera-card { border-left: 2px solid rgba(192, 132, 252, 0.35); }
#other .camera-card { border-left: 2px solid rgba(148, 163, 184, 0.35); }
#rwis .camera-card { border-left: 2px solid rgba(34, 211, 238, 0.35); }

#bridge-julien .camera-card:hover { border-color: #60a5fa; border-left-color: #60a5fa; }
#bridge-wisconsin .camera-card:hover { border-color: #34d399; border-left-color: #34d399; }
#downtown .camera-card:hover { border-color: #a78bfa; border-left-color: #a78bfa; }
#us20-jfk .camera-card:hover { border-color: #facc15; border-left-color: #facc15; }
#us20-nw .camera-card:hover { border-color: #f59e0b; border-left-color: #f59e0b; }
#us20-sw .camera-card:hover { border-color: #fb923c; border-left-color: #fb923c; }
#us20-west .camera-card:hover { border-color: #f87171; border-left-color: #f87171; }
#us61-south .camera-card:hover { border-color: #f472b6; border-left-color: #f472b6; }
#us61-151 .camera-card:hover { border-color: #c084fc; border-left-color: #c084fc; }
#other .camera-card:hover { border-color: #94a3b8; border-left-color: #94a3b8; }
#rwis .camera-card:hover { border-color: #22d3ee; border-left-color: #22d3ee; }

/* The bleed those sections used to sit in is flattened for every section, and
   these are sections too. */
#main-content section {
    margin-left: 0;
    margin-right: 0;
}

/* The old headings carried a text badge ("US 20", "DOT", "FAV") before the
   title. With the sections regrouped by place, a badge reading "DOT" beside
   "RWIS Road Weather Cameras" says nothing the title does not, and mixing text
   badges with the new colour dots read as two unrelated designs. One dot per
   section, coloured to match its filter pill and its map markers. */
#rwis h2::before,
#favorites h2::before {
    content: "";
    display: block;
    width: 7px;
    height: 7px;
    padding: 0;
    border: 0;
    border-radius: 50%;
    margin: 0;
    background: currentColor;
    flex-shrink: 0;
}

#favorites h2 { color: var(--accent-bright, #fbbf24); }


/* --- Phones ----------------------------------------------------------------
   Measured on iPhone SE, iPhone 14 Pro, Pixel 7, a 360px Android and the Fold's
   344px cover screen. 93% of this site's traffic is phones. */
@media (max-width: 768px) {
    /* Two columns, always. auto-fill with a 240-280px minimum silently fell to
       one column below ~390px, which is most of the small phones in use: 45
       cameras became 45 full-width rows to scroll past. Two 150px cards beat
       one 350px card for finding the camera you want. */
    .camera-grid:not(.list-view) {
        grid-template-columns: repeat(2, minmax(0, 1fr));
        gap: 0.6rem;
    }

    /* The heading wrapped to two lines with the colour dot floating beside the
       second one. Shorter tracking keeps "Julien Dubuque Bridge" on one line,
       and the dot stays with the first line if a longer name ever wraps. */
    section h2 {
        font-size: 0.68rem;
        letter-spacing: 0.06em;
        align-items: flex-start;
    }

    section h2::before {
        margin-top: 0.32em;
    }

    section h2 .section-chevron,
    section h2 .cam-count {
        margin-top: 0.1em;
    }

    /* Thumb-sized dismiss buttons. Both of these were under 20px wide. */
    .incident-banner-dismiss,
    #pwa-ios-dismiss {
        min-width: 44px;
        min-height: 44px;
        display: inline-flex;
        align-items: center;
        justify-content: center;
    }

    /* The incident banner is the first thing under the filter bar; it should
       never take a fifth of the screen. */
    .incident-banner-content {
        align-items: center;
    }

    #incident-banner-text {
        display: -webkit-box;
        -webkit-line-clamp: 3;
        -webkit-box-orient: vertical;
        overflow: hidden;
    }
}

/* Light theme: the group colours above are tuned for a near-black page and run
   1.8:1 to 2.5:1 on the light surface, under the 3:1 minimum for large text.
   Same hues, darker. GENERATED alongside the block above. */
[data-theme="light"] #bridge-julien h2 { color: #1D4ED8; }
[data-theme="light"] #bridge-wisconsin h2 { color: #047857; }
[data-theme="light"] #downtown h2 { color: #6D28D9; }
[data-theme="light"] #us20-jfk h2 { color: #A16207; }
[data-theme="light"] #us20-nw h2 { color: #B45309; }
[data-theme="light"] #us20-sw h2 { color: #C2410C; }
[data-theme="light"] #us20-west h2 { color: #B91C1C; }
[data-theme="light"] #us61-south h2 { color: #BE185D; }
[data-theme="light"] #us61-151 h2 { color: #7E22CE; }
[data-theme="light"] #other h2 { color: #475569; }
[data-theme="light"] #rwis h2 { color: #0E7490; }
[data-theme="light"] #favorites h2 { color: #B45309; }

[data-theme="light"] #bridge-julien .camera-card { border-left-color: rgba(29, 78, 216, 0.3); }
[data-theme="light"] #bridge-wisconsin .camera-card { border-left-color: rgba(4, 120, 87, 0.3); }
[data-theme="light"] #downtown .camera-card { border-left-color: rgba(109, 40, 217, 0.3); }
[data-theme="light"] #us20-jfk .camera-card { border-left-color: rgba(161, 98, 7, 0.3); }
[data-theme="light"] #us20-nw .camera-card { border-left-color: rgba(180, 83, 9, 0.3); }
[data-theme="light"] #us20-sw .camera-card { border-left-color: rgba(194, 65, 12, 0.3); }
[data-theme="light"] #us20-west .camera-card { border-left-color: rgba(185, 28, 28, 0.3); }
[data-theme="light"] #us61-south .camera-card { border-left-color: rgba(190, 24, 93, 0.3); }
[data-theme="light"] #us61-151 .camera-card { border-left-color: rgba(126, 34, 206, 0.3); }
[data-theme="light"] #other .camera-card { border-left-color: rgba(71, 85, 105, 0.3); }
[data-theme="light"] #rwis .camera-card { border-left-color: rgba(14, 116, 144, 0.3); }

/* Touch targets. Two "Privacy Policy" links sit inline inside a sentence, which
   the target-size rule exempts, but these are standalone controls and a thumb
   has to land on them. `pointer: coarse` catches the Fold's inner screen, a
   phone in landscape and tablets, which all take the >=769px desktop layout
   while still being touched. */
.brand-link {
    min-height: 44px;
    display: inline-flex;
    align-items: center;
}

@media (pointer: coarse) {
    .view-toggle-btn,
    .density-toggle,
    .map-filter-btn,
    .header-fb-link-desktop {
        min-height: 44px;
    }

    .view-toggle-btn,
    .density-toggle,
    .header-fb-link-desktop {
        min-width: 44px;
    }

    .header-fb-link-desktop {
        display: inline-flex;
        align-items: center;
        justify-content: center;
    }
}


/* --- Camera modal on a phone -----------------------------------------------
   146px of header before any video at 390x844: the title and its three buttons
   wrapped to two rows and the auto-pause chip took a third. One row, icons
   without labels, and the video starts where it should. */
@media (max-width: 768px) {
    .modal-header {
        padding: 0.5rem 0.6rem 0.4rem;
    }

    .modal-header-row1 {
        align-items: center;
        gap: 0.4rem;
        flex-wrap: nowrap;
    }

    #modal-title {
        font-size: 0.98rem;
        line-height: 1.25;
        min-width: 0;
        overflow: hidden;
        text-overflow: ellipsis;
        white-space: nowrap;
    }

    /* Icon-only actions: the star and the warning triangle carry the meaning,
       and the labels were what pushed the row to wrap. */
    #modal-fav-label,
    .report-incident-btn span {
        display: none;
    }

    .modal-fav-btn,
    .report-incident-btn,
    .modal-header-close {
        min-width: 44px;
        min-height: 44px;
        display: inline-flex;
        align-items: center;
        justify-content: center;
        padding: 0;
    }

    .modal-header-row2 {
        margin-top: 0.3rem;
    }

    /* Belt and braces for WebKit, which ignores a video's controls property
       change on an element that has already begun playing. */
    #modal-video-container video::-webkit-media-controls,
    #modal-video-container video::-webkit-media-controls-enclosure,
    #modal-video-container video::-webkit-media-controls-panel {
        display: none !important;
        -webkit-appearance: none;
    }

    /* Nearby cameras scrolls sideways; say so by letting the next card peek. */
    .modal-sidebar .nearby-cameras,
    .modal-sidebar .sidebar-nearby {
        scroll-snap-type: x proximity;
    }
}


/* The modal title carries the place; this is the direction under it, quieter,
   which is what distinguishes two cameras pointed at the same bridge. */
/* Was an ::after on #modal-title. A line clamp on that element counted it
   as a line, so a two-line camera name pushed it past the clamp and it was
   cut. It is a real element now, outside anything clamping the name. */
.modal-title-dir {
    display: block;
    margin-top: 0.1rem;
    font-size: 0.74rem;
    font-weight: 500;
    letter-spacing: 0;
    text-transform: none;
    color: var(--text-muted);
}


/* ==========================================================================
   SYSTEM PASS 1  ·  card, status, section heading
   Everything below reads from the token scales. No new numbers.
   ========================================================================== */

/* --- Camera card ----------------------------------------------------------
   A card is a frame around a photograph and two lines of text. It should not
   also be a shadowed, bordered, rounded object competing with what it holds. */
.camera-card {
    background: var(--surface);
    border: 1px solid transparent;
    border-radius: var(--radius-md);
    box-shadow: none;
    overflow: hidden;
    isolation: isolate;
    transition: border-color var(--dur-base) var(--ease),
                background-color var(--dur-base) var(--ease);
}

.camera-card:hover,
.camera-card:focus-within {
    border-color: var(--border-strong);
    background: var(--surface-hover);
    box-shadow: none;
}

/* One focus ring, on the control that actually takes focus. */
.camera-card .cam-open:focus-visible {
    outline: none;
    box-shadow: var(--focus-ring);
    border-radius: var(--radius-md);
}

.camera-thumb-container {
    background: #05080F;
    aspect-ratio: 16 / 9;
}

/* Reserving the frame's shape stops the grid reflowing as 45 images land. */
.camera-thumb {
    width: 100%;
    height: 100%;
    object-fit: cover;
    transition: transform 350ms var(--ease);
}

.camera-info {
    display: flex;
    flex-direction: column;
    gap: var(--space-1);
    padding: var(--space-3) var(--space-3) calc(var(--space-3) + var(--space-1));
}

.camera-name {
    font-size: var(--text-sm);
    font-weight: 600;
    line-height: var(--leading-tight);
    letter-spacing: var(--tracking-tight);
    color: var(--text);
}

.camera-meta {
    display: flex;
    align-items: center;
    gap: var(--space-2);
    font-size: var(--text-xs);
    font-weight: 500;
    line-height: var(--leading-tight);
    color: var(--text-muted);
}

/* --- Status marker --------------------------------------------------------
   Off the picture, into the text row: a dot, a word, and the age in tabular
   figures so it cannot jiggle as the seconds tick. */
.cam-status {
    position: static;
    display: inline-flex;
    align-items: center;
    gap: var(--space-2);
    padding: 0;
    background: none;
    backdrop-filter: none;
    -webkit-backdrop-filter: none;
    border: 0;
    border-radius: 0;
    font-size: var(--text-xs);
    color: var(--text-muted);
}

.cam-status-text {
    font-size: var(--text-label);
    font-weight: 700;
    letter-spacing: var(--tracking-label);
    text-transform: uppercase;
    color: var(--ok);
}

.cam-status[data-kind="snapshot"] .cam-status-text { color: var(--stale); }
.camera-card.is-offline .cam-status-text { color: var(--down); }

.cam-status-age {
    font-family: var(--font-mono);
    font-variant-numeric: tabular-nums;
    font-size: var(--text-xs);
    color: var(--text-muted);
}

.cam-status-age:not(:empty)::before {
    content: "";
    margin: 0;
}

/* --- Section heading ------------------------------------------------------
   A quiet label, its count, and a rule that carries the eye to the chevron. */
section h2 {
    display: flex;
    align-items: center;
    gap: var(--space-2);
    margin-bottom: var(--space-3);
    font-size: var(--text-label);
    font-weight: 700;
    letter-spacing: var(--tracking-label);
    text-transform: uppercase;
}

section h2 .cam-count {
    margin-left: 0;
    font-size: var(--text-xs);
    font-weight: 500;
    letter-spacing: 0;
    text-transform: none;
    color: var(--text-muted);
}

/* The rule fills the gap between the count and the chevron, so a heading reads
   as one line rather than three things floating at different distances. */
section h2::after {
    content: "";
    flex: 1;
    height: 1px;
    background: var(--border);
    margin-inline: var(--space-2);
}

section h2 .section-chevron {
    margin-left: 0;
    color: var(--text-muted);
    transition: transform var(--dur-base) var(--ease);
}


/* The meta row now carries the place, the direction and the freshness marker.
   The place text may wrap on a narrow card; the marker never should, so it
   keeps its own line rather than breaking between the dot and the age. */
.camera-meta {
    flex-wrap: wrap;
    row-gap: var(--space-1);
}

.camera-meta-place {
    min-width: 0;
}

.camera-meta .cam-status {
    margin-left: auto;
    white-space: nowrap;
}

.cam-status-dot {
    width: 6px;
    height: 6px;
    border-radius: 50%;
    background: var(--ok);
    box-shadow: 0 0 0 3px rgba(52, 211, 153, .16);
    flex-shrink: 0;
}

.cam-status[data-kind="snapshot"] .cam-status-dot {
    background: var(--stale);
    box-shadow: 0 0 0 3px rgba(251, 191, 36, .16);
}

.camera-card.is-offline .cam-status-dot {
    background: var(--down);
    box-shadow: none;
}


/* ==========================================================================
   SYSTEM PASS 2  ·  panels, alert, header, controls, states
   ========================================================================== */

/* --- Sidebar panels -------------------------------------------------------
   One recipe. A panel is a surface, a label, and its content. */
.sidebar-card {
    padding: var(--space-4);
    background: var(--surface);
    border: 1px solid var(--border);
    border-radius: var(--radius-md);
    box-shadow: none;
}

.sidebar-card h3 {
    margin: 0 0 var(--space-3);
    font-size: var(--text-label);
    font-weight: 700;
    letter-spacing: var(--tracking-label);
    text-transform: uppercase;
    color: var(--text-muted);
}

/* The reading a panel exists to show: one number, unmissable, tabular. */
.river-level,
.weather-temp {
    font-size: var(--text-2xl);
    font-weight: 700;
    line-height: 1;
    letter-spacing: -0.02em;
    font-variant-numeric: tabular-nums;
}

.sidebar-card p,
.sidebar-card li {
    font-size: var(--text-sm);
    line-height: var(--leading-normal);
}

/* Numbers that tick must not reflow their neighbours. */
.road-temp,
.weather-detail-value,
.river-level,
.status-timer,
.cam-status-age,
.header-status-bar time {
    font-variant-numeric: tabular-nums;
}

/* --- Incident banner ------------------------------------------------------
   Unmissable without being the loudest thing on a page of photographs: a
   tinted surface with an amber marker, not a full-bleed slab. */
.incident-banner-content {
    gap: var(--space-3);
    padding: var(--space-3) var(--space-4);
    background: rgba(245, 158, 11, .10);
    border: 1px solid rgba(245, 158, 11, .38);
    border-left: 3px solid var(--accent);
    border-radius: var(--radius-md);
    box-shadow: none;
}

.incident-banner-content.incident-banner-danger {
    background: rgba(244, 63, 94, .10);
    border-color: rgba(244, 63, 94, .38);
    border-left-color: var(--danger, #f43f5e);
}

#incident-banner-text {
    font-size: var(--text-sm);
    line-height: var(--leading-normal);
    color: var(--text);
}

/* --- Header strip ---------------------------------------------------------
   Conditions at a glance. Quiet labels, confident numbers. */
.header-status-bar {
    gap: var(--space-4);
    font-size: var(--text-xs);
}

.brand-link h1 {
    letter-spacing: -0.03em;
}

.branding p {
    font-size: var(--text-xs);
    color: var(--text-muted);
}

/* --- Controls -------------------------------------------------------------
   Every control that takes focus shows the same ring, and every one a thumb
   has to hit is at least 44px on a touch screen. */
button:focus-visible,
a:focus-visible,
input:focus-visible,
select:focus-visible,
textarea:focus-visible,
[tabindex]:focus-visible {
    outline: none;
    box-shadow: var(--focus-ring);
    border-radius: var(--radius-sm);
}

.contact-btn,
.advertise-btn,
.form-submit,
.map-panel-head,
.view-toggle-btn,
.nav-link {
    transition: background-color var(--dur-fast) var(--ease),
                color var(--dur-fast) var(--ease),
                border-color var(--dur-fast) var(--ease);
}

/* Primary action: filled amber, dark text, no gradient and no glow. */
.contact-btn,
.form-submit,
.advertise-btn {
    min-height: 44px;
    padding: 0 var(--space-4);
    border-radius: var(--radius-sm);
    font-size: var(--text-sm);
    font-weight: 700;
    letter-spacing: 0;
}

.contact-btn:hover,
.form-submit:hover,
.advertise-btn:hover {
    background: var(--accent-bright, #fbbf24);
}

.contact-btn:active,
.form-submit:active,
.advertise-btn:active {
    transform: translateY(1px);
}

input[type="text"],
input[type="email"],
input[type="search"],
textarea,
select {
    min-height: 44px;
    padding: var(--space-2) var(--space-3);
    border-radius: var(--radius-sm);
    font-size: var(--text-base);
}

/* --- States ---------------------------------------------------------------
   Loading, empty and error were bare strings. They are states, so they should
   look like states: quiet, centred, and clearly not a failure of the page. */
/* These two are only ever in the DOM while they are showing, so they may own
   their own display. */
.weather-loading,
.map-loading {
    display: flex;
    align-items: center;
    justify-content: center;
    gap: var(--space-2);
    min-height: 44px;
    padding: var(--space-3);
    font-size: var(--text-sm);
    color: var(--text-muted);
    text-align: center;
}

/* The search empty state is toggled by JavaScript between none and block, so
   this styles it without touching display. Setting display here put "No
   cameras match your search." above the grid on every load. */
.camera-search-no-results,
.sidebar-card .text-muted {
    padding: var(--space-5) var(--space-3);
    font-size: var(--text-sm);
    color: var(--text-muted);
    text-align: center;
}

/* A camera with no frame is a state, not an error: diagonal hatch, muted
   label, and the card keeps its shape so the grid never jumps. */
.camera-thumb-error {
    gap: var(--space-2);
    font-size: var(--text-xs);
    font-weight: 500;
    letter-spacing: 0.02em;
    color: var(--down);
}

/* Success and error inside the dialogs, matched to each other. */
.form-success,
.form-error {
    padding: var(--space-3) var(--space-4);
    border-radius: var(--radius-sm);
    font-size: var(--text-sm);
    line-height: var(--leading-normal);
}

/* --- Motion ---------------------------------------------------------------
   The tokens already collapse to 1ms under prefers-reduced-motion; this stops
   anything that animates by other means. */
@media (prefers-reduced-motion: reduce) {
    *,
    *::before,
    *::after {
        animation-duration: 1ms !important;
        animation-iteration-count: 1 !important;
        transition-duration: 1ms !important;
        scroll-behavior: auto !important;
    }

    .camera-card:hover .camera-thumb {
        transform: none;
    }
}

/* --- Line length ----------------------------------------------------------
   Measured at 1920: the consent line ran to 267 characters, the RWIS caption
   to 223, the footer disclaimer to 143. Anything past about 75 characters
   costs the reader the return sweep, and these are the only places on the site
   with running text. Cap the measure; leave everything that is a label, a
   number or a control alone. */
.consent-inner p,
.rwis-subtitle,
.footer-disclaimer,
.footer-about,
.footer-advertise-text,
.footer-contact-text,
.sidebar-contact p,
.advertise-header p,
.camera-seo-header p,
.map-panel-hint,
.road-note {
    max-width: 68ch;
}

/* The consent bar centres its own text, so the measure has to centre with it. */
.consent-inner p {
    margin-inline: auto;
}

/* The footer columns are already narrow; the cap only ever applies on the
   widest screens, so it must not fight the column it sits in. */
.footer-col p {
    max-width: min(68ch, 100%);
}

/* --- Corrections from the 375px review ------------------------------------
   Two things the control and heading rules above got wrong on a phone. */

/* The search field has an absolutely positioned magnifier at 0.85rem from the
   left edge. The shared input padding above overrode the room that icon needs,
   so the glyph sat on top of the "S" in "Search cameras...". */
.camera-search-bar input,
#camera-search {
    padding-left: 2.6rem;
}

/* Section headings: one fluid size instead of a base rule and a phone override
   fighting over which loads last. At 375 the fixed 0.72rem plus tracking
   wrapped "Julien Dubuque Bridge" onto two lines and left the colour dot
   stranded beside the second one. */
section h2 {
    font-size: clamp(0.64rem, 2.6vw, var(--text-label));
    align-items: center;
}

section h2::before {
    margin-top: 0;
}

@media (max-width: 480px) {
    /* The rule between the count and the chevron has nothing useful to do on a
       narrow screen, and it was what forced the wrap. */
    section h2::after {
        display: none;
    }

    section h2 .cam-count {
        margin-left: auto;
    }
}

/* On a narrow card the meta wraps to its own line; keep it tight so a card is
   never taller than it needs to be. */
@media (max-width: 480px) {
    .camera-info {
        padding: var(--space-2) var(--space-3) var(--space-3);
    }

    .camera-meta {
        gap: var(--space-1) var(--space-2);
    }
}

/* The section heading's text lives inside a .section-toggle button that JS
   inserts for the collapse control, and that button was rendering at the
   browser's default heading size, which is what actually made "Julien Dubuque
   Bridge" wrap on a phone. The button has to inherit the heading, and the
   heading needs a plain size: clamp() with a var() inside was being dropped. */
section h2 {
    font-size: 0.7rem;
}

@media (max-width: 480px) {
    section h2 {
        font-size: 0.64rem;
    }
}

section h2 .section-toggle,
.section-toggle {
    display: flex;
    align-items: center;
    gap: var(--space-2);
    width: 100%;
    padding: 0;
    background: none;
    border: 0;
    color: inherit;
    font: inherit;
    letter-spacing: inherit;
    text-transform: inherit;
    text-align: left;
    cursor: pointer;
}

.section-toggle:focus-visible {
    outline: none;
    box-shadow: var(--focus-ring);
    border-radius: var(--radius-sm);
}

/* --- Vertical rhythm ------------------------------------------------------
   Measured between the blocks of the main column: 8px, 24px, 0px, 14px, 26px.
   Five different gaps for the same kind of separation is what makes a page
   feel assembled rather than designed. Two steps from the scale now: --space-5
   between blocks, --space-6 between camera sections, which are the largest
   units on the page and earn the extra air. */
#main-content > .incident-banner,
.incident-banner {
    margin-bottom: var(--space-5);
}

.camera-search-bar {
    padding-bottom: 0;
    margin-bottom: var(--space-5);
}

.map-panel-head {
    margin-bottom: var(--space-4);
}

.inline-map-view.map-compact {
    margin-bottom: var(--space-6);
}

#main-content section {
    margin-bottom: var(--space-6);
}

@media (max-width: 480px) {
    .camera-search-bar,
    .incident-banner {
        margin-bottom: var(--space-4);
    }

    #main-content section {
        margin-bottom: var(--space-5);
    }
}

/* The search field styles its own focus with a border-colour change, which is
   a colour-only signal and was the one control on the page that did not show
   the shared ring. It also sits at 34px tall in the collapsed header variant,
   under the 44px a thumb needs. */
.camera-search-bar input:focus,
.camera-search-bar input:focus-visible,
#camera-search:focus,
#camera-search:focus-visible {
    outline: none;
    box-shadow: var(--focus-ring);
    border-color: var(--accent);
}

.cam-search-inline #camera-search,
.camera-search-bar input {
    min-height: 44px;
}

/* --- Chrome, both themes ---------------------------------------------------
   The header, nav, filter bar and footer were hardcoded slate (#1e293b) with a
   135deg gradient on the header, so in light theme the page went light and the
   chrome stayed dark. They are surfaces like every other surface now, in both
   themes, and the gradient is gone: a flat bar reads as an instrument panel,
   and a decorative gradient is exactly the kind of thing this pass removes.

   The amber rule under the header stays. It is the one piece of brand
   ornament on the page and it earns its place. */
header {
    background: var(--surface);
    background-image: none;
    border-bottom: 2px solid var(--accent);
    color: var(--text);
}

nav {
    background: var(--surface);
    border-bottom: 1px solid var(--border);
}

footer {
    background: var(--surface);
    border-top: 1px solid var(--border);
    color: var(--text-muted);
}

.branding p,
.footer-disclaimer,
.footer-copyright {
    color: var(--text-muted);
}

/* Controls that assumed a dark bar underneath them. */
#view-toggle-group,
.view-toggle-group {
    background: var(--bg);
    border: 1px solid var(--border);
}

.view-toggle-btn {
    color: var(--text-muted);
}

.view-toggle-btn.active {
    background: var(--accent);
    color: #1A1206;
}

.camera-search-bar input,
#camera-search,
.cam-search-inline #camera-search {
    background: var(--bg);
    border: 1px solid var(--border);
    color: var(--text);
}

.camera-search-bar input::placeholder,
#camera-search::placeholder {
    color: var(--text-muted);
}

.header-status-bar {
    background: var(--bg);
    border: 1px solid var(--border);
    color: var(--text);
}

/* The mobile drawer and bottom tab bar sit on the same surface as the header. */
.mobile-menu-drawer,
.mobile-bottom-nav {
    background: var(--surface);
    border-color: var(--border);
}

.mobile-nav-item {
    color: var(--text-muted);
}

.mobile-nav-item.active {
    color: var(--accent);
}

/* --- Reserve the conditions strip ------------------------------------------
   Measured on production: a 0.044 layout shift at ~500ms, with the header
   strip and the filter pills as sources. The weather span starts empty and
   fills with "72°F, Mostly Cloudy · Wind: 13 mph SW · Humidity: 83%", so the
   strip grows sideways and drags everything beside it. Reserving the space the
   text will occupy costs nothing and removes the shift.

   ch units track the font, so this holds if the type scale changes. */
.header-weather-desktop-val {
    display: inline-flex;
    align-items: center;
    gap: var(--space-2);
    min-width: 26ch;
}

#river-data { min-width: 7ch; }
#sun-data { min-width: 15ch; }
#uv-data { min-width: 12ch; }

/* Each of those is a value that arrives late; keep them from re-centring their
   own row as they land. */
.header-status-bar .status-item {
    justify-content: flex-start;
}

@media (max-width: 900px) {
    /* The strip collapses to the temperature alone on narrow screens, so the
       desktop reservations would only add dead space. */
    .header-weather-desktop-val,
    #river-data,
    #sun-data,
    #uv-data {
        min-width: 0;
    }
}

/* --- Light theme: the parts that assumed a dark bar ------------------------
   Making the chrome light exposed sixteen contrast failures, all of them text
   that was chosen to sit on slate: footer headings at 1.48:1, footer links at
   2.56:1, the search field's own text at 1.08:1, and the amber advertise
   button at 1.66:1. Each one takes a token that is already tuned for a light
   surface, and the amber steps down to #B45309 where it is text. */
[data-theme="light"] .footer-col-heading,
[data-theme="light"] .footer-disclaimer {
    color: var(--text-muted);
}

[data-theme="light"] #camera-search,
[data-theme="light"] .camera-search-bar input {
    color: var(--text);
    background: var(--bg);
    border-color: var(--border);
}

[data-theme="light"] #camera-search::placeholder {
    color: var(--text-muted);
}

[data-theme="light"] .footer-about,
[data-theme="light"] .footer-contact-text,
[data-theme="light"] .footer-advertise-text,
[data-theme="light"] .footer-copyright {
    color: var(--text-muted);
}

[data-theme="light"] .footer-social-link,
[data-theme="light"] .footer-links a,
[data-theme="light"] footer a {
    color: #1D4ED8;
}

[data-theme="light"] .footer-social-link:hover,
[data-theme="light"] .footer-links a:hover,
[data-theme="light"] footer a:hover {
    color: #1E40AF;
}

/* The advertise button is amber-on-amber-tint; as text on white it fails, so
   in light theme it becomes a filled button like the other primary actions. */
[data-theme="light"] .advertise-btn,
[data-theme="light"] #open-advertise-footer {
    background: var(--accent);
    color: #1A1206;
    border-color: var(--accent);
}

[data-theme="light"] .advertise-btn:hover,
[data-theme="light"] #open-advertise-footer:hover {
    background: #d97706;
    color: #FFFFFF;
}


/* The search field and the advertise button, at enough specificity to win.
   The field kept near-white text from a rule scoped to the search bar; the
   button drew amber ink on amber fill once the footer stopped redefining
   --accent-ink. */
[data-theme="light"] .camera-search-bar input#camera-search,
[data-theme="light"] .cam-search-inline input#camera-search,
[data-theme="light"] input#camera-search {
    color: var(--text);
    background: var(--bg);
    border-color: var(--border);
    -webkit-text-fill-color: var(--text);
}

[data-theme="light"] button.advertise-btn,
[data-theme="light"] button#open-advertise,
[data-theme="light"] button#open-advertise-footer {
    background: var(--accent);
    color: #1A1206;
    border-color: var(--accent);
}

[data-theme="light"] button.advertise-btn:hover,
[data-theme="light"] button#open-advertise:hover,
[data-theme="light"] button#open-advertise-footer:hover {
    background: #B45309;
    color: #FFFFFF;
}

/* --- Phone held sideways ---------------------------------------------------
   Keyed on height, not width. A phone in landscape is 844x390: wide enough
   that every max-width mobile rule stops applying, short enough that the
   stacked header/video/sidebar layout leaves the video 34px tall. Measured
   on staging before this block existed; see the note in the commit.

   min-width keeps it away from a genuinely small window on a desktop, where
   the stacked layout is still the right answer. */
@media (max-height: 560px) and (min-width: 600px) {
    /* No floating card: there is no room to float in.  */
    #camera-modal { padding: 0; align-items: stretch; }

    #camera-modal .modal-content {
        max-width: none;
        width: 100%;
        height: 100%;
        max-height: 100%;
        border-radius: 0;
        border-left: 0;
        border-right: 0;
    }

    /* Title, actions and the status strip share one row. Stacked, they were
       a third of the screen before the video started. */
    #camera-modal .modal-header {
        display: flex;
        flex-direction: row;
        align-items: center;
        gap: var(--space-3, 0.75rem);
        padding: 0.25rem 0.6rem;
        flex-wrap: nowrap;
    }

    #camera-modal .modal-header-row1 {
        flex: 1 1 auto;
        min-width: 0;
        margin: 0;
    }

    #camera-modal .modal-header-row2 {
        flex: 0 0 auto;
        order: -1;
        margin-top: 0;
    }

    #camera-modal #modal-title {
        font-size: var(--text-sm, 0.85rem);
        white-space: nowrap;
        overflow: hidden;
        text-overflow: ellipsis;
    }

    /* The 44px targets stay 44px: a thumb is the same size in landscape, and
       the rule that normally guarantees it lives in a max-width query this
       viewport is too wide to match. Dropping the labels without this took
       them to 38x26 and 22x24. */
    #camera-modal .modal-fav-btn span,
    #camera-modal .report-incident-btn span { display: none; }

    #camera-modal .modal-fav-btn,
    #camera-modal .report-incident-btn,
    #camera-modal .modal-header-close {
        min-width: 44px;
        min-height: 44px;
        display: inline-flex;
        align-items: center;
        justify-content: center;
        padding: 0;
    }

    /* The auto-pause chip's reset control is 20px wide in both orientations. */
    #camera-modal .status-timer-reset,
    #camera-modal #header-timer-reset {
        min-width: 44px;
        min-height: 44px;
    }

    /* Video beside the details, not above them. */
    #camera-modal .modal-body {
        grid-template-columns: minmax(0, 1fr) 240px;
        align-content: stretch;
        flex: 1 1 auto;
        min-height: 0;
    }

    #camera-modal .modal-video-wrap { min-height: 0; }

    /* Fill the column and letterbox inside it, rather than forcing 16/9 on a
       box that cannot be that tall. */
    #camera-modal #modal-video-container {
        aspect-ratio: auto;
        height: 100%;
        max-height: none;
        min-height: 0;
        flex: 1 1 auto;
    }

    #camera-modal #modal-video-container video,
    #camera-modal #modal-video-container img {
        width: 100%;
        height: 100%;
        object-fit: contain;
    }

    #camera-modal .modal-sidebar {
        border-top: 0;
        border-left: 1px solid var(--border);
        max-height: none;
        overflow-y: auto;
    }

    /* Nearby cameras goes back to a vertical list: it has the column height
       for it here, and a sideways rail inside a 240px column shows one tile. */
    #camera-modal .sidebar-related .related-cams-rail {
        flex-direction: column;
        overflow-x: hidden;
        overflow-y: auto;
    }
    #camera-modal .related-cam-tile { flex: 0 0 auto; }

    /* An unsold slot was taking 86px out of a 390px screen. */
    #camera-modal .modal-ad,
    #camera-modal .video-kbd-hints { display: none; }

    /* Theater mode still drops the sidebar; it just has more to give now. */
    #camera-modal.theater .modal-body { grid-template-columns: minmax(0, 1fr); }
}

/* --- Road surface on a phone ----------------------------------------------
   The sidebar that normally carries this card is display:none under 769px,
   which hid the panel from 93% of the audience. app.js moves the card in
   here at that width. The min-height is reserved from first paint so the
   move does not shift the camera grid down. */
#road-mobile-host { display: none; }

@media (max-width: 768px) {
    #road-mobile-host {
        display: block;
        min-height: 148px;
        margin-bottom: var(--space-4, 1rem);
    }

    /* It is a sidebar card living outside the sidebar; give it the card
       treatment the surrounding sections use. */
    #road-mobile-host .sidebar-card {
        background: var(--surface);
        border: 1px solid var(--border);
        border-radius: var(--radius-md, 12px);
        padding: var(--space-4, 1rem);
        margin: 0;
    }
}

/* --- Weather alert headline, light theme -----------------------------------
   var(--warning) is #d97706 in light theme, which runs 3.01:1 on --bg
   (#faf8f5). The title is 0.9rem/600, so 14.4px: normal text, 4.5:1 required.
   Amber 800 keeps the hue at 6.69:1. Scoped here rather than moving
   --warning, which eight other rules use on darker surfaces. */
[data-theme="light"] .weather-alert-title {
    color: #92400e;
}

/* --- Camera sheet on phones ------------------------------------------------
   Most of this site's traffic is phones, and a centred dialog is a desktop
   shape. Below 769px the modal is a bottom sheet: anchored to the bottom,
   rounded at the top, with a handle to drag it away. app.js binds the drag
   to the handle and to the video surface. */
.sheet-handle { display: none; }

@media (max-width: 768px) {
    #camera-modal {
        align-items: flex-end;
        padding: 0;
    }

    #camera-modal .modal-content {
        width: 100%;
        max-width: none;
        max-height: 92dvh;
        border-radius: 18px 18px 0 0;
        border-bottom: 0;
        border-left: 0;
        border-right: 0;
        transform: translateY(0);
        transition: transform var(--dur-base, 200ms) var(--ease, ease);
        will-change: transform;
    }

    /* Entry: the sheet rises. Only on the first paint after opening, which
       app.js drives by removing .sheet-entering on the next frame. */
    #camera-modal.sheet-entering .modal-content { transform: translateY(100%); }

    /* While a finger is down the sheet must track it exactly, so no easing. */
    #camera-modal.sheet-dragging .modal-content { transition: none; }

    .sheet-handle {
        display: flex;
        align-items: center;
        justify-content: center;
        width: 100%;
        /* 44px of grabbable height for a thumb, though the bar drawn inside
           it is only 4px tall. */
        min-height: 44px;
        padding: 0;
        border: 0;
        background: transparent;
        cursor: grab;
        touch-action: none;
        flex-shrink: 0;
    }
    .sheet-handle:active { cursor: grabbing; }

    .sheet-handle-bar {
        display: block;
        width: 44px;
        height: 4px;
        border-radius: 999px;
        background: var(--text-muted);
        opacity: 0.55;
    }

    /* The video surface is a drag target too, so the browser must not claim
       the vertical gesture for scrolling. */
    #camera-modal #modal-video-container { touch-action: none; }
}

@media (prefers-reduced-motion: reduce) {
    #camera-modal .modal-content { transition-duration: 1ms; }
}

/* --- Fewer controls on a phone ---------------------------------------------
   Six icons across the top of a 390px frame is most of the picture. Theater
   mode does nothing on a layout where the sidebar is already stacked below
   the video, and picture-in-picture is the system's own feature on mobile
   (and unavailable for inline video on iOS Safari). The remaining four are
   play/pause, snapshot, share and fullscreen. */
@media (max-width: 768px) {
    #camera-modal #modal-theater,
    #camera-modal #modal-pip {
        display: none;
    }

    /* With four buttons instead of six there is room to give each one a
       proper target rather than squeezing them to 34px. */
    #camera-modal .video-ctrl-btn {
        min-width: 44px;
        min-height: 44px;
    }
}

/* --- Paid ad units ---------------------------------------------------------
   These slots carried seeded mock-ups until the first real advertiser landed.
   Two things change once the ad is somebody's actual business:

   1. It has to be labelled. The "Sponsored" line is the first thing inside the
      link, so it is also the first thing a screen reader announces.
   2. Real artwork is a wordmark, not an emoji. The old rule forced any logo
      into the 44px square the emoji uses, which rendered a 447x190 mark at
      44x19. The image variant keeps the 44px height and lets width follow.
   --------------------------------------------------------------------------- */

.fake-ad-sponsored {
    display: block;
    font-size: var(--text-label);
    letter-spacing: var(--tracking-label);
    text-transform: uppercase;
    color: var(--text-muted);
    line-height: var(--leading-tight);
    margin-bottom: var(--space-1);
}

/* .fake-ad-text span is the description rule; the label needs its own size. */
.fake-ad-text .fake-ad-sponsored { font-size: var(--text-label); }

.fake-ad-logo--img {
    width: auto;
    height: 44px;
    max-width: 132px;
    background: none;
    border-radius: 0;
}

/* 44px is right for a one-glyph mark and far too small for a stacked lockup:
   Sedbrook's runs three tiers of type, and at 44px the lower two turned to
   mush. The banner card is ~99px tall and its text column is taller than the
   mark either way, so the extra height is free in both layouts, wrapped or
   not. */
.fake-ad-banner .fake-ad-logo--img {
    height: 64px;
    max-width: 172px;
}

[data-theme="light"] .fake-ad-logo--img { background: none; }

.fake-ad-logo--img img {
    height: 100%;
    width: auto;
    max-width: 100%;
    object-fit: contain;
    display: block;
}

/* One file is shown per theme. When an ad supplies no light variant both
   sources are identical, so this still resolves to the right picture.
   These have to out-specify `.fake-ad-logo--img img` above (0-1-1), which
   otherwise forces display:block back on and paints both logos side by side. */
.fake-ad-logo--img .fake-ad-logo-light { display: none; }
[data-theme="light"] .fake-ad-logo--img .fake-ad-logo-dark { display: none; }
[data-theme="light"] .fake-ad-logo--img .fake-ad-logo-light { display: block; }

/* A wordmark already carries the business name, so it needs less air beside
   the copy than a lone emoji tile does. */
.fake-ad--branded { gap: var(--space-3); }

/* Vertical (sidebar) cards centre their content, so the mark centres too and
   may run the full width of the rail. */
.fake-ad-vertical .fake-ad-logo--img {
    max-width: 100%;
    margin-inline: auto;
}

/* The modal renders every ad as a slim row and drops the logo to 32px. */
.modal-ad .fake-ad-logo--img {
    width: auto;
    height: 32px;
    max-width: 96px;
}

/* Under 480px .fake-ad wraps: the mark and the copy share a row and the CTA
   takes its own. The mark keeps its height there, since the copy column is the
   taller of the two, but the width has to give or the copy is left setting two
   words per line. */
@media (max-width: 480px) {
    .fake-ad-logo--img { max-width: 140px; }
    .fake-ad-banner .fake-ad-logo--img { max-width: 140px; }
}

@media (max-width: 380px) {
    .fake-ad-logo--img { height: 40px; max-width: 104px; }
    .fake-ad-banner .fake-ad-logo--img { height: 52px; max-width: 116px; }
}

/* --- Error state owns the frame --------------------------------------------
   The error panel and the control overlay are both absolutely positioned in
   #modal-video-container, so they were drawn over each other: on a phone the
   pause button sat on the message and the error's X icon landed on the
   snapshot button. Every hidden control acts on a video that is not there.
   The previous/next arrows stay: switching to a working camera is the useful
   thing to do from here. */
#modal-video-container:has(.stream-expired) .video-top-controls,
#modal-video-container:has(.stream-expired) .video-live-chip {
    display: none;
}

/* Room to breathe, and clear of the arrows on either side. */
.stream-expired {
    display: flex;
    flex-direction: column;
    align-items: center;
    justify-content: center;
    gap: var(--space-3, 0.75rem);
    text-align: center;
    padding: var(--space-4, 1rem) 3.25rem;
}

/* --- Still fallback when the live stream will not start --------------------
   Iowa DOT's video origin can hand out bytes far too slowly to play while the
   cameras themselves are fine and our cached frame is current. Showing that
   frame beats a black box: the picture is what people came for. The message
   then has to sit over the image instead of replacing it, so this variant
   moves it to the bottom and puts a scrim behind it for contrast. */
.stream-still-fallback {
    position: absolute;
    inset: 0;
    width: 100%;
    height: 100%;
    object-fit: contain;
    background: #000;
    z-index: 10;
}

.stream-expired.has-still {
    justify-content: flex-end;
    gap: var(--space-2, 0.5rem);
    padding: var(--space-4, 1rem) 3.25rem var(--space-3, 0.75rem);
    /* Transparent at the top so the footage stays visible, opaque enough at
       the bottom that the text clears AA over any frame. */
    background: linear-gradient(to bottom, rgba(0, 0, 0, 0) 45%, rgba(0, 0, 0, 0.82) 100%);
    color: #fff;
}

.stream-expired.has-still p {
    font-size: 0.95rem;
    line-height: 1.35;
    max-width: 46ch;
    text-shadow: 0 1px 3px rgba(0, 0, 0, 0.9);
}

/* --- Packing the sheet header ----------------------------------------------
   Measured at 360x840: 44px handle + 117px header above a 203px video, and
   the title clipped to "Mississippi Riv...". The countdown had a 52px row to
   itself. This reclaims about 70px and lets the title finish. */
@media (max-width: 768px) {
    /* The bar still reads as a grab target, and the whole header above the
       video is draggable anyway, so the row does not need to be 44px tall. */
    #camera-modal .sheet-handle { min-height: 30px; }

    #camera-modal .modal-header {
        padding-top: 0.15rem;
        padding-bottom: 0.4rem;
    }

    /* Two lines, so a name like "US 61 @ Mississippi River Bridge - East"
       arrives whole instead of as "Mississippi Riv...". */
    #camera-modal #modal-title {
        white-space: normal;
        overflow: visible;
        text-overflow: clip;
        display: -webkit-box;
        -webkit-line-clamp: 2;
        -webkit-box-orient: vertical;
        overflow-wrap: anywhere;
        font-size: var(--text-base, 1rem);
        line-height: var(--leading-tight, 1.25);
    }

    /* The countdown joins the subtitle line rather than owning a row. */
    #camera-modal .modal-header-row2 {
        margin-top: 0;
        min-height: 0;
    }
    #camera-modal #modal-status {
        font-size: var(--text-xs, 0.75rem);
    }
    #camera-modal #modal-status .status-timer-reset {
        min-width: 32px;
        min-height: 32px;
    }

    /* The readings and the countdown share one row under the picture. The
       row carries its own surface: styling only the bar inside it left the
       countdown sitting on the video wrap's black background. */
    /* :empty never matches: the host always contains the bar element, even
       when the bar itself has no readings. Ask about the readings. */
    #camera-modal .stream-conditions:empty,
    #camera-modal .stream-conditions:not(:has(.weather-bar-item)) { display: none; }
    #camera-modal .stream-conditions {
        display: flex;
        flex-wrap: wrap;
        align-items: center;
        gap: var(--space-2, 0.5rem) var(--space-3, 0.75rem);
        padding: var(--space-2, 0.5rem) var(--space-4, 0.9rem);
        background: var(--surface);
        border-bottom: 1px solid var(--border);
    }

    /* Nothing to report: no row. An empty bar in its own row is dead space,
       where painted over the video it was merely invisible. */
    #camera-modal .stream-conditions > .stream-weather-bar:empty { display: none; }

    /* display:contents dissolves the bar's own box so its readings become
       items of the row itself. As its own flex container it claimed a whole
       line and pushed the countdown onto a third one. */
    #camera-modal .stream-conditions .stream-weather-bar {
        display: contents;
    }
    #camera-modal .stream-conditions .weather-bar-item {
        text-shadow: none;
        opacity: 1;
    }


    /* Four readings on two lines, not three. */
    #camera-modal .stream-conditions .weather-bar-item { font-size: var(--text-xs, 0.75rem); }
}

/* The advertiser's town. On a page people read while deciding which way to
   drive, "where is it" is the second question after "what is it", so it gets
   its own line rather than being buried in the description. Sits below the
   copy, quieter than it, with the pin optically aligned to the cap height. */
.fake-ad-location {
    display: flex;
    align-items: center;
    gap: var(--space-1);
    margin-top: var(--space-1);
    font-size: var(--text-xs);
    color: var(--text-muted);
    line-height: var(--leading-tight);
}

.fake-ad-text .fake-ad-location { font-size: var(--text-xs); }

.fake-ad-pin {
    width: 13px;
    height: 13px;
    flex-shrink: 0;
    stroke-linecap: round;
    stroke-linejoin: round;
}

/* Vertical sidebar cards centre their text, so the row centres with it. */
.fake-ad-vertical .fake-ad-location { justify-content: center; }

/* --- Menu notch on the bottom nav ------------------------------------------
   Pull up on the bar to open the menu, pull down on the menu to close it.
   The notch is fused to the bar's top edge so it reads as a handle rather
   than a button. app.js injects it and owns the gestures. */
@media (max-width: 768px) {
    .mobile-bottom-nav { position: fixed; }

    .bn-menu-grip {
        position: absolute;
        bottom: calc(100% - 1px);
        left: 50%;
        transform: translateX(-50%);
        width: 96px;
        min-height: 30px;
        display: flex;
        flex-direction: column;
        align-items: center;
        justify-content: flex-start;
        gap: 3px;
        padding: 6px 0 0;
        margin: 0;
        appearance: none;
        -webkit-appearance: none;
        border: 1px solid var(--border);
        border-bottom: 0;
        border-radius: 11px 11px 0 0;
        background: var(--surface);
        color: inherit;
        cursor: grab;
        /* The bar owns this vertical gesture. */
        touch-action: none;
    }
    .bn-menu-grip:active { cursor: grabbing; }

    /* The bar the thumb aims at. */
    .bn-menu-grip::after {
        content: "";
        width: 36px;
        height: 4px;
        border-radius: 3px;
        background: var(--text-muted);
        transition: width var(--dur-fast, 120ms) var(--ease, ease), background var(--dur-fast, 120ms) var(--ease, ease);
    }
    .bn-menu-grip.dragging::after { width: 52px; background: var(--accent, #f59e0b); }

    .bn-menu-grip-label {
        font-size: 0.5rem;
        font-weight: 600;
        letter-spacing: 0.07em;
        text-transform: uppercase;
        line-height: 1;
        color: var(--text-muted);
        /* A whisper: the notch shape is the affordance. */
        opacity: 0.62;
    }

    .bn-menu-grip-chev {
        position: absolute;
        top: -13px;
        left: 50%;
        transform: translateX(-50%);
        color: var(--accent, #f59e0b);
        opacity: 0;
        pointer-events: none;
    }

    /* Teach the gesture: the notch stretches upward twice per run while a
       chevron climbs out of it and fades. The bottom edge stays fused to the
       bar, so it stretches rather than detaching. Same shape as GLHub's
       gl-bn-grip, which is where this pattern comes from. */
    .bn-menu-grip.hinting { animation: bnGripStretch 1.15s ease-in-out 2; }
    .bn-menu-grip.hinting .bn-menu-grip-chev { animation: bnGripChev 1.15s ease-in-out 2; }

    /* Two bobs inside one run: up, part-way back, up again. */
    @keyframes bnGripStretch {
        0%, 100% { min-height: 30px; }
        30%      { min-height: 38px; }
        50%      { min-height: 33px; }
        65%      { min-height: 38px; }
    }

    /* The chevron RISES rather than bobbing: it is the part that says which
       way to move, so it travels the whole way up and fades out at the top. */
    @keyframes bnGripChev {
        0%, 15% { opacity: 0; transform: translate(-50%, 4px); }
        35%     { opacity: 1; transform: translate(-50%, -4px); }
        70%     { opacity: 1; transform: translate(-50%, -7px); }
        100%    { opacity: 0; transform: translate(-50%, -11px); }
    }

    /* The drawer becomes a bottom sheet. `top` is set inline by the existing
       toggle to sit under the header, so it has to be overridden hard. */
    .mobile-menu-drawer {
        top: auto !important;
        bottom: 0;
        left: 0;
        right: 0;
        max-height: 82dvh;
        overflow-y: auto;
        border-radius: 18px 18px 0 0;
        border-bottom: 0;
        border-left: 0;
        border-right: 0;
        transform: translateY(100%);
        transition: transform var(--dur-base, 200ms) var(--ease, ease), opacity var(--dur-base, 200ms) var(--ease, ease);
    }
    .mobile-menu-drawer.open { transform: translateY(0); }
    .mobile-menu-drawer.sheet-dragging { transition: none; }

    /* Its own grab strip, so the sheet says it can be pulled down. */
    .mobile-menu-drawer::before {
        content: "";
        display: block;
        width: 44px;
        height: 4px;
        margin: 0 auto 0.6rem;
        border-radius: 999px;
        background: var(--text-muted);
        opacity: 0.55;
    }
}

@media (prefers-reduced-motion: reduce) {
    .bn-menu-grip.hinting,
    .bn-menu-grip.hinting .bn-menu-grip-chev { animation: none; }
    .mobile-menu-drawer { transition-duration: 1ms; }
}

/* --- Auto-pause countdown, in the sidebar ----------------------------------
   It used to own a row in the modal header, which cost the picture height on
   every screen. It now sits under Route and Direction, so it is styled like
   the facts around it rather than like a control parked in a list. */
#camera-modal #sidebar-info #modal-status {
    display: flex;
    align-items: center;
    justify-content: space-between;
    gap: var(--space-2, 0.5rem);
    width: 100%;
}

/* Route above it is label-left, value-right; this row matches, instead of
   floating right on its own. */
#camera-modal #sidebar-info .status-timer-group {
    display: flex;
    align-items: center;
    justify-content: space-between;
    width: 100%;
    gap: 0.4rem;
    padding: 0;
    background: none;
    border: 0;
}

/* The label earns its words back here: there is room, and "Auto-pause" on
   its own is less cryptic than a bare clock icon. */
#camera-modal #sidebar-info .status-timer-label {
    display: inline;
    font-size: var(--text-label, 0.68rem);
    letter-spacing: var(--tracking-label, 0.06em);
    text-transform: uppercase;
    color: var(--text-muted);
}

#camera-modal #sidebar-info .status-timer {
    font-variant-numeric: tabular-nums;
    font-weight: 600;
}

#camera-modal #sidebar-info .status-timer-reset {
    min-width: 32px;
    min-height: 32px;
    display: inline-flex;
    align-items: center;
    justify-content: center;
}

/* --- Nearby camera with no frame yet ---------------------------------------
   Iowa DOT has not published one, which is not the same as the camera being
   down. A faded broken image read as a bug; a camera glyph reads as pending. */
.related-cam-thumb-empty {
    display: flex;
    align-items: center;
    justify-content: center;
    background: var(--surface-alt, rgba(148, 163, 184, 0.12));
    border-radius: 6px;
    color: var(--text-muted);
    opacity: 0.75;
}
.related-cam-thumb-empty svg { width: 55%; height: 55%; }

/* --- Form dialogs have to scroll -------------------------------------------
   effects.css gives every modal except the camera one height:100% !important
   on phones, and .modal-content carries overflow:hidden for its corners. The
   contact form is 773px of content in a 746px box, so Send was 7px below the
   fold with nothing to scroll. Measured on a Pixel 6 Pro. */
@media (max-width: 768px) {
    .modal:not(#camera-modal) .modal-content {
        overflow-y: auto;
        -webkit-overflow-scrolling: touch;
        overscroll-behavior: contain;
    }

    /* Clear the gesture bar so the last control is not pinned against it. */
    .modal:not(#camera-modal) .modal-body,
    .modal:not(#camera-modal) form {
        padding-bottom: max(var(--space-4, 1rem), env(safe-area-inset-bottom, 0px));
    }
}

/* --- Install prompt --------------------------------------------------------
   It sat at z-index 1001 against .modal's 1000, so it floated inside an open
   camera, between the details and Copy link. Same fault the privacy notice
   had. Page content tops out at 101, so 990 keeps it above the page and
   under any dialog.

   It was also 99px tall with "access" orphaned on its own line; with the 62px
   nav under it that is 161px of a 746px screen standing on the cameras. */
.pwa-install-banner {
    z-index: 990;
}

@media (max-width: 768px) {
    .pwa-install-banner {
        padding-top: 0.55rem;
        padding-bottom: 0.55rem;
    }
    /* One line each. The heading already says what it is. */
    .pwa-install-banner .pwa-install-text p,
    .pwa-install-banner p {
        font-size: var(--text-xs, 0.75rem);
        line-height: var(--leading-tight, 1.3);
        margin: 0;
    }
}

/* --- Player chrome ---------------------------------------------------------
   The controls were four solid rgba(0,0,0,0.55) squares across the top of the
   picture plus two at the sides. A scrim carries legibility for the whole row
   at once, so the buttons themselves can disappear and leave the road. */
#modal-video-container .video-controls-overlay::before {
    content: "";
    position: absolute;
    inset: 0 0 auto 0;
    height: 96px;
    pointer-events: none;
    z-index: 0;
    background: linear-gradient(to bottom,
        rgba(0, 0, 0, 0.5) 0%,
        rgba(0, 0, 0, 0.28) 45%,
        rgba(0, 0, 0, 0) 100%);
}

/* The scrim does the work now, so the buttons stop drawing boxes. The shadow
   keeps a white icon readable if it lands on a pale patch. */
#modal-video-container .video-ctrl-btn {
    background: none;
    filter: drop-shadow(0 1px 3px rgba(0, 0, 0, 0.85));
    transition: transform var(--dur-fast, 120ms) var(--ease, ease), opacity var(--dur-fast, 120ms) var(--ease, ease);
}
#modal-video-container .video-ctrl-btn:hover,
#modal-video-container .video-ctrl-btn:focus-visible {
    background: none;
    opacity: 1;
    transform: scale(1.08);
}

/* The LIVE chip sits in the same scrim, so it can lose its own slab too. */
#modal-video-container .video-live-chip {
    background: none;
    filter: drop-shadow(0 1px 3px rgba(0, 0, 0, 0.85));
}

/* Mid-frame, with no scrim behind them, the arrows keep a backing. Round and
   softer, so they read as controls rather than tiles on the road. */
#modal-video-container .video-nav-btn {
    background: rgba(15, 23, 42, 0.42);
    border-radius: 999px;
    width: 44px;
    height: 44px;
}
/* The shadow goes on the glyph. On the button it haloed the whole
   translucent shape and the arrows read as smudges. */
#modal-video-container .video-nav-btn svg {
    filter: drop-shadow(0 1px 2px rgba(0, 0, 0, 0.8));
}
#modal-video-container .video-nav-btn:hover,
#modal-video-container .video-nav-btn:focus-visible {
    background: rgba(15, 23, 42, 0.62);
}

/* Above the scrim. */
#modal-video-container .video-top-controls,
#modal-video-container .video-live-chip,
#modal-video-container .video-nav-btn { z-index: 2; }

#camera-modal #sidebar-info .status-timer-label { margin-right: auto; }
#camera-modal #sidebar-info .status-timer { margin-left: auto; }

/* --- Room at the edge ------------------------------------------------------
   The close button sat 9px from the right boundary at 344px wide, so any
   displacement takes it off screen first. Reported clipped on a Fold cover
   screen; not reproducible at 280-360px here, and the cause is still open. */
@media (max-width: 768px) {
    /* style.css pins this absolutely at <=600px, against a relative header.
       Pinned to an offset it cannot respond to the layout, so it sat outside
       the header's content box and left the screen entirely on a narrow
       device. The header is one non-wrapping row now, so it belongs in the
       flow as the last action, which is what it already is above 600px. */
    #camera-modal .modal-header-close {
        position: static;
        top: auto;
        right: auto;
    }

    #camera-modal .modal-header-actions {
        /* Not `flex: 1 1 100%`, which it inherits from the wrapping layout
           this header no longer uses. With flex-wrap:nowrap the title and
           the actions both asked for 100% of the row, and the actions cannot
           shrink (44px touch targets), so the close button hung outside the
           header and was cut off on a narrow screen. */
        flex: 0 0 auto;
        padding-right: 0;
        gap: 0.3rem;
    }

    /* The header keeps its own edge padding even if a child tries to grow. */
    #camera-modal .modal-header { padding-right: var(--space-3, 0.75rem); }
}

/* --- Sidebar forecast rows -------------------------------------------------
   The description was centred in a narrow column, so a long NWS phrase wrapped
   to four ragged lines and that one row set the height of the card. Left
   aligned and clamped to two lines, the days scan as a column again. */
.sidebar-forecast-day {
    align-items: start;
}

.sidebar-forecast-day-desc {
    text-align: left;
    display: -webkit-box;
    -webkit-line-clamp: 2;
    -webkit-box-orient: vertical;
    overflow: hidden;
    line-height: var(--leading-tight, 1.3);
}

/* The day and the temperature are short and known; give the width back to
   the reading, which is the part that was being squeezed. */
.sidebar-forecast-day-name { width: 54px; }
.sidebar-forecast-day-temp { width: 68px; white-space: nowrap; }

/* --- Map mode fills the page -----------------------------------------------
   The fixed 500/400/300px heights belong to the old picker panel. As a view
   mode the map should take the content area, minus the sticky header and
   filter row above it and the bottom nav below. */
#inline-map-view:not(.map-compact) {
    display: flex;
    flex-direction: column;
    height: calc(100dvh - 190px);
    min-height: 380px;
}

#inline-map-view:not(.map-compact) .inline-camera-map {
    flex: 1 1 auto;
    height: auto;
    min-height: 0;
}

/* The toolbar and the hint keep their own size inside the column. */
#inline-map-view:not(.map-compact) .map-toolbar,
#inline-map-view:not(.map-compact) .map-panel-hint {
    flex: 0 0 auto;
}

/* --- Map mode toolbar ------------------------------------------------------
   It came to ~700px of a 746px screen: a redundant Back button, ten pills
   wrapped onto three rows, and three layer buttons, with the map below the
   fold. One scrolling row, the way the camera filter bar already works. */
#inline-map-view:not(.map-compact) .map-toolbar {
    display: flex;
    flex-direction: column;
    gap: var(--space-2, 0.5rem);
    padding-bottom: var(--space-2, 0.5rem);
}

/* The switcher's grid button does this, from the sticky row, at any scroll
   position. A second control for it is just height. */
#inline-map-view .map-back-btn { display: none; }

/* The same ten pills sat in the sticky filter row roughly 400px above these,
   and that row had no effect on the markers. One control drives the grid and
   the map now, so this copy goes. */
#inline-map-view .map-filter-bar { display: none; }

#inline-map-view:not(.map-compact) .map-layer-bar {
    display: flex;
    flex-wrap: nowrap;
    overflow-x: auto;
    -webkit-overflow-scrolling: touch;
    scrollbar-width: none;
    gap: var(--space-2, 0.5rem);
    padding-bottom: 2px;
}
#inline-map-view:not(.map-compact) .map-layer-bar::-webkit-scrollbar { display: none; }

#inline-map-view:not(.map-compact) .map-filter-btn {
    flex: 0 0 auto;
    white-space: nowrap;
}

/* Layers are a smaller decision than which cameras to show. */
#inline-map-view:not(.map-compact) .map-layer-bar { justify-content: flex-start; }
#inline-map-view:not(.map-compact) .map-layer-bar .map-filter-btn {
    font-size: var(--text-xs, 0.75rem);
}

/* --- The camera controls block ---------------------------------------------
   Search gets its own full-width row. Underneath it the jump menu and the view
   picker face each other. Scoped to the id on purpose: this is the one place
   that owns this layout, and an id here beat a class rule added later, which
   is how the second row ended up 329px wide inside a 1,028px block.

   Search and the switcher used to share a single row, and before that the
   switcher sat in the filter rail, where it took 144px of 411px on a phone. */
#camera-search-bar {
    display: flex;
    flex-direction: column;
    align-items: stretch;
    gap: var(--space-3, 0.75rem);
}

#camera-search-bar .camera-search-wrap {
    width: 100%;
    min-width: 0;
}

#camera-search-bar #view-toggle-group {
    display: inline-flex;
    flex: 0 0 auto;
    gap: 2px;
    /* sm, like the field and the button it shares a line with. md left it the
       one visibly rounder object in the row. The design system's md is for
       cards and panels; this is a control. */
    border-radius: var(--radius-sm, 8px);
}


#camera-search-bar .view-toggle-btn {
    min-width: 40px;
    min-height: 40px;
    display: inline-flex;
    align-items: center;
    justify-content: center;
    border-radius: var(--radius-sm, 8px);
}

@media (pointer: coarse) {
    #camera-search-bar .view-toggle-btn { min-width: 44px; min-height: 44px; }
}

/* --- Switcher in the wide filter row ---------------------------------------
   At 769px and up the search field and the switcher both move into the filter
   row, which has the width for them there. */

/* --- Controls that can be hit ----------------------------------------------
   WCAG 2.2 AA floor is 24x24 for anything that is not inline text in a
   sentence. Measured on production across six widths: the desktop Facebook
   link was 16x16, the radar opacity slider 70x4, and the header weather
   widget 58x22 on a tablet. */
.header-fb-link-desktop {
    min-width: 24px;
    min-height: 24px;
}

/* A slider looks like a thin track, so keep the track thin and give the
   control its height in padding. Four pixels is hard to hit with a mouse and
   close to impossible with a thumb. */
.radar-opacity {
    height: 24px;
    background: transparent;
    -webkit-appearance: none;
    appearance: none;
}
.radar-opacity::-webkit-slider-runnable-track {
    height: 4px;
    border-radius: 999px;
    background: var(--border);
}
.radar-opacity::-moz-range-track {
    height: 4px;
    border-radius: 999px;
    background: var(--border);
}
/* The thumb has to be pulled back into the track once the track is thin. */
.radar-opacity::-webkit-slider-thumb {
    -webkit-appearance: none;
    width: 16px;
    height: 16px;
    margin-top: -6px;
    border-radius: 50%;
    background: var(--accent, #f59e0b);
    cursor: pointer;
}
.radar-opacity::-moz-range-thumb {
    width: 16px;
    height: 16px;
    border: 0;
    border-radius: 50%;
    background: var(--accent, #f59e0b);
    cursor: pointer;
}

/* It carries role=button and tabindex, so it is a control and needs the
   floor like any other. */
#weather-widget.widget-clickable {
    min-height: 24px;
}

/* A thumb wants more than the floor. */
@media (pointer: coarse) {
    .radar-opacity { height: 32px; }
    .header-fb-link-desktop { min-width: 32px; min-height: 32px; }
}

/* --- Title and direction as one block --------------------------------------
   #modal-title and .modal-title-dir are siblings in the header row, so they
   need to stack rather than sit side by side in that flex row. */
#camera-modal .modal-header-row1 { flex-wrap: wrap; }
#camera-modal #modal-title { flex: 1 1 auto; min-width: 0; }
#camera-modal .modal-title-dir {
    flex: 1 1 100%;
    order: 3;
    min-width: 0;
    overflow: hidden;
    text-overflow: ellipsis;
    white-space: nowrap;
}

/* --- Menu sheet: one panel, three bands -------------------------------------
   Sections, then settings, then what is happening outside. The bands are
   separated by hairlines rather than by a change of button shape, so the
   sheet reads as one designed surface. */

/* A three-column grid cannot hold five items without leaving a hole. Flex
   with grow lets the last row divide whatever is left, so five items become
   3 + 2 and both rows are full. It also survives a section being added or
   dropped, which the fixed grid did not. */
.mobile-menu-grid {
    display: flex;
    flex-wrap: wrap;
    gap: 0.5rem;
    margin-bottom: 0.85rem;
}
.mobile-menu-item {
    flex: 1 1 28%;
    min-width: 0;
    gap: 0.5rem;
    padding: 0.9rem 0.5rem;
    border-radius: 14px;
    font-size: 0.74rem;
}

/* Painted once. The border and the tint already say which section you are
   in; the glow underneath it was doing the same job twice and louder. */
.mobile-menu-item.active {
    box-shadow: none;
    background: linear-gradient(160deg, rgba(245, 158, 11, 0.16), rgba(245, 158, 11, 0.06)) !important;
}

/* The settings band. Same corner radius and border as the tiles so it
   belongs to the same sheet, shorter because it is the secondary group,
   and above the 44px a thumb wants rather than the 40 it had. */
.mobile-menu-actions {
    gap: 0.5rem;
    padding-top: 0.85rem;
    margin-bottom: 0.85rem;
    border-top: 1px solid var(--border);
}
.mobile-menu-action {
    min-height: 44px;
    border-radius: 14px;
    gap: 0.45rem;
    font-size: 0.76rem;
}
/* 22px in the tiles against 16px here was the loudest part of the mismatch. */
.mobile-menu-action svg { width: 18px; height: 18px; }

/* Hover belongs to pointers. A phone has no hover to give, but Android
   leaves :hover on whatever was tapped last, and the sheet opens directly
   under the thumb that tapped the notch, so a button came up wearing the
   amber border that means "you are here". */
@media (hover: none) {
    .mobile-menu-item:hover:not(.active),
    .mobile-menu-action:hover {
        color: var(--text-muted);
        background: rgba(255, 255, 255, 0.03);
        border-color: rgba(255, 255, 255, 0.08);
    }
    [data-theme="light"] .mobile-menu-item:hover:not(.active),
    [data-theme="light"] .mobile-menu-action:hover {
        background: rgba(15, 23, 42, 0.03);
        border-color: rgba(15, 23, 42, 0.08);
    }
}

/* The live band. Temperature and closings are the reason a lot of people
   open this sheet at all, so they get the width and a readable size
   instead of being set as a footnote. */
.mobile-menu-quick-info {
    gap: 0.5rem 1.1rem;
    padding-top: 0.85rem;
    margin-top: 0;
    border-top: 1px solid var(--border);
    font-size: 0.8rem;
    color: var(--text);
}
.mobile-menu-quick-info .quick-item { gap: 0.45rem; font-weight: 500; }
.mobile-menu-quick-info .quick-dot { width: 7px; height: 7px; }

/* A handle nobody can see is not a handle. */
.mobile-menu-drawer::before {
    width: 40px;
    height: 5px;
    margin-bottom: 0.85rem;
    opacity: 0.75;
}

/* Room at the bottom, so the last row is not flush against the edge of the
   screen where a gesture bar lives. */
.mobile-menu-drawer { padding: 0.9rem 1rem 1.25rem; }

/* --- "you are in here" -----------------------------------------------------
   Set by updateMobileNav when the open section is not one of the four on the
   bar. Without it the bar sits fully unlit on the Scores view and nothing on
   screen says where you are. */
.bn-menu-grip.is-overflow-active { border-color: rgba(245, 158, 11, 0.5); }
.bn-menu-grip.is-overflow-active::after { background: var(--accent, #f59e0b); }
.bn-menu-grip.is-overflow-active .bn-menu-grip-label {
    color: var(--accent, #f59e0b);
    /* Louder than the resting whisper: being the active section earns it. */
    opacity: 0.95;
    font-weight: 800;
}

/* --- Pills stay pills -------------------------------------------------------
   A pill is padding plus a 999px radius, which turns a short label into a
   circle: "All" measured 46x44 on the scores row, aspect 1.03, sitting beside
   "Colleges" at 1.85 and "High Schools" at 2.43. A width floor keeps the row
   reading as one set of controls.

   The scores row ONLY. A flex item's min-width: auto is what stops
   flex-shrink from squeezing it below its own text, and writing any explicit
   min-width throws that away. Applied to .cam-filter-btn it did not raise a
   floor, it removed one, and the camera row shrank every pill to exactly
   56px: scrollWidth 98 against clientWidth 54, "Julien Dubuque" clipped. The
   camera pills were never circles anyway ("All" is 1.56 there). */
.scores-filter-btn {
    min-width: 3.5rem;
    /* So a future flex change cannot cost this row its content minimum. */
    flex-shrink: 0;
}

/* --- Upstream feed notice + degraded cards -------------------------------
   Appended last on purpose: this file already carries several !important
   rules and a card-state cascade, and last-in wins without adding another
   one. See project_dbqcams_growth_2026_08 for the layering traps.

   Two distinct card states now exist where there was one:
     .is-offline   Iowa DOT publishes nothing for this camera. Nothing to show.
     .is-degraded  The stream is live; their server is too slow to give us a
                   still. The player still works, so the card must not read as
                   dead or sort to the bottom with the corpses.
   -------------------------------------------------------------------------- */

/* Reserves nothing on a healthy load, so a normal page has no shift. */
#upstream-notice-host:empty { display: none; }

.upstream-notice {
    display: flex;
    gap: 12px;
    align-items: flex-start;
    margin: 0 0 18px;
    padding: 14px 16px;
    background: var(--surface);
    border: 1px solid var(--border-strong);
    border-left: 3px solid var(--stale);
    border-radius: var(--radius);
    box-shadow: var(--shadow-sm);
}

.upstream-notice-icon {
    flex: 0 0 auto;
    width: 22px;
    height: 22px;
    margin-top: 1px;
    color: var(--stale);
}

.upstream-notice-body {
    font-size: .92rem;
    line-height: 1.5;
    color: var(--text-muted);
}

.upstream-notice-body strong { color: var(--text); }


/* A delayed camera is still a working camera: no corpse dimming, and it keeps
   its place in the grid instead of sorting to the end with the dead ones. */
.camera-card.is-degraded { opacity: 1; order: 0; }
.camera-card.is-degraded .cam-status-dot { background: var(--stale); }
.camera-card.is-degraded .cam-status-text { color: var(--stale); }
.camera-card.is-degraded .camera-thumb-error { color: var(--stale); }
.camera-card.is-degraded .camera-thumb-error svg { color: var(--stale); opacity: .85; }

/* The phone is 93% of this audience and the notice sits above the grid, so it
   must not eat the fold. Tighter box, same message. */
@media (max-width: 768px) {
    .upstream-notice { padding: 12px 13px; gap: 10px; margin-bottom: 14px; }
    .upstream-notice-body { font-size: .86rem; }
    .upstream-notice-icon { width: 20px; height: 20px; }
}

/* --- Touch targets and narrow-width containment --------------------------
   Found by sweeping 280-412px, which the suite did not cover (it started at
   390px). Both of these are silent: they need a narrow viewport or a ruler.
   -------------------------------------------------------------------------- */


/* The scores filter row is display:flex with no wrap and no scroll, and its
   pills carry flex-shrink: 0 (deliberately, so labels keep their content
   minimum). With nowhere to give, the row itself became wider than a 280px
   viewport and dragged the whole PAGE into horizontal scroll. The camera
   filter bar already solves this by scrolling; match it rather than letting
   the pills shrink, which is what flex-shrink: 0 exists to prevent. */
.scores-filter-row {
    overflow-x: auto;
    flex-wrap: nowrap;
    scrollbar-width: none;
    -webkit-overflow-scrolling: touch;
}
.scores-filter-row::-webkit-scrollbar { display: none; }
.scores-filter-btn { white-space: nowrap; }

/* 31px is not a thumb target. */
@media (max-width: 768px) {
    .scores-schedule-link { min-height: 44px; display: inline-flex; align-items: center; }
}

/* A flex item defaults to min-width: auto, which means "never shrink below my
   content's minimum". .scores-header-top is 190px at a 280px viewport, but its
   child sat at 256px and pushed the document to scrollWidth 301, so the whole
   page scrolled sideways. Same trap as the mcdtv grid: the fix is to let the
   item shrink and let the heading wrap, not to shrink the viewport's chrome. */
.scores-header-top > * { min-width: 0; }
/* style.css sets align-items: flex-start on this container, and at <=768px it
   also turns it into a column. In a column flex box that pair means "size each
   child to its own content" rather than "fill the row", so the heading block
   sat at its 256px max-content inside a 190px parent no matter what min-width
   said. Stretching the children is what actually lets min-width: 0 bite. */
.scores-header-top > * { align-self: stretch; }

/* The last piece, and the non-obvious one. style.css also sets flex-wrap: wrap
   on this container, and at <=768px it is a COLUMN. A wrapping column flex box
   is multi-line, and a flex line's cross size (here, its width) comes from its
   items' content, not from the container. So the heading stretched to its own
   256px max-content instead of the container's 190px, and neither min-width: 0
   nor align-self could change that. As a single-line column it stretches to the
   container, which is what the layout wanted all along. */
@media (max-width: 768px) {
    .scores-header-top { flex-wrap: nowrap; }
}
.scores-title { flex-wrap: wrap; min-width: 0; }
.scores-title, .scores-tab-subtitle { overflow-wrap: anywhere; }

/* Score rows: the opponent is the row's whole point, and it was the only part
   being thrown away. The row is
       [result 26px] [opponent flex:1] [status min 50px] [date min 50px]
   with the last two flex-shrink: 0, so "Scheduled" expanded to fit and the
   opponent kept whatever was left. Measured on staging: at 390px the opponent
   got 139px of the 207px it needed, and at 320px it got 69px, rendering rows
   that read "vs ...". Truncation started at every width tested, not just the
   narrow end. Letting the name wrap costs a line and keeps the information. */
@media (max-width: 480px) {
    .score-game-row { align-items: flex-start; }
    .score-opponent {
        white-space: normal;
        overflow: visible;
        text-overflow: clip;
        overflow-wrap: anywhere;
    }
    /* Stop the status column bidding against the name for width. */
    .score-final { min-width: 0; }
}

/* =========================================================================
   Phone spacing pass, 22 Aug 2026. Measured on the Pixel 6 Pro at 411x746.
   ========================================================================= */

/* --- The section heading's "N cams" count sat in a different place in every
   heading. Two rules were fighting: the count carries margin-right: auto from
   the "keep the count next to the title" block, and the <=480px block adds
   margin-left: auto. Two auto margins centre an item in the leftover space, so
   the count floated wherever the title happened to end. Measured on the live
   site the count's right edge landed at 265, 281, 301, 302, 303, 316, 319, 320
   and 320px down the page while the chevron stayed pinned at 399. Killing the
   right auto pins the count to the chevron and gives one clean column. */
@media (max-width: 480px) {
    section h2 .cam-count {
        margin-left: auto;
        margin-right: 0;
    }
}

/* --- The filter rail that needed this is gone, and so is the rule, but keep
   the lesson: scroll-snap-align: start snaps to the scrollport's start edge,
   which sits INSIDE the container's padding unless scroll-padding-inline says
   otherwise. Any future snapping row loads at scrollLeft = its own gutter and
   eats it. */

/* --- A card's status wrapped to a line of its own whenever the place text was
   long ("East Wide Angle . US 20", "US 20 Intersection . US 20"), and
   margin-left: auto then threw it at the right edge, leaving a card-wide hole
   under the place. About a third of the cards read that way. Let the place
   shrink into its own column instead of wrapping the whole row: the status
   keeps its right-hand column on every card, and a long place takes a second
   line under itself where a second line belongs. */
.camera-meta {
    flex-wrap: nowrap;
    align-items: flex-start;
}

.camera-meta-place {
    flex: 1 1 auto;
    min-width: 0;
}

.camera-meta .cam-status {
    flex: 0 0 auto;
    margin-left: auto;
}

/* --- The footer's link column rendered hard left inside an otherwise centred
   footer. The touch-target rule gives those links display: flex to get them to
   44px, and a flex container ignores the text-align: center it inherits from
   .footer-col. The links need the flex box centred, not the text. */
@media (max-width: 768px) {
    .footer-links a,
    .footer-nav a,
    #footer-cameras-link,
    #footer-feeds-link,
    #footer-weather-link,
    #footer-closings-link,
    #footer-scores-link {
        justify-content: center;
    }
}

/* --- In the 7-day strip every column is as tall as the longest description,
   so one four-line entry ("Mostly Sunny then Chance Showers And Thunderstorms")
   left the other columns with a ~90px hole and put the four temperatures on
   four different baselines. The descriptions are condensed at the source now;
   this keeps the temperature on the same line whatever the text does. */
.weather-day-temps {
    margin-top: auto;
}

/* --- The rail's scroll hint is a mask on .cam-nav-bar, and a mask takes the
   element's own background with it: the right 28px of the bar went transparent,
   so cameras scrolled visibly through the top-right corner under a bar that is
   supposed to be opaque. Put the surface on the sticky wrapper behind it and
   the mask fades only the pills, which is what it was for. */
.sticky-top-wrapper {
    background: var(--surface);
}

/* --- With the place on its own column it wraps, and it was breaking inside the
   route number: "West Zoom . US" / "20". Let the browser avoid the orphan. */
.camera-meta-place {
    text-wrap: pretty;
}

/* =========================================================================
   Phone chrome pass, 22 Aug 2026. The Cameras tab carried 213px of permanent
   chrome on a 746px screen: 121px sticky top, 62px nav, a 30px menu notch and
   a floating back-to-top button on top of that.
   ========================================================================= */

/* --- The header and the filter rail stayed pinned for all 7,500px of the
   page, including over the footer where filtering cameras means nothing. They
   travel out of the way when the reader is going down and come straight back
   on the first upward flick, which is the only time they are wanted. */
@media (max-width: 768px) {
    .sticky-top-wrapper {
        transition: transform var(--dur-base, 200ms) var(--ease, ease);
    }

    .sticky-top-wrapper.chrome-away {
        transform: translateY(-100%);
    }
}

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

/* --- The floating back-to-top button was a permanent 44px obstruction: it
   covered the road-surface condition chip and the tail of the Contact Us
   paragraph. Tapping the bottom nav already scrolls to the top of the tab
   (effects.js runs window.scrollTo on every nav click, the active tab
   included), so on a phone the button is a second way to do a thing that is
   already one tap away, drawn over the content. */
@media (max-width: 768px) {
    .back-to-top { display: none !important; }
}

/* --- The 7-day strip scrolls sideways and clipped the fourth day mid-word
   with no hint that there was more. The filter rail's fade works here because
   .weather-daily has no background of its own: the mask reveals the panel
   behind it, not the page. */
@media (max-width: 768px) {
    .weather-daily {
        -webkit-mask-image: linear-gradient(to right, #000 0, #000 calc(100% - 28px), transparent 100%);
        mask-image: linear-gradient(to right, #000 0, #000 calc(100% - 28px), transparent 100%);
    }

    .weather-daily.scrolled-end {
        -webkit-mask-image: none;
        mask-image: none;
    }
}

/* --- Tonight has no high, so its lone temperature rendered in the muted
   .weather-day-low style while every other column led with a bold high. The
   one number a column has is that column's headline whichever it is. */
.weather-day-item:not(:has(.weather-day-high)) .weather-day-low {
    color: var(--text);
    font-weight: 800;
    font-size: 1em;
    margin-left: 0;
}

/* --- "Privacy Policy" in the footer was a 77x15 target. It sits mid-sentence,
   so grow the hit area with an overlay rather than the box: making the link
   inline-flex would drag the whole copyright line to 44px. */
.footer-copyright a {
    position: relative;
}

.footer-copyright a::after {
    content: "";
    position: absolute;
    left: 0;
    right: 0;
    top: 50%;
    height: 44px;
    transform: translateY(-50%);
}

/* The menu notch measures 96x30, which is under the 44px platform guidance but
   over WCAG 2.5.8's 24px minimum, and the whole nav bar is its pull surface
   anyway. Left alone deliberately: padding it upward would put an invisible
   96x14 strip over the cards above it, which is worse than the thing it fixes.
   Same for the notch itself, whose SHAPE is the affordance. */

/* --- A feed that its operator takes off air at night used to render as a
   broken-image glyph on a black box, because the host's player emits a poster
   URL with its own template placeholders still in it. The card says who runs
   the camera and when it is back instead. */
.feed-offline {
    /* The base container makes its 16:9 box with padding-top: 56.25%, which
       would push this content out below the box. Same ratio, real height. */
    padding-top: 0;
    aspect-ratio: 16 / 9;
    display: flex;
    flex-direction: column;
    align-items: center;
    justify-content: center;
    gap: var(--space-2);
    text-align: center;
    padding: var(--space-4);
    background: var(--bg);
    color: var(--text-muted);
}

.feed-offline svg {
    width: 28px;
    height: 28px;
    opacity: 0.6;
}

.feed-offline-title {
    margin: 0;
    font-weight: 600;
    color: var(--text);
}

.feed-offline-note {
    margin: 0;
    font-size: var(--text-sm);
    max-width: 32ch;
}

/* =========================================================================
   Camera sheet, narrow phones. 22 Aug 2026, reported from a real screen.
   ========================================================================= */

/* --- The header cost 106px of a 746px screen at 344-360px and read as an
   accident: the title on one line, the three round buttons stranded on a line
   of their own, and "East . US 20" on a third. Cause is flex line-breaking,
   which happens before shrinking: #modal-title had flex-basis auto, so it
   asked for its whole max-content width (236px of text plus a stale 40px
   padding-right left over from when the close button was absolutely
   positioned), the actions cannot shrink below their 44px targets, and
   236 + 40 + 146 does not fit in 325px. At 411px it fitted with 4px to spare,
   which is exactly why only the narrow screens showed it.

   Basis 0 stops the title bidding for max-content, so it always shares the
   line with the actions. It wraps to a second line rather than truncating: an
   ellipsis on "E Mississippi Riv..." was a defect in its own right. Measured
   after: 106px -> 71px, and the video gets those 35px. */
@media (max-width: 768px) {
    #camera-modal #modal-title {
        flex: 1 1 0;
        min-width: 0;
        padding-right: 0;
        white-space: normal;
        text-overflow: clip;
        display: -webkit-box;
        -webkit-box-orient: vertical;
        -webkit-line-clamp: 2;
        overflow: hidden;
    }

    /* The header sat at a 9.6px gutter while every row below it (detail rows,
       nearby tiles) sat at 14px, so the title hung left of everything else. */
    #camera-modal .modal-header {
        padding-left: 14px;
        padding-right: 14px;
    }

    #camera-modal #stream-conditions {
        padding-left: 14px;
        padding-right: 14px;
    }
}

/* --- The nearby rail runs 1,248px inside a 323px window and cut the second
   tile mid-word with no hint. The rail has no background of its own, so the
   mask fades the tiles into the sheet rather than punching a hole in it. */
.related-cams-rail {
    -webkit-mask-image: linear-gradient(to right, #000 0, #000 calc(100% - 24px), transparent 100%);
    mask-image: linear-gradient(to right, #000 0, #000 calc(100% - 24px), transparent 100%);
}

.related-cams-rail.scrolled-end {
    -webkit-mask-image: none;
    mask-image: none;
}

/* --- A nearby tile is 200px wide with a 56px thumbnail, leaving ~125px for a
   name that needs 150, so every one of them ended in an ellipsis. The rail
   scrolls and now says so with a fade, so buy the name the width it needs and
   let it take a second line; the distance line keeps one. */
@media (max-width: 768px) {
    #camera-modal .related-cam-tile {
        flex: 0 0 220px;
    }

    #camera-modal .related-cam-name {
        white-space: normal;
        text-overflow: clip;
        display: -webkit-box;
        -webkit-box-orient: vertical;
        -webkit-line-clamp: 2;
        overflow: hidden;
    }

    #camera-modal .related-cam-dist {
        white-space: nowrap;
        overflow: hidden;
        text-overflow: ellipsis;
    }

    /* The strip wraps to two lines on a narrow screen; it does not need a
       full line's worth of air between them. */
    #camera-modal #stream-conditions {
        row-gap: 0.2rem;
    }
}

/* --- Collapsed camera groups (2026-08-25) ---------------------------------
   A collapsed section used to render its heading and nothing else: same 55px
   row, same colour dot, same "8 cams" count, and the only thing separating it
   from an open group was an 18px chevron at 0.6 opacity pointing right instead
   of down. On a phone that reads as a group that failed to load, and since the
   whole heading is a 352x44 button, one stray thumb on the way down the page
   hid eight cameras until the reader worked out what had happened.

   Analytics from the first 1,627 sessions say the filter bar, the search box
   and the view toggle together see 7.6% of sessions, so these headings are how
   almost everyone navigates this page. The state a heading is in has to be
   legible without being decoded. */

/* Hidden by default; the collapsed state is what reveals it. */
.section-reveal {
    display: none;
}

section.collapsed .section-reveal {
    display: flex;
    align-items: center;
    justify-content: center;
    gap: var(--space-2);
    width: 100%;
    min-height: 52px;
    padding: 0 var(--space-4);
    background: var(--surface);
    border: 1px dashed var(--border-strong);
    border-radius: var(--radius-md);
    color: var(--text-muted);
    font: inherit;
    font-size: var(--text-sm);
    font-weight: 500;
    letter-spacing: 0;
    text-align: center;
    cursor: pointer;
    transition: background-color var(--dur-fast) var(--ease),
                border-color var(--dur-fast) var(--ease),
                color var(--dur-fast) var(--ease);
}

section.collapsed .section-reveal:hover {
    background: var(--surface-hover);
    border-color: var(--accent);
    color: var(--text);
}

/* Chrome leaves :hover on the last-tapped point, so a touch device would keep
   the amber border on whichever group was opened last. */
@media (hover: none) {
    section.collapsed .section-reveal:hover {
        background: var(--surface);
        border-color: var(--border-strong);
        color: var(--text-muted);
    }
}

section.collapsed .section-reveal:focus-visible {
    outline: none;
    box-shadow: var(--focus-ring);
}

.section-reveal-chevron {
    width: 16px;
    height: 16px;
    flex-shrink: 0;
    opacity: 0.8;
}

/* The heading's own chevron is quiet when open, because an open group needs no
   explanation. Closed, it is the second half of a statement the button below
   is making, so it comes up to full strength. */
section.collapsed h2 .section-chevron {
    opacity: 1;
}

/* A collapsed group is one heading and one button. Carrying the full open-group
   margin underneath left a hole the size of a missing row of cameras, which is
   exactly what it looked like. */
section.collapsed {
    margin-bottom: var(--space-5);
}

/* The empty-search state now names the catalog's limit, so it is two sentences
   rather than five words. Cap it and give the first line weight. */
.camera-search-no-results {
    max-width: 52ch;
    margin-inline: auto;
    line-height: var(--leading-normal);
    text-wrap: pretty;
}

.camera-search-no-results strong {
    display: inline-block;
    margin-bottom: var(--space-1);
    color: var(--text);
    font-weight: 600;
}

/* An open heading needs room before its grid; a collapsed one is talking to the
   button directly underneath it. */
section.collapsed h2 {
    margin-bottom: var(--space-2);
}

/* The Dodge Street cards carry no place line: the section heading already says
   where they are, and Iowa DOT's own names put "US 20" under all seven. With
   the place gone, `margin-left: auto` had nothing to push against and stranded
   the status chip against the right edge of an otherwise empty row. */
.camera-meta .cam-status:only-child {
    margin-left: 0;
}

/* --- Jump-to menu (2026-08-25) --------------------------------------------
   The row this sits in was eleven corridor filter pills. Of the first 1,630
   recorded sessions, 124 (7.6%) touched the filter bar, the search box, the map
   or the view toggle at all. The other 92% move down the page by heading, so
   the accelerator worth having takes you TO a group instead of hiding nine.

   The rail could not show what it held either: at 393px it scrolled 1,273px of
   content through a 393px window, so three of eleven destinations were on
   screen. The menu shows every one. */

.jump-btn {
    display: inline-flex;
    align-items: center;
    gap: var(--space-3);
    min-height: 44px;
    padding: 0 var(--space-3) 0 var(--space-4);
    /* Same fill, border and corner as the search field beside it. It was
       --surface over --border-strong with a shadow, which made it the one
       white, raised object in a row of flat grey controls. The pill radius
       was left over from the filter row it replaced. */
    background: var(--bg);
    border: 1px solid var(--border);
    border-radius: var(--radius-sm);
    color: var(--text);
    font: inherit;
    font-size: var(--text-sm);
    font-weight: 600;
    letter-spacing: 0;
    cursor: pointer;
    white-space: nowrap;
    transition: background-color var(--dur-fast) var(--ease),
                border-color var(--dur-fast) var(--ease);
}

.jump-btn:hover {
    background: var(--surface-hover);
    border-color: var(--border-strong);
}

/* Chrome leaves :hover on the last-tapped point, so the button would sit there
   wearing its hover state after every use. */
@media (hover: none) {
    .jump-btn:hover {
        background: var(--bg);
        border-color: var(--border);
    }
}

.jump-btn:focus-visible {
    outline: none;
    box-shadow: var(--focus-ring);
}

/* Three rules of decreasing width: a list glyph, but each rule carries one of
   the group colours this site already uses on headings, dots and map markers.
   paintJumpButtonIcon() fills them from the rendered headings, so they follow
   the theme and cannot drift from the sections they stand for. */
.jump-btn-icon {
    width: 15px;
    height: 15px;
    flex-shrink: 0;
}

.jump-btn-rule {
    fill: var(--text-muted);
    transition: fill var(--dur-base) var(--ease);
}

.jump-btn-chevron {
    width: 14px;
    height: 14px;
    flex-shrink: 0;
    color: var(--text-muted);
    transition: transform var(--dur-base) var(--ease);
}

.jump-btn[aria-expanded="true"] .jump-btn-chevron {
    transform: rotate(180deg);
}

.jump-btn[aria-expanded="true"] {
    border-color: var(--accent);
}

/* The backdrop only exists for the phone sheet. On a pointer device a popover
   with a click-outside handler needs no scrim, and a scrim over the cameras is
   exactly the sort of thing this site does not do. */
.jump-backdrop {
    display: none;
}

.jump-menu {
    position: absolute;
    top: calc(100% + 8px);
    left: 0;
    /* Inside .sticky-top-wrapper's own stacking context (z-index: 100), so
       this only has to beat its siblings. The phone sheet is a different
       story: see the relocation note in the max-width block below. */
    z-index: var(--z-overlay);
    min-width: 280px;
    max-width: min(360px, calc(100vw - 2rem));
    padding: var(--space-2);
    background: var(--surface);
    border: 1px solid var(--border-strong);
    border-radius: var(--radius-md);
    box-shadow: var(--shadow-lg);
    /* openMenu() sets max-height from the room actually below the button. */
    overflow-y: auto;
    overscroll-behavior: contain;
}

.jump-menu[hidden] {
    display: none;
}

.jump-menu-title {
    margin: 0 0 var(--space-1);
    padding: var(--space-1) var(--space-2);
    font-size: var(--text-label);
    font-weight: 600;
    letter-spacing: var(--tracking-label);
    text-transform: uppercase;
    color: var(--text-muted);
}

.jump-menu-items {
    display: flex;
    flex-direction: column;
}

.jump-item {
    display: flex;
    align-items: center;
    gap: var(--space-3);
    width: 100%;
    min-height: 44px;
    padding: 0 var(--space-2);
    background: none;
    border: 0;
    border-radius: var(--radius-sm);
    color: var(--text);
    font: inherit;
    font-size: var(--text-sm);
    text-align: left;
    cursor: pointer;
    transition: background-color var(--dur-fast) var(--ease);
}

.jump-item:hover {
    background: var(--surface-hover);
}

@media (hover: none) {
    .jump-item:hover { background: none; }
}

.jump-item:focus-visible {
    outline: none;
    box-shadow: var(--focus-ring);
}

.jump-item-dot {
    width: 8px;
    height: 8px;
    border-radius: 50%;
    flex-shrink: 0;
}

.jump-item-label {
    flex: 1 1 auto;
    min-width: 0;
    /* Two of these names are long enough to wrap at 280px. A wrapped label is
       fine; a clipped one is not, and ellipsis on a place name is worse than
       a second line. */
    text-wrap: pretty;
}

/* The count is a reading, so it goes mono and tabular like every other ticking
   number on this site. */
.jump-item-count {
    flex-shrink: 0;
    font-family: var(--font-mono);
    font-size: var(--text-xs);
    font-variant-numeric: tabular-nums;
    color: var(--text-muted);
}

/* --- Phone: a bottom sheet, matching the camera sheet and the Menu sheet ---
   A popover anchored under a button that is itself inside sliding sticky chrome
   ends up in the wrong place the moment the header translates away. */
@media (max-width: 768px) {
    .jump-backdrop {
        display: block;
        position: fixed;
        inset: 0;
        /* effects.css puts .mobile-bottom-nav at 999 and the Menu notch rides
           with it. A sheet below that reads as broken: the nav painted over
           its last two rows. A sheet is a temporary surface and covering the
           nav is what the camera sheet already does. Still under .modal's
           1000, so an open camera always wins. */
        z-index: 999;
        background: rgba(6, 10, 18, .55);
        -webkit-backdrop-filter: blur(2px);
        backdrop-filter: blur(2px);
    }

    .jump-backdrop[hidden] {
        display: none;
    }

    .jump-menu {
        position: fixed;
        top: auto;
        left: 0;
        right: 0;
        bottom: 0;
        z-index: 999;
        min-width: 0;
        max-width: none;
        max-height: 80dvh;
        overflow-y: auto;
        padding: var(--space-3) var(--space-3) calc(var(--space-5) + env(safe-area-inset-bottom));
        border: 0;
        border-top: 1px solid var(--border-strong);
        border-radius: 18px 18px 0 0;
        animation: jump-sheet-in var(--dur-base) var(--ease);
    }

    .jump-menu-title {
        font-size: var(--text-sm);
        text-transform: none;
        letter-spacing: 0;
        color: var(--text);
    }

    .jump-item {
        min-height: 48px;
        font-size: var(--text-base);
    }
}

@keyframes jump-sheet-in {
    from { transform: translateY(12px); opacity: 0; }
    to   { transform: translateY(0);    opacity: 1; }
}

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

/* --- Camera controls block (2026-08-25) -----------------------------------
   Search on its own full-width row, then the jump menu and the view picker
   facing each other underneath. The whole block scrolls away with the page.

   It used to be two things in two places: a sticky bar welded under the nav
   holding the jump button, and a separate band below it holding search and the
   view picker, with a 769px breakpoint shuttling pieces between them. That
   cost 59px of permanent chrome on a 746px phone to carry one button, and it
   gave the search field two homes and two sets of rules, one of which let it
   hang 135px past the right edge of its own scroller at 1280px. */
.camera-search-bar {
    padding: var(--space-3) 0 var(--space-5);
}

.camera-search-bar .camera-search-wrap {
    position: relative;
}

.cam-nav-row {
    display: flex;
    /* stretch, not center: the view picker is 44px of button plus its own 2px
       inset, so a centred jump button sat 8px shorter than the thing beside
       it and the row read as two unrelated controls. */
    align-items: stretch;
    justify-content: space-between;
    gap: var(--space-3);
    /* At 280px the button (137px) and the view picker (144px) cannot share a
       line inside a 256px box. Wrapping keeps both at their full 44px touch
       size rather than crushing one of them; margin-left: auto then holds the
       picker at the right on its own line, which here is the intended
       two-line layout rather than an item accidentally orphaned there. */
    flex-wrap: wrap;
    row-gap: var(--space-2);
}

.cam-nav-row #view-toggle-group {
    margin-left: auto;
}

/* The popover is anchored here, so this is what it hangs from.
   This is also where the page-nav chrome gets turned back off, and it has to
   out-specify `[data-theme="light"] nav` (0-1-1), not just the bare `nav`
   (0-0-1). At 0-1-0 the dark theme looked right and the light theme painted a
   white square with a bottom border behind the button, showing at all four
   corners of a pill. Two classes is 0-2-0 and wins in both. */
.cam-nav-row .jump-nav {
    position: relative;
    display: flex;
    padding: 0;
    background: none;
    border: 0;
    overflow: visible;
}

.cam-nav-row .jump-nav .jump-btn {
    height: 100%;
}

.cam-nav-row #view-toggle-group {
    display: inline-flex;
    flex: 0 0 auto;
    gap: 2px;
    margin: 0;
}

/* --- Desktop: the three controls share one line ---------------------------
   Stacked, they cost two rows to hold one input and two small controls, with
   most of a 1,028px row left empty beside each. On a phone the stack is right,
   because a 137px button and a 144px picker leave search nothing.

   .cam-nav-row goes display: contents so its two children become flex items of
   the band itself and can be ordered against the search field between them. It
   is a plain div with no semantics, so dissolving its box costs nothing; the
   popover still hangs off .jump-nav, which keeps its own box. */
@media (min-width: 769px) {
    #camera-search-bar {
        flex-direction: row;
        align-items: center;
    }

    .cam-nav-row {
        display: contents;
    }

    .cam-nav-row .jump-nav {
        order: 1;
        flex: 0 0 auto;
    }

    #camera-search-bar .camera-search-wrap {
        order: 2;
        flex: 1 1 auto;
        width: auto;
        min-width: 0;
    }

    .cam-nav-row #view-toggle-group {
        order: 3;
        margin-left: 0;
    }

    /* One height across the row. The picker is 3px of padding around 40px
       buttons and the field is 44px, so left alone they sat 4px apart. */
    #camera-search-bar .camera-search-wrap,
    #camera-search-bar #camera-search,
    .cam-nav-row .jump-btn,
    .cam-nav-row #view-toggle-group {
        height: 44px;
        min-height: 44px;
    }

    #camera-search-bar .view-toggle-btn {
        min-height: 36px;
        height: 36px;
    }
}

/* --- Leaving (2026-08-25) --------------------------------------------------
   The camera view had two exits and only one animated. A swipe carried the
   sheet down and off the screen; the close button, Escape, the scrim and Back
   set display:none and it was simply gone, mid-stream. Same surface, same
   intention, two endings. closeModal() adds .modal-leaving for one --dur-base
   before it hides anything, and the swipe hands over mid-gesture rather than
   running its own copy. */
#camera-modal.modal-leaving {
    background-color: transparent;
    transition: background-color var(--dur-base) var(--ease);
}

/* Desktop keeps its centre. A dialog that slides off the bottom of a 1440px
   screen is a phone gesture wearing the wrong clothes: it falls back a step
   and fades, which is the reverse of how it arrived. */
#camera-modal.modal-leaving .modal-content {
    opacity: 0;
    transform: scale(0.98);
    transition: opacity 150ms var(--ease), transform 150ms var(--ease);
}

@media (max-width: 768px) {
    /* On a phone it is a sheet, so it leaves the way it came in: downward,
       full height, no fade. Fading a sheet as it slides reads as two effects
       fighting. */
    #camera-modal.modal-leaving .modal-content {
        opacity: 1;
        transform: translateY(100%);
        transition: transform var(--dur-base) var(--ease);
    }
}

/* --- The favourite star ----------------------------------------------------
   Setting a favourite happened in 1.6% of recorded sessions. That is the tier
   where a flourish belongs, and one of the few places on this site that is not
   somebody checking a road in ten seconds. On the way in only. */
@keyframes fav-pop {
    0%   { transform: scale(1); }
    45%  { transform: scale(1.3); }
    100% { transform: scale(1); }
}

.fav-btn.fav-pop svg {
    animation: fav-pop 220ms var(--ease);
}

@media (prefers-reduced-motion: reduce) {
    .fav-btn.fav-pop svg { animation: none; }
}

/* --- The desktop jump popover ----------------------------------------------
   The phone sheet got an entry the day it was built and the popover never did:
   it appeared fully formed with no relationship to the button that opened it.
   Scaled from its own top-left corner, which is where the button is. */
@media (min-width: 769px) {
    @keyframes jump-pop {
        from { opacity: 0; transform: scale(0.96) translateY(-4px); }
        to   { opacity: 1; transform: none; }
    }

    .jump-menu {
        transform-origin: top left;
        animation: jump-pop 160ms var(--ease);
    }
}

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

/* --- Collapsing a camera group ---------------------------------------------
   `display: none` teleports eight cards and drops the rest of the page several
   hundred pixels with no bridge.

   Progressive enhancement on purpose. `interpolate-size` is what makes
   height: auto animatable without measuring anything in JavaScript, and a
   browser without it keeps exactly today's behaviour rather than getting some
   half-built approximation. This is the one animation here that moves layout
   rather than transform and opacity, which is why it is gated and why it is
   the one to re-measure if the grid ever feels heavy on a phone. */
@supports (interpolate-size: allow-keywords) {
    :root {
        interpolate-size: allow-keywords;
    }

    section .camera-grid,
    section .rwis-subtitle {
        overflow: hidden;
        /* height: auto has to be DECLARED, not merely the used value.
           interpolate-size makes the keyword animatable, but there is still
           nothing to interpolate from unless both ends are written down: with
           it left implicit the grid went 800px to 101px in one frame. */
        height: auto;
        transition: height var(--dur-base) var(--ease),
                    opacity var(--dur-base) var(--ease);
    }

    section.collapsed .camera-grid,
    section.collapsed .rwis-subtitle {
        /* Beats the display:none !important in style.css, which is what made
           the change instant in the first place. */
        display: grid !important;
        height: 0;
        opacity: 0;
        /* Delayed to the end of the transition so a collapsed group leaves the
           tab order once it has finished leaving, rather than the moment it
           starts. A discrete property only flips at the far end. */
        visibility: hidden;
        transition: height var(--dur-base) var(--ease),
                    opacity var(--dur-base) var(--ease),
                    visibility 0s linear var(--dur-base);
    }

    section.collapsed .rwis-subtitle {
        display: block !important;
    }
}

/* ==========================================================================
   TABLET PASS, 2026-08-25 (Pixel Tablet, 800x1092 portrait / 1280x612 landscape)
   --------------------------------------------------------------------------
   Every touch affordance on this site was gated on WIDTH. A tablet is a WIDE
   touch device, so it landed outside all of them: the 44px close button lives
   under `max-width: 600px`, the compact-landscape rescue under
   `max-height: 560px`, and the tablet is 612px tall in landscape. Gate on the
   POINTER instead, which is what those rules were always describing.
   ========================================================================== */

/* 1. `vh` is the LARGE viewport and includes browser UI the reader cannot see.
      Measured on the device: innerHeight 612, but 100vh = 100lvh = 708. So
      `max-height: 95vh` resolved to 672.6px inside a 612px window, and since
      .modal-content is `overflow: hidden` and .modal-body does not scroll, the
      title row was clipped off the top and "Copy link" off the bottom with no
      way to reach either. `dvh` tracks the real viewport: 95dvh = 581.4px.
      The `vh` line stays first as the fallback for anything without dvh. */
/* Scoped to 769px and up, which is where the rules being corrected live.
   Left at the top level these carried the same (1,1,0) specificity as the
   phone sheet's own `#camera-modal .modal-content { max-height: 92dvh }` at
   reskin.css:2510 and, being later in the file, quietly raised the phone
   sheet to 95dvh as well. 92 is a chosen number there: the sheet is meant to
   leave a strip of page visible behind it so the grab handle reads as a
   handle. The same applies to the plain .modal-content rule, which would
   otherwise have capped the contact and advertise dialogs on phones, where
   effects.css deliberately runs them full height. */
@media (min-width: 769px) {
    .modal-content {
        max-height: 95vh;
        max-height: 95dvh;
    }
    #camera-modal .modal-content {
        max-height: 95vh;
        max-height: 95dvh;
    }
    #modal-video-container {
        max-height: calc(95vh - 120px);
        max-height: calc(95dvh - 120px);
    }
}

/* 2. Keyboard hints on a device with no keyboard. `@media (min-width: 900px)`
      turned these on, which caught the tablet in landscape and spent 37px of an
      already-overflowing sheet telling a touch user to press Esc. Keys are a
      pointer capability, not a width. */
@media (hover: none) and (pointer: coarse) {
    .video-kbd-hints { display: none; }
}

/* 3. Touch targets in the camera sheet. These 44px rules already existed, but
      only under `max-width: 600px` and under the short-landscape query, so on
      the tablet the close button rendered 21x24 . the primary way out of a
      full-screen sheet, at a quarter of the minimum target area. */
@media (hover: none) and (pointer: coarse) {
    #camera-modal .modal-header-close,
    #camera-modal .modal-fav-btn,
    #camera-modal .report-incident-btn {
        min-width: 44px;
        min-height: 44px;
        display: inline-flex;
        align-items: center;
        justify-content: center;
    }
    #camera-modal .modal-header-close { padding: 0; }
}

/* 4. The grid missed a third column by 19px. Measured: main column 908px, gap
      13.6px, so three 300px tracks need 927.2px and the browser fell back to
      two 447px cards . a traffic thumbnail at 1.5x the size it needs, and card
      height 325px against a 424px reader band, so no row ever sat fully on
      screen. A 280px floor yields three 293.6px tracks and 238px cards.
      Gated on pointer, not width, for the same reason as the rules above, and
      because a media query keys on the VIEWPORT (1280px here) while the grid
      container is 908px . scoping this by viewport width silently missed.
      A wide touch device is a tablet in landscape, where vertical room is the
      scarce thing and rows are precious. Desktop has a fine pointer and keeps
      its 300px floor untouched; the tablet in PORTRAIT has a 752px container
      that only ever fits two tracks anyway, so it is unaffected either way. */
@media (hover: none) and (pointer: coarse) and (min-width: 1000px) {
    .camera-grid {
        grid-template-columns: repeat(auto-fill, minmax(min(280px, 100%), 1fr));
    }
}
