diff --git a/CHANGELOG.md b/CHANGELOG.md index 000f4c0f..fe489668 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,19 @@ # Changelog -## v1.2.6 — 2026-07-19 +## v1.2.7 — 2026-07-24 + +### Fixed +- **The detail modal no longer collapses to a strip of padding in Chrome and + the Home Assistant companion app.** The `.modal-card` was capped with + `max-height: calc(100vh - 48px)`, which the card build rewrites to + `calc(100cqh - 48px)`. When Home Assistant lays the card out with an + *indefinite* height (masonry / content-sized slots), Blink resolves the + container-query height `100cqh` to `0`, so the cap became `calc(0 - 48px)` → + clamped to `0` and the modal shrank to just its padding. Firefox and Safari + resolved the unit against the rendered height, so they were unaffected. The + cap is now `max-height: 100%` against the `inset:0` overlay (whose height is + always definite), which equals the old value on a normal viewport but never + collapses. Fixes #58. ### Added - **64 Australian species.** Generated kachō-e illustrations (128 renders, diff --git a/dist/habird-card.js b/dist/habird-card.js index 7f23ca87..5505d9c7 100644 --- a/dist/habird-card.js +++ b/dist/habird-card.js @@ -3,7 +3,7 @@ (function () { 'use strict'; var HABIRD_TEMPLATE = "
\n\n\n \n \n \n collage\n\n
\n
\n \n \n \n \n \n \n
\n \n
\n\n\n\n
\n
\n \n

Heard Recently

\n
\n
\n\n \n
\n
\n \n \n
\n\n \n
\n
\n
\n \n
\n \n
\n
\n\n \n
\n
\n
\n \n \n \n \n
\n
\n
\n
\n\n
\n
\n\n
\n
\n
\n \n
\n
\n \"\"\n
\n \n \n \n
\n
\n
\n

-

\n

\n
\n genus-\n rarity-\n
\n
\n
-all time
\n
-window
\n
-first heard
\n
\n

Loading description...

\n
\n
\n
\n
\n

Recordings

\n \n \n
\n \n
    \n
  1. Loading recordings...
  2. \n
\n
\n
\n wiki\n ebird\n
\n
\n
\n\n
\n
\n
\n \n

your birds

\n

The birds outside your window

\n

A tiny microphone identifies every passing bird with BirdNET-Go, built on Cornell's BirdNET. Each species shows up as an illustration in the collage, sized by how often it's been heard. Confident detections perch; uncertain ones fly past.

\n \n
\n
\n\n\n\n\n\n
\n
\n

\n
\n
\n
\n\n\n\n\n
"; -var HABIRD_CSS = " :host {\n color-scheme: light;\n /* Type is variable-driven so the card build can swap the editorial\n serif/mono pairing for the Home Assistant system font. */\n --av-font-display: ui-serif, \"Iowan Old Style\", \"Apple Garamond\", \"Bookman Old Style\", Georgia, serif;\n --av-font-mono: ui-monospace, \"SF Mono\", Menlo, monospace;\n --paper: #fcfcfb; /* near-pure white, faintest warm undertone */\n --paper-2: #f3f2ee; /* recessed track */\n --paper-3: #e8e6df; /* placeholder fill */\n --ink: #1a1612;\n --ink-2: #4a3f31;\n --ink-soft: #908576;\n --accent: #4a3f31; /* warm dark grey - matches ink-2, no longer red */\n --accent-2: #1a1612; /* near-black ink - same hue as the title text */\n --hairline: rgba(26,22,18,0.14); /* faint divider lines + chart gridlines */\n --pill: #fcfcfb; /* active-toggle sliding pill */\n /* Depth shadow recipes - replace flat 1px borders */\n --edge: inset 0 0 0 1px rgba(255,255,255,0.55), 0 0 0 1px rgba(26,22,18,0.04);\n --edge-lg: inset 0 0 0 1px rgba(255,255,255,0.6), 0 0 0 1px rgba(26,22,18,0.06), 0 8px 28px rgba(26,22,18,0.06);\n --recess: inset 0 1px 2px rgba(26,22,18,0.06), inset 0 0 0 1px rgba(26,22,18,0.04);\n --raised: inset 0 0 0 1px rgba(255,255,255,0.8), 0 0 0 1px rgba(26,22,18,0.05), 0 1px 2px rgba(26,22,18,0.07);\n }\n /* ---- Charcoal dark theme. Set via data-theme=\"dark\" on (an\n inline script in index.html applies the saved choice before paint;\n the switcher lives in Settings). The whole UI is variable-driven, so\n flipping ink<->paper inverts cleanly: text reads light, the stats\n squares become light marks on dark, the collage cutouts (transparent\n PNGs) sit on the charcoal directly. Shadow recipes are re-cut for a\n dark surface (faint light edges, deeper drop shadows). ---- */\n :host([data-theme=\"dark\"]) {\n color-scheme: dark;\n --paper: #1a1a1a; /* neutral charcoal page bg (no blue tint) */\n --paper-2: #242424; /* recessed track */\n --paper-3: #303030; /* placeholder fill */\n --ink: #ece8e1; /* warm off-white text / marks */\n --ink-2: #b7afa2; /* secondary text */\n --ink-soft: #837c70; /* muted text */\n --accent: #d8d2c6; /* light warm - pills read light-on-dark */\n --accent-2: #ece8e1;\n --hairline: rgba(236,232,225,0.20); /* faint light divider on charcoal */\n --pill: #404040; /* raised, clearly lighter than the track (neutral) */\n --edge: inset 0 0 0 1px rgba(255,255,255,0.05), 0 0 0 1px rgba(0,0,0,0.4);\n --edge-lg: inset 0 0 0 1px rgba(255,255,255,0.06), 0 0 0 1px rgba(0,0,0,0.5), 0 10px 30px rgba(0,0,0,0.5);\n --recess: inset 0 1px 2px rgba(0,0,0,0.5), inset 0 0 0 1px rgba(255,255,255,0.04);\n --raised: inset 0 0 0 1px rgba(255,255,255,0.07), 0 0 0 1px rgba(0,0,0,0.4), 0 1px 2px rgba(0,0,0,0.5);\n }\n * { box-sizing: border-box; }\n .av-shell { margin: 0; padding: 0; height: 100%; background: var(--paper); color: var(--ink); }\n .av-shell {\n font-family: var(--av-font-display);\n -webkit-font-smoothing: antialiased;\n overflow: hidden;\n }\n .mono { font-family: var(--av-font-mono); letter-spacing: 0.02em; }\n\n /* ============ Stage / views ============\n Stage is a flex column: a static title block sits on top, the views\n slider fills the remaining height. The title NEVER moves when the\n view changes - only its text fades when the new view's title differs\n from the current one. */\n .stage {\n position: absolute; inset: 0; overflow: hidden;\n display: flex; flex-direction: column;\n }\n .static-head {\n flex: 0 0 auto;\n padding: 84px 32px 22px;\n text-align: center; position: relative; z-index: 5;\n }\n .static-head .pre {\n display: inline-block;\n background: none; border: 0; padding: 0;\n font: italic 400 clamp(13px, 1.4cqw, 18px)/1 var(--av-font-display);\n color: var(--ink-2); letter-spacing: 0.06em; margin-bottom: 6px;\n cursor: pointer; transition: color 140ms ease;\n }\n .static-head .pre:hover {\n color: var(--ink);\n text-decoration: underline; text-underline-offset: 3px;\n text-decoration-thickness: 1px;\n }\n .static-head h1 {\n margin: 0; padding: 0;\n font: 700 clamp(24px, 3.2cqw, 40px)/1 var(--av-font-display);\n letter-spacing: 0.06em; color: var(--ink); text-transform: uppercase;\n font-style: normal;\n transition: opacity 240ms cubic-bezier(.7,.05,.2,1);\n }\n .static-head.swap-out h1 { opacity: 0; }\n /* Stats + Atlas hide the shared title so their content uses the full\n height (the collage keeps it). */\n body.av-view-stats .static-head, body.av-view-atlas .static-head { display: none; }\n @container av-card (max-width: 700px) {\n .static-head { padding: 96px 18px 22px; }\n }\n\n .views {\n flex: 1 1 auto; min-height: 0;\n position: relative; display: flex;\n transition: transform 480ms cubic-bezier(.7,.05,.2,1);\n }\n .view { flex: 0 0 100%; height: 100%; overflow: auto; padding: 8px 56px 132px; position: relative; }\n /* The collage view fits the viewport (no scroll). Atlas keeps normal\n scroll; stats scrolls when its grid outgrows the box (overridden at\n the .stats-grid rules below). */\n .view#v0, .view#v1 { overflow: hidden; padding: 8px 32px 88px; }\n .view#v0 { display: flex; }\n .view#v1 { display: flex; flex-direction: column; }\n @container av-card (max-width: 700px) { .view { padding: 8px 18px 132px; } .view#v0, .view#v1 { padding: 8px 16px 88px; } }\n\n /* ============ Top bar - window picker (left) + menu button (right). ============\n Visible on every view; the picker is the universal time-window control. */\n .top {\n position: absolute; top: 22px; left: 0; right: 0;\n display: flex; justify-content: space-between; align-items: center;\n padding: 0 28px; z-index: 30; pointer-events: none;\n }\n .top > * { pointer-events: auto; }\n .top .window-pick {\n display: inline-flex; gap: 0; padding: 4px;\n background: var(--paper-2); border-radius: 999px;\n box-shadow: var(--recess);\n position: relative;\n }\n .top .window-pick button {\n background: transparent; border: 0; color: var(--ink-soft);\n font: 10px/1 var(--av-font-mono); letter-spacing: 0.18em;\n padding: 9px 16px; border-radius: 999px; cursor: pointer; text-transform: uppercase;\n transition: color 200ms ease;\n position: relative; z-index: 1;\n }\n .top .window-pick button:hover { color: var(--ink); }\n .top .window-pick button[aria-current=\"true\"] { color: var(--ink); }\n .menu-btn {\n background: var(--paper); border: 0; color: var(--ink);\n font: 11px/1 var(--av-font-mono); padding: 9px 14px 8px;\n border-radius: 999px; cursor: pointer; letter-spacing: 0.18em; text-transform: lowercase;\n box-shadow: var(--raised);\n transition: transform 160ms ease;\n }\n .menu-btn:hover { transform: translateY(-1px); }\n .menu-btn:active { transform: translateY(0); }\n /* Compact top bar on mobile - the time-window pill goes from\n spacious to thumb-tight without losing legibility. */\n @container av-card (max-width: 700px) {\n .top { top: 12px; padding: 0 12px; }\n .top .window-pick { padding: 3px; }\n .top .window-pick button {\n font-size: 8.5px; padding: 6px 10px; letter-spacing: 0.12em;\n }\n .menu-btn { font-size: 9px; padding: 6px 11px 5px; letter-spacing: 0.14em; }\n .return-to-atlas { top: 10px; left: 10px; padding: 5px 10px; font-size: 8.5px; }\n }\n @container av-card (max-width: 420px) {\n .top .window-pick button { font-size: 8px; padding: 5px 7px; letter-spacing: 0.08em; }\n }\n\n /* ============ Bottom slider - recessed track, raised active pill ============ */\n .slider {\n position: absolute; bottom: 28px; left: 50%; transform: translateX(-50%);\n display: flex; gap: 0; padding: 4px; z-index: 30;\n background: var(--paper-2); border-radius: 999px;\n box-shadow: var(--recess);\n position: absolute; /* re-emit for the pill positioning */\n }\n .slider button {\n background: transparent; border: 0; color: var(--ink-soft);\n font: 10px/1 var(--av-font-mono); letter-spacing: 0.2em;\n padding: 11px 24px; border-radius: 999px; cursor: pointer; text-transform: uppercase;\n transition: color 200ms ease;\n position: relative; z-index: 1;\n }\n .slider button:hover { color: var(--ink); }\n .slider button[aria-current=\"true\"] { color: var(--ink); }\n\n /* Shared sliding pill - JS animates transform and width to the\n active button's position. The pill is the only element with the\n raised-paper look; buttons just toggle their text colour. */\n .seg-pill {\n position: absolute; top: 4px; bottom: 4px; left: 0;\n background: var(--pill); border-radius: 999px;\n box-shadow: var(--raised);\n transition: transform 320ms cubic-bezier(.7,.05,.2,1), width 320ms cubic-bezier(.7,.05,.2,1);\n will-change: transform, width;\n pointer-events: none; z-index: 0;\n }\n\n /* ============ Menu dropdown ============ */\n #menu-dd {\n position: absolute; top: 58px; right: 28px; z-index: 25;\n width: 360px; max-width: calc(100cqw - 24px);\n max-height: calc(100cqh - 80px); overflow: auto;\n background: var(--paper); border-radius: 12px;\n box-shadow: var(--edge-lg);\n padding: 14px; opacity: 0; transform: translateY(-6px);\n pointer-events: none; transition: opacity 200ms ease, transform 200ms ease;\n }\n #menu-dd.open { opacity: 1; transform: translateY(0); pointer-events: auto; }\n\n .lock-row {\n display: flex; gap: 6px; align-items: stretch;\n background: var(--paper-2); border-radius: 8px;\n box-shadow: var(--recess);\n padding: 4px;\n }\n .lock-row input {\n flex: 1; background: transparent; border: 0; outline: 0;\n color: var(--ink); padding: 8px 10px;\n font: 13px var(--av-font-mono);\n }\n .lock-row input::placeholder { color: var(--ink-soft); }\n .lock-row button {\n background: var(--paper); border: 0; color: var(--ink);\n width: 32px; padding: 0; border-radius: 6px; cursor: pointer;\n font: 14px/1 var(--av-font-mono);\n box-shadow: var(--raised);\n display: flex; align-items: center; justify-content: center;\n }\n .lock-row button:hover { color: var(--accent); }\n .lock-hint {\n margin: 8px 4px 0; font: 10px var(--av-font-mono);\n color: var(--ink-soft); letter-spacing: 0.04em;\n }\n .lock-err { color: var(--accent); }\n /* Tiny \"built by teddy\" attribution - appears under the lock-screen\n password field AND at the foot of the about-card. Same monospace\n micro-type used by .lock-hint so the two contexts read consistent. */\n .built-by {\n margin: 14px 4px 0;\n font: 10px var(--av-font-mono);\n color: var(--ink-soft);\n letter-spacing: 0.04em;\n }\n .built-by a {\n color: inherit;\n text-decoration: underline;\n text-underline-offset: 2px;\n }\n .built-by a:hover { color: var(--ink); }\n .about-card .built-by { margin-top: 18px; }\n\n .menu-items { padding: 4px 4px; display: none; }\n .menu-items.show { display: block; }\n .menu-items h3 {\n font: 9px/1 var(--av-font-mono); letter-spacing: 0.24em;\n color: var(--ink-soft); text-transform: uppercase; margin: 12px 4px 4px;\n }\n .menu-items a {\n display: block; padding: 9px 8px; color: var(--ink); text-decoration: none;\n font: 14px var(--av-font-display); border-radius: 6px;\n transition: background 120ms ease;\n }\n .menu-items a:hover { background: var(--paper-2); color: var(--accent); }\n\n /* ============ Settings cards inside the drawer ============\n Form controls match the time-picker's design language: recessed\n track + raised paper element. Toggles, sliders, and segmented\n pickers share the same depth-shadow recipe. */\n .menu-section {\n margin-top: 14px; padding-top: 12px;\n border-top: 1px solid rgba(26,22,18,0.07);\n }\n .menu-section:first-of-type { margin-top: 6px; border-top: 0; padding-top: 0; }\n .menu-section h3 {\n font: 700 9px var(--av-font-mono); letter-spacing: 0.20em;\n color: var(--ink); text-transform: uppercase;\n margin: 0 0 8px 4px;\n }\n /* Collapsible section - header is a button row with caret on the right.\n Default state is collapsed so accidental clicks don't touch settings. */\n .menu-section.collapsible > .section-header {\n display: flex; align-items: center; justify-content: space-between;\n cursor: pointer; user-select: none;\n padding: 4px 6px 6px 0;\n margin: 0 0 4px 0;\n }\n .menu-section.collapsible > .section-header h3 { margin: 0; }\n .menu-section.collapsible > .section-header .caret {\n width: 10px; height: 10px; transition: transform 200ms cubic-bezier(.7,.05,.2,1);\n color: var(--ink-soft);\n }\n .menu-section.collapsible[data-open=\"true\"] > .section-header .caret { transform: rotate(90deg); }\n .menu-section.collapsible > .section-body {\n max-height: 0; overflow: hidden;\n transition: max-height 280ms cubic-bezier(.7,.05,.2,1);\n }\n .menu-section.collapsible[data-open=\"true\"] > .section-body {\n max-height: 800px;\n }\n .menu-row {\n display: grid; grid-template-columns: 1fr auto; gap: 12px;\n align-items: center;\n padding: 8px 6px 8px 10px;\n border-radius: 6px;\n }\n .menu-row + .menu-row { box-shadow: inset 0 1px 0 rgba(26,22,18,0.06); }\n .menu-row .label {\n font: 12.5px var(--av-font-display); color: var(--ink);\n }\n .hint {\n display: block;\n font: 9px var(--av-font-mono);\n color: var(--ink-soft); letter-spacing: 0.04em; margin-top: 1px;\n }\n\n /* Toggle switch (raised pill that slides) */\n .switch {\n position: relative; width: 38px; height: 20px;\n background: var(--paper-2); border-radius: 999px;\n box-shadow: var(--recess);\n cursor: pointer;\n border: 0; padding: 0;\n }\n .switch::after {\n content: ''; position: absolute; top: 2px; left: 2px;\n width: 16px; height: 16px;\n background: var(--pill); border-radius: 999px;\n box-shadow: var(--raised);\n transition: transform 220ms cubic-bezier(.7,.05,.2,1), background 200ms ease;\n }\n .switch[aria-checked=\"true\"] {\n background: var(--ink-2);\n }\n .switch[aria-checked=\"true\"]::after {\n transform: translateX(18px);\n background: var(--pill);\n }\n\n /* Slider - title row (label + value badge with breathing room) and\n a track row underneath. Value sits at the far right with a wide\n gap so it never crowds the label text. */\n .slider-row {\n display: block;\n padding: 8px 6px 10px 10px;\n }\n .slider-row .head {\n display: flex; align-items: baseline; gap: 24px;\n margin-bottom: 6px;\n }\n .slider-row .head .label-block { flex: 1; min-width: 0; }\n .slider-row .label {\n font: 12.5px var(--av-font-display); color: var(--ink);\n }\n .slider-row .value {\n flex: 0 0 auto;\n font: 11px var(--av-font-mono);\n color: var(--ink); font-variant-numeric: tabular-nums;\n background: var(--paper-2); padding: 3px 10px; border-radius: 4px;\n box-shadow: var(--recess);\n min-width: 48px; text-align: center;\n }\n .slider-row .slider-track {\n margin-top: 2px;\n }\n .slider-track input[type=\"range\"] {\n -webkit-appearance: none; appearance: none;\n width: 100%; height: 18px; background: transparent; cursor: pointer;\n }\n .slider-track input[type=\"range\"]::-webkit-slider-runnable-track {\n height: 4px; background: var(--paper-2); border-radius: 999px;\n box-shadow: var(--recess);\n }\n .slider-track input[type=\"range\"]::-webkit-slider-thumb {\n -webkit-appearance: none; appearance: none;\n width: 16px; height: 16px; margin-top: -6px;\n background: var(--paper); border-radius: 999px;\n box-shadow: var(--raised);\n cursor: pointer;\n }\n .slider-track input[type=\"range\"]::-moz-range-track {\n height: 4px; background: var(--paper-2); border-radius: 999px;\n box-shadow: var(--recess);\n }\n .slider-track input[type=\"range\"]::-moz-range-thumb {\n width: 16px; height: 16px;\n background: var(--paper); border-radius: 999px;\n box-shadow: var(--raised); border: 0;\n cursor: pointer;\n }\n\n /* Segmented picker (same pill-on-recess pattern as the time picker) */\n .seg {\n display: inline-flex; padding: 4px;\n background: var(--paper-2); border-radius: 999px;\n box-shadow: var(--recess);\n position: relative;\n }\n .seg button {\n background: transparent; border: 0; color: var(--ink-soft);\n font: 9.5px var(--av-font-mono);\n letter-spacing: 0.16em; text-transform: uppercase;\n padding: 6px 12px; border-radius: 999px; cursor: pointer;\n position: relative; z-index: 1;\n transition: color 200ms ease;\n }\n .seg button[aria-current=\"true\"] {\n color: var(--ink);\n background: var(--pill);\n box-shadow: var(--raised);\n }\n\n /* Inline live-audio player - sits at the top of the unlocked drawer.\n A single play/stop button with a quiet running-indicator dot. */\n .live-audio {\n display: flex; align-items: center; gap: 12px;\n padding: 10px 12px; margin: 0 0 6px;\n background: var(--paper-2); border-radius: 8px;\n box-shadow: var(--recess);\n }\n .live-audio .pulse {\n width: 8px; height: 8px; border-radius: 50%;\n background: var(--ink-soft); flex-shrink: 0;\n transition: background 200ms ease;\n }\n .live-audio[data-on=\"true\"] .pulse {\n background: #c44a3b;\n animation: pulsate 1.6s ease-in-out infinite;\n }\n @keyframes pulsate {\n 0%, 100% { opacity: 0.4; transform: scale(1); }\n 50% { opacity: 1; transform: scale(1.25); }\n }\n .live-audio .label {\n flex: 1; font: 12.5px var(--av-font-display); color: var(--ink);\n }\n .live-audio .label .hint {\n font: 9px var(--av-font-mono); color: var(--ink-soft);\n letter-spacing: 0.04em;\n }\n .live-audio button {\n background: var(--paper); border: 0; color: var(--ink);\n padding: 6px 12px; border-radius: 4px;\n font: 9.5px var(--av-font-mono);\n letter-spacing: 0.14em; text-transform: uppercase; cursor: pointer;\n box-shadow: var(--raised);\n display: inline-flex; align-items: center; gap: 6px;\n }\n .live-audio button svg { width: 9px; height: 9px; }\n .live-audio button + button { margin-left: 4px; }\n /* Live spectrogram - hidden until the user clicks LISTEN. We\n collapse via max-height (not display:none) so the canvas inside\n keeps its layout dimensions and paints correctly on expand. */\n .live-spectro {\n width: 100%; height: 0;\n background: var(--paper-2); border-radius: 6px;\n margin: 0; overflow: hidden;\n box-shadow: var(--recess);\n transition: height 240ms cubic-bezier(.7,.05,.2,1), margin 240ms ease;\n }\n .live-audio[data-on=\"true\"] + .live-spectro {\n height: 70px; margin: 0 0 8px;\n }\n .live-status {\n font: 9.5px var(--av-font-mono); color: var(--ink-soft);\n letter-spacing: 0.04em; text-transform: lowercase;\n margin: -4px 0 6px 0; padding: 0 4px;\n min-height: 13px;\n }\n .live-status.err { color: #a83a2d; }\n .menu-links a .l-hint {\n display: block; font: 8.5px var(--av-font-display); text-transform: none;\n letter-spacing: 0; color: var(--ink-soft); margin-top: 2px;\n }\n .menu-links a.full { grid-column: 1 / -1; }\n\n /* External BirdNET pages - small grid of cards */\n .menu-links {\n display: grid; grid-template-columns: 1fr 1fr; gap: 6px;\n margin: 4px 0 2px;\n }\n .menu-links a {\n display: flex; align-items: center; justify-content: center;\n padding: 9px 10px;\n background: var(--paper-2);\n color: var(--ink); text-decoration: none;\n font: 10.5px var(--av-font-mono);\n letter-spacing: 0.10em; text-transform: lowercase;\n border-radius: 6px;\n box-shadow: var(--raised);\n transition: transform 160ms ease, color 160ms ease;\n }\n .menu-links a:hover { transform: translateY(-1px); color: var(--ink); background: var(--paper); }\n .menu-save-row {\n display: flex; gap: 8px; align-items: center; justify-content: flex-end;\n margin-top: 10px; padding: 0 4px;\n }\n .menu-save-row .save-state {\n flex: 1; font: 9px var(--av-font-mono);\n color: var(--ink-soft); letter-spacing: 0.04em;\n }\n .menu-save-row .save-state.ok { color: #2d5d3a; }\n .menu-save-row .save-state.err { color: var(--accent); }\n .menu-save-row button {\n background: var(--ink); color: var(--paper); border: 0;\n padding: 7px 14px; border-radius: 6px;\n font: 10px var(--av-font-mono); letter-spacing: 0.14em;\n text-transform: uppercase; cursor: pointer;\n box-shadow: var(--raised);\n }\n .menu-save-row button:disabled { opacity: 0.4; cursor: default; }\n\n /* ============ View 1 - Collage (Galliformes-style poster) ============\n Viewport-fit, no scroll. The shared static-head provides the title;\n this view's content is just the cluster of bird cutouts which fills\n the remaining viewport height. */\n .gcollage {\n /* No max-width: the packing area spans the full viewport so the flock\n can use the whole screen on wide laptops/monitors. */\n flex: 1 1 auto; width: 100%; margin: 0 auto;\n position: relative; min-height: 0;\n }\n /* Centre-out bloom: each tile fades + scales in; JS staggers the\n animation-delay by distance from the cluster centre. backwards\n fill-mode holds the hidden start state through the delay, and we keep\n no forwards transform so the hover scale still works afterward. */\n @keyframes gtile-in {\n from { opacity: 0; transform: scale(0.82); }\n to { opacity: 1; transform: scale(1); }\n }\n .gtile.entering {\n animation: gtile-in 420ms cubic-bezier(.2,.7,.3,1) backwards;\n }\n @media (prefers-reduced-motion: reduce) {\n .gtile.entering { animation: none; }\n }\n /* Atlas: cards rise + fade in, row by row (JS staggers by row). */\n @keyframes bird-card-in {\n from { opacity: 0; transform: translateY(16px) scale(0.96); }\n to { opacity: 1; transform: none; }\n }\n .bird-card.entering { animation: bird-card-in 480ms cubic-bezier(.2,.7,.3,1) backwards; }\n /* Stats: the heatmap populates top-to-bottom - the hour header leads,\n species rows stagger down the list; the side panel fades in just\n behind. Opacity only (sticky cells reposition themselves, so\n animating transform here would fight their layout). */\n @keyframes stats-fade-in { from { opacity: 0; } to { opacity: 1; } }\n .stats-hm-head.entering, .stats-hm-row.entering,\n .stats-side h3.entering, .stats-side small.entering, .stats-side li.entering {\n animation: stats-fade-in 340ms ease backwards;\n }\n @media (prefers-reduced-motion: reduce) {\n .bird-card.entering, .stats-hm-head.entering, .stats-hm-row.entering,\n .stats-side h3.entering, .stats-side small.entering, .stats-side li.entering { animation: none; }\n }\n .gtile {\n position: absolute;\n background: transparent; border: 0; padding: 0; margin: 0;\n overflow: visible; /* let drop-shadow extend past the bbox */\n /* transform stays quick for hover; the layout box glides so a\n repack (new bird, count change, resize) reads as the flock\n settling rather than a redraw. */\n transition: transform 130ms ease,\n left 760ms cubic-bezier(.3, 0, .2, 1),\n top 760ms cubic-bezier(.3, 0, .2, 1),\n width 760ms cubic-bezier(.3, 0, .2, 1),\n height 760ms cubic-bezier(.3, 0, .2, 1);\n /* Events are handled at the .collage level via alpha-mask\n hit-testing - the tile rectangles must not intercept them. */\n pointer-events: none;\n }\n /* Hover state is driven by JS alpha-mask hit-testing (.is-hover),\n NOT the CSS :hover pseudo - the rectangular tile bounding boxes\n overlap, so :hover would light up the wrong bird. .is-hover only\n ever sits on the one tile whose silhouette is actually under the\n cursor. */\n .gtile.is-hover { transform: scale(1.04); z-index: 3; }\n /* A bird leaving the window fades + shrinks away; renderCollage\n removes the node when the exit finishes. */\n .gtile.leaving {\n opacity: 0; transform: scale(.85);\n transition: opacity 520ms ease, transform 520ms ease;\n }\n .gtile img {\n width: 100%; height: 100%; object-fit: contain; display: block;\n /* Cutouts arrive as alpha-bbox-cropped transparent-bg PNGs, so\n object-fit: contain leaves no whitespace inside the tile - the\n bird touches all four sides. Soft drop shadow keeps it readable\n on paper. */\n filter: drop-shadow(0 2px 6px rgba(26,22,18,0.10));\n transition: filter 130ms ease;\n }\n .gtile.is-hover img {\n filter: drop-shadow(0 3px 10px rgba(26,22,18,0.26)) saturate(1.1);\n }\n /* Hover pill - surfaces the windowed count for the bird under the\n cursor. Confirms (Anna's-hummingbird-is-#1 paranoia) that the\n collage scaling reflects the selected window, not all-time. */\n /* JS anchors the pill to the hovered bird (left/top set per-hover) and\n keeps it clear of the bottom switcher, so it no longer hides behind\n it. translateX(-50%) centres it on the supplied point; z-index sits\n above the slider (30) as a backstop for any residual overlap. */\n .collage-tip {\n position: absolute; left: 0; top: 0;\n transform: translate(0, 0) translateX(-50%);\n padding: 6px 14px;\n background: var(--paper);\n border-radius: 999px;\n box-shadow: 0 6px 18px rgba(26,22,18,0.12),\n inset 0 0 0 1px rgba(255,255,255,0.6);\n font: italic 13px/1.3 var(--av-font-display);\n color: var(--ink);\n letter-spacing: 0.02em;\n opacity: 0; pointer-events: none;\n transition: opacity 150ms ease,\n transform 200ms cubic-bezier(.3, 0, .2, 1);\n white-space: nowrap;\n z-index: 31;\n }\n .collage-tip[aria-hidden=\"false\"] { opacity: 1; }\n .collage-tip .ct-name { font-style: normal; font-weight: 600; }\n .collage-tip .ct-n { font-style: normal; font-weight: 700; }\n .collage-tip .ct-w { color: var(--ink-soft); }\n\n .empty {\n color: var(--ink-soft); font: 10px var(--av-font-mono);\n padding: 120px 0; text-align: center; letter-spacing: 0.18em;\n text-transform: uppercase;\n }\n\n /* Page header is now shared at .stage > .static-head - see top of CSS. */\n /* Stats grid: the activity heatmap on the LEFT, text sections on the\n RIGHT. Each text section is a list with subtle separators. The\n heatmap scrolls within its panel; the view itself can also scroll\n if the whole grid outgrows the viewport. */\n .stats-grid {\n flex: 1 1 auto; min-height: 0;\n display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 48px;\n max-width: 1400px; margin: 0 auto; width: 100%;\n align-items: stretch;\n }\n /* Anything that doesn't fit (short desktop window, stacked mobile\n layout) scrolls instead of clipping. */\n .view#v1 { overflow-y: auto; overflow-x: hidden; }\n @container av-card (max-width: 900px) {\n /* Stacked single column: take the content's natural (tall) height instead\n of shrinking to the view (flex: 1 1 auto + min-height: 0 would let the\n grid collapse, clipping the heatmap), so #v1 actually scrolls on phones\n and portrait displays. */\n .stats-grid { grid-template-columns: 1fr; gap: 40px; flex: 0 0 auto; min-height: 0; }\n /* Stacked order: tables first, the heatmap underneath, and a buffer\n below it so its bottom row scrolls clear of the floating view\n switcher. */\n .stats-side { order: 1; }\n /* Full-height heatmap (no inner scroll) so the whole view is ONE scroll on\n a phone, not a nested one. Selector outranks the base max-height rule\n that follows it in source order. */\n .view#v1 .stats-timeline { order: 2; margin-bottom: 64px; max-height: none; }\n }\n /* Desktop two-column: size the grid to the side panel's content height\n (the heatmap pane stretches to match it, scrolling internally) and\n float it in the centre of the view. */\n @container av-card (min-width: 901px) {\n .stats-grid { flex: 0 1 auto; margin: auto; }\n }\n\n /* Activity heatmap: one row per species (most-heard first), one cell\n per hour of day, BirdNET-Go dashboard style. Cell tint deepens with\n detection count - mixed from paper toward ink so both themes work -\n and the count prints inside. The hour header sticks to the top of\n the scroll; species names stick to the left edge when the plot\n scrolls sideways on phones. */\n .stats-timeline {\n position: relative;\n flex: 1 1 auto; min-height: 120px;\n max-height: min(88cqh, 1600px);\n overflow: auto; -webkit-overflow-scrolling: touch;\n }\n .stats-hm {\n --hm-name: 200px;\n display: flex; flex-direction: column;\n min-width: 100%;\n }\n .stats-hm-head, .stats-hm-row {\n display: grid;\n grid-template-columns: var(--hm-name) repeat(24, minmax(22px, 1fr)) 48px;\n gap: 3px;\n padding: 1px 0;\n }\n .stats-hm-head {\n position: sticky; top: 0; z-index: 3;\n background: var(--paper);\n }\n .stats-hm-hr {\n font: 10px/1 var(--av-font-mono); color: var(--ink-soft);\n letter-spacing: 0.04em;\n text-align: center; padding: 3px 0 4px;\n }\n .stats-hm-name {\n position: sticky; left: 0; z-index: 2;\n background: var(--paper);\n display: flex; align-items: center;\n padding-right: 10px; overflow: hidden;\n transition: background 140ms ease;\n }\n .stats-hm-name .com {\n font: 600 14px/1.2 var(--av-font-display); color: var(--ink);\n white-space: nowrap; overflow: hidden; text-overflow: ellipsis;\n transition: color 130ms ease;\n }\n .stats-hm-cell {\n display: flex; align-items: center; justify-content: center;\n min-height: 26px;\n border-radius: 2px;\n background: var(--paper-2);\n font: 11px/1 var(--av-font-mono); color: var(--ink);\n font-variant-numeric: tabular-nums;\n overflow: hidden;\n }\n /* Hours outside the current sub-day window get no tray tint, so the\n window's extent reads as the band of filled trays. */\n .stats-hm-cell.off { background: transparent; box-shadow: inset 0 0 0 1px var(--paper-2); }\n /* Five count steps (log scale, set in renderActivity), paper -> ink. */\n .stats-hm-cell.l1 { background: color-mix(in srgb, var(--ink) 12%, var(--paper)); }\n .stats-hm-cell.l2 { background: color-mix(in srgb, var(--ink) 26%, var(--paper)); }\n .stats-hm-cell.l3 { background: color-mix(in srgb, var(--ink) 45%, var(--paper)); color: var(--paper); }\n .stats-hm-cell.l4 { background: color-mix(in srgb, var(--ink) 68%, var(--paper)); color: var(--paper); }\n .stats-hm-cell.l5 { background: var(--ink); color: var(--paper); }\n .stats-hm-tot {\n display: flex; align-items: center; justify-content: flex-end;\n padding-right: 2px;\n font: 12px/1 var(--av-font-mono); color: var(--ink-soft);\n font-variant-numeric: tabular-nums;\n }\n .stats-hm-head .stats-hm-tot {\n align-items: flex-start; padding-top: 3px;\n font-size: 9.5px; letter-spacing: 0.04em;\n }\n /* Cross-highlight: hovering a heatmap row or a table row lights up the\n matching species on the other side - the name shifts to the accent\n ink. Click opens the species' detail modal (data-sci delegation). */\n .stats-hm-row { cursor: pointer; }\n .stats-hm-row:hover .stats-hm-name,\n .stats-hm-row.sync-hi .stats-hm-name { background: var(--paper-2); }\n .stats-hm-row:hover .com,\n .stats-hm-row.sync-hi .com { color: var(--accent); }\n .stats-hm-empty {\n position: absolute; inset: 0;\n display: flex; align-items: center; justify-content: center;\n font: 11px var(--av-font-mono); color: var(--ink-soft);\n text-transform: lowercase; letter-spacing: 0.18em;\n }\n /* Caption under the matrix: what the columns cover (rolling window vs\n last-7-days aggregate) and whether the rows were trimmed. Sticky so\n it doesn't wander off when the plot scrolls sideways. */\n .stats-hm-cap {\n position: sticky; left: 0;\n padding: 8px 0 2px;\n font: 8.5px/1 var(--av-font-mono); color: var(--ink-soft);\n letter-spacing: 0.04em;\n }\n /* Phones: fixed-width cells -> the plot grows past the viewport and\n scrolls horizontally under the sticky name column. */\n @container av-card (max-width: 700px) {\n .stats-hm { --hm-name: 104px; min-width: max-content; }\n .stats-hm-head, .stats-hm-row {\n grid-template-columns: var(--hm-name) repeat(24, 26px) 40px;\n }\n }\n\n /* (Old chart-card / .stats-charts CSS removed - replaced by the\n activity heatmap above.) */\n\n .stats-side {\n display: flex; flex-direction: column; justify-content: center;\n align-self: stretch; max-height: 100%;\n padding: 6px 0;\n }\n .stats-side h3 {\n font: 700 11.5px var(--av-font-mono); color: var(--ink);\n letter-spacing: 0.18em; text-transform: uppercase; margin: 0 0 1px;\n border-left: 2px solid var(--accent); padding-left: 10px;\n }\n /* Tight, content-sized groups - they sit just below their headers\n rather than spreading to fill the column. */\n .stats-side .grp { flex: 0 0 auto; }\n .stats-side .grp + .grp { margin-top: 22px; }\n .stats-side .grp small {\n display: block; font: 10px var(--av-font-mono); color: var(--ink-soft);\n letter-spacing: 0.06em; margin: 0 0 3px 12px;\n }\n /* The whole list is one grid so counts share an aligned column, sized\n to the longest name (max-content) so that column sits right beside\n the names instead of stretching to a far edge. Each row is a subgrid\n so it still gets its own hover background + separator. */\n .stats-side ol {\n list-style: none; padding: 0; margin: 0;\n display: grid; grid-template-columns: 40px max-content 1fr;\n column-gap: 14px; align-content: start;\n }\n .stats-side li {\n display: grid; grid-template-columns: subgrid; grid-column: 1 / -1;\n align-items: baseline; padding: 3px 0;\n box-shadow: inset 0 -1px 0 rgba(26,22,18,0.05);\n font: 14px/1.3 var(--av-font-display); color: var(--ink);\n transition: background 140ms ease, color 140ms ease;\n }\n .stats-side li[data-sci] { cursor: pointer; }\n .stats-side li[data-sci]:hover { background: var(--paper-2); }\n /* Cross-highlight from a hovered timeline square. */\n .stats-side li.sync-hi { background: var(--paper-2); }\n .stats-side li.sync-hi span:not(.yr):not(.ct) { color: var(--accent); }\n .stats-side li .yr { font: 11px var(--av-font-mono); color: var(--accent-2); letter-spacing: 0.04em; }\n .stats-side li .ct { font: 13px var(--av-font-mono); color: var(--ink-soft); font-variant-numeric: tabular-nums; justify-self: end; }\n\n /* (Histogram strip removed - charts moved into the stats-grid LEFT\n column as full-bleed chart cards. See .stats-charts above.) */\n\n /* ============ View 3 - Atlas ============\n Field-guide grid: one card per species, each card holds the cutout,\n names, window/all-time detection counts, and small action chips for\n audio playback, Wikipedia, and eBird. */\n .atlas-controls {\n max-width: 1280px; margin: 0 auto 18px;\n display: flex; justify-content: flex-end; align-items: center;\n }\n .atlas-sort {\n display: inline-flex; padding: 3px; position: relative;\n background: var(--paper-2); border-radius: 999px;\n box-shadow: var(--recess);\n }\n .atlas-sort button {\n background: transparent; border: 0; color: var(--ink-soft);\n width: 30px; height: 26px; padding: 0; border-radius: 999px;\n cursor: pointer; position: relative; z-index: 1;\n display: inline-flex; align-items: center; justify-content: center;\n transition: color 200ms ease;\n }\n .atlas-sort button svg { width: 13px; height: 13px; display: block; }\n .atlas-sort button:hover { color: var(--ink); }\n .atlas-sort button[aria-current=\"true\"] { color: var(--ink); }\n .atlas-sort button .tip {\n position: absolute; top: calc(100% + 6px); left: 50%;\n transform: translateX(-50%); white-space: nowrap;\n font: 9px var(--av-font-mono); letter-spacing: 0.14em;\n text-transform: lowercase; color: var(--ink-soft);\n background: var(--paper); padding: 3px 8px; border-radius: 4px;\n box-shadow: var(--raised);\n opacity: 0; pointer-events: none; transition: opacity 160ms ease;\n }\n .atlas-sort button:hover .tip { opacity: 1; }\n .atlas-grid {\n display: grid;\n grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));\n gap: 22px;\n max-width: 1280px; margin: 0 auto;\n }\n /* Mobile atlas: a compact 2-up grid instead of one giant card per row,\n so you can scan many birds and tap easily. Inline actions are hidden -\n tapping the card opens the detail modal, which has play/wiki/ebird +\n recordings - making the whole card one big touch target. */\n @container av-card (max-width: 700px) {\n /* minmax(0,1fr) lets the columns shrink below their content's intrinsic\n width (otherwise the chips force overflow). Selectors are scoped under\n .atlas-grid so they out-specify the base .bird-card rules that appear\n later in the file. */\n .atlas-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 12px; }\n .atlas-grid .bird-card { min-height: 0; padding: 12px 12px 14px; border-radius: 9px; }\n .atlas-grid .bird-card .stat { top: 10px; left: 11px; font-size: 8px; }\n .atlas-grid .bird-card .stat .n { font-size: 11px; }\n .atlas-grid .bird-card .img-wrap { margin: 30px 0 8px; }\n .atlas-grid .bird-card .img-wrap img { max-height: 96px; }\n .atlas-grid .bird-card h3 { font-size: 12.5px; }\n .atlas-grid .bird-card .sci { font-size: 9.5px; }\n .atlas-grid .bird-card .actions { display: none; }\n .atlas-grid .lifer-badge { top: 9px; right: 9px; font-size: 7px; padding: 3px 5px; letter-spacing: 0.1em; }\n }\n .bird-card {\n position: relative;\n background: var(--paper);\n border-radius: 10px;\n padding: 16px 16px 12px;\n box-shadow: var(--edge-lg);\n display: flex; flex-direction: column;\n min-height: 320px;\n cursor: pointer;\n transition: transform 200ms ease, box-shadow 200ms ease;\n }\n .bird-card:hover { transform: translateY(-1px); box-shadow: var(--edge-xl, 0 6px 16px rgba(26,22,18,0.12)); }\n .bird-card .actions, .bird-card .spectro-wrap { cursor: auto; }\n .bird-card .actions button, .bird-card .actions a { cursor: pointer; }\n .bird-card .stat {\n position: absolute; top: 14px; left: 16px;\n font: 9px/1.5 var(--av-font-mono);\n color: var(--ink-soft); letter-spacing: 0.04em;\n }\n /* \"lifer\" badge - shown top-right when a species' first-ever detection\n falls inside the selected window (set in renderAtlas). pointer-events\n off so it never blocks the card's click-to-open. */\n .lifer-badge {\n position: absolute; top: 12px; right: 12px; z-index: 2;\n font: 700 8px/1 var(--av-font-mono);\n letter-spacing: 0.14em; text-transform: uppercase;\n color: var(--paper); background: var(--accent);\n padding: 4px 7px; border-radius: 999px;\n box-shadow: 0 2px 6px rgba(26,22,18,0.18);\n pointer-events: none;\n }\n .bird-card .stat .n {\n color: var(--ink); font: 700 13px/1 var(--av-font-display);\n font-variant-numeric: tabular-nums; margin-right: 4px;\n letter-spacing: 0;\n }\n .bird-card .stat .lbl { display: block; }\n .bird-card .img-wrap {\n flex: 1 1 auto; min-height: 0;\n display: flex; align-items: center; justify-content: center;\n margin: 38px 0 10px;\n padding: 4px;\n }\n .bird-card .img-wrap img {\n max-width: 100%; max-height: 160px; display: block;\n filter: drop-shadow(0 2px 6px rgba(26,22,18,0.10));\n }\n .bird-card h3 {\n margin: 0;\n font: 700 14.5px/1.15 var(--av-font-display);\n color: var(--ink); letter-spacing: 0.01em;\n }\n .bird-card .sci {\n font: italic 11px/1.2 var(--av-font-display);\n color: var(--ink-2); margin: 2px 0 0;\n }\n .bird-card .actions {\n display: flex; gap: 6px; align-items: stretch;\n margin-top: 12px; padding-top: 10px;\n box-shadow: inset 0 1px 0 rgba(26,22,18,0.07);\n }\n .bird-card .chip {\n flex: 0 0 auto;\n background: var(--paper-2);\n border: 0; color: var(--ink);\n padding: 5px 9px;\n border-radius: 4px;\n font: 9.5px/1 var(--av-font-mono);\n text-transform: lowercase; letter-spacing: 0.10em;\n text-decoration: none;\n cursor: pointer;\n display: inline-flex; align-items: center; gap: 5px;\n box-shadow: var(--raised);\n transition: transform 160ms ease, color 160ms ease;\n white-space: nowrap;\n }\n .bird-card .chip:hover {\n color: var(--ink); transform: translateY(-1px);\n }\n .bird-card .chip[data-active=\"true\"] {\n color: var(--paper);\n background: var(--ink);\n }\n .bird-card .chip svg { width: 10px; height: 10px; display: block; }\n .bird-card .chip.play { padding-left: 7px; }\n .bird-card .chip.ext::after { content: '↗'; opacity: 0.6; font-size: 11px; }\n\n /* Selected card highlight - matches the page-slider and time-window\n pill aesthetic: the active element is a slightly *brighter* paper\n surface (raised) on top of a slightly *recessed* background.\n No accent colour, no glowing ring - just the same depth shadow\n recipe the rest of the chrome uses. */\n .bird-card { transition: transform 280ms cubic-bezier(.7,.05,.2,1), box-shadow 280ms ease, background 200ms ease; }\n .bird-card[data-active=\"true\"] {\n background: var(--paper);\n box-shadow:\n inset 0 0 0 1px rgba(255,255,255,0.85),\n 0 0 0 1px rgba(26,22,18,0.06),\n 0 10px 26px rgba(26,22,18,0.10);\n transform: translateY(-1px);\n }\n .bird-card[data-pulse=\"true\"] {\n animation: bird-card-pulse 520ms cubic-bezier(.45,.05,.2,1);\n }\n /* Soft fade-in of the raised state, no ring pulse */\n @keyframes bird-card-pulse {\n 0% { transform: translateY(2px); }\n 100% { transform: translateY(-1px); }\n }\n\n .atlas-empty {\n grid-column: 1 / -1;\n padding: 80px 16px; text-align: center;\n font: 12px/1.6 var(--av-font-display); color: var(--ink-2);\n }\n .atlas-empty p { margin: 0 0 4px; }\n .atlas-empty p.hint {\n font: 10px var(--av-font-mono); color: var(--ink-soft);\n letter-spacing: 0.06em;\n }\n\n /* ============ Admin screen (system / logs / tools) ============\n Overlays the slider when activated via #admin=... hash. Lives\n inside the same shell as the rest of the app so the header,\n menu button, and time-window pill stay put - feels native. */\n .admin-screen {\n position: absolute; inset: 60px 0 60px 0;\n background: var(--bg);\n overflow-y: auto;\n z-index: 5;\n opacity: 0; pointer-events: none;\n transition: opacity 200ms ease;\n }\n .admin-screen[aria-hidden=\"false\"] { opacity: 1; pointer-events: auto; }\n .admin-frame {\n max-width: 1100px; margin: 0 auto; padding: 0 28px 40px;\n }\n .admin-title {\n text-align: center; margin: 0 auto 28px;\n }\n .admin-title h1 {\n margin: 0;\n font: 32px/1.1 var(--av-font-display);\n color: var(--ink); letter-spacing: 0.005em;\n }\n /* Return-to-atlas pill in the same slot the time-window pill\n normally lives - matches its visual language. */\n .return-to-atlas {\n position: absolute; top: 18px; left: 20px; z-index: 6;\n display: none; align-items: center; gap: 6px;\n padding: 7px 14px; border-radius: 999px;\n background: var(--paper-2);\n color: var(--ink-soft);\n font: 10px var(--av-font-mono); letter-spacing: 0.18em;\n text-transform: lowercase; cursor: pointer;\n text-decoration: none;\n box-shadow: var(--recess);\n transition: color 200ms ease;\n }\n .av-shell.admin-on .return-to-atlas { display: inline-flex; }\n .av-shell.admin-on #slider { visibility: hidden; }\n .av-shell.admin-on .static-head { opacity: 0; pointer-events: none; }\n /* Hide the time-window pill on admin pages (it doesn't apply\n there) - the return-to-atlas link takes its slot. */\n .av-shell.admin-on #winPick { visibility: hidden; }\n .return-to-atlas:hover { color: var(--ink); }\n .return-to-atlas svg { width: 10px; height: 10px; }\n .admin-body { padding-top: 4px; }\n .admin-grid {\n display: grid; gap: 14px;\n grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));\n margin-bottom: 22px;\n }\n .admin-card {\n background: var(--paper); border-radius: 8px;\n padding: 14px 16px; box-shadow: var(--edge);\n }\n .admin-card h3 {\n margin: 0 0 6px; font: 10px var(--av-font-mono);\n letter-spacing: 0.18em; text-transform: uppercase;\n color: var(--ink-soft); font-weight: 500;\n }\n .admin-card .v { font: 20px var(--av-font-display); color: var(--ink); }\n .admin-card .sub { font: 11px var(--av-font-mono); color: var(--ink-soft); margin-top: 4px; }\n .admin-card.alert { box-shadow: 0 0 0 1px var(--accent), var(--edge); }\n .admin-card.alert h3 { color: var(--accent); }\n .admin-card.warn { box-shadow: 0 0 0 1px #c47a1f, var(--edge); }\n .admin-card.warn h3 { color: #c47a1f; }\n .admin-section-head {\n font: 700 11px var(--av-font-mono);\n letter-spacing: 0.18em; text-transform: uppercase;\n color: var(--ink); margin: 24px 0 10px;\n border-left: 2px solid var(--accent-2); padding-left: 10px;\n }\n table.admin-tbl {\n width: 100%; border-collapse: collapse;\n font: 12px var(--av-font-mono);\n background: var(--paper); border-radius: 8px; overflow: hidden;\n box-shadow: var(--edge);\n }\n table.admin-tbl th, table.admin-tbl td {\n border-bottom: 1px solid var(--rule, #c8bfae);\n padding: 9px 12px; text-align: left;\n }\n table.admin-tbl tr:last-child th, table.admin-tbl tr:last-child td { border-bottom: 0; }\n table.admin-tbl th {\n color: var(--ink-soft); text-transform: uppercase;\n font-size: 9.5px; letter-spacing: 0.14em; font-weight: 500;\n }\n table.admin-tbl .pill {\n display: inline-block; padding: 1px 8px; border-radius: 999px;\n font-size: 9.5px; letter-spacing: 0.06em; text-transform: uppercase;\n }\n table.admin-tbl .pill.active { background: rgba(90,122,58,.18); color: #4d6730; }\n table.admin-tbl .pill.inactive { background: rgba(168,52,29,.18); color: var(--accent); }\n table.admin-tbl .pill.failed { background: rgba(168,52,29,.18); color: var(--accent); }\n table.admin-tbl button.restart {\n font: 9.5px var(--av-font-mono);\n letter-spacing: 0.04em; text-transform: lowercase;\n padding: 4px 10px; border: 0; background: var(--paper-2);\n border-radius: 4px; cursor: pointer; color: var(--ink-soft);\n box-shadow: var(--raised);\n }\n table.admin-tbl button.restart:hover { color: var(--ink); }\n table.admin-tbl button.restart[disabled] { opacity: .5; cursor: wait; }\n .admin-logs-toolbar {\n display: flex; gap: 8px; align-items: center; margin-bottom: 14px;\n }\n .admin-logs-toolbar select, .admin-logs-toolbar input[type=\"number\"] {\n font: 12px var(--av-font-mono);\n padding: 6px 10px; border: 1px solid var(--rule, #c8bfae);\n border-radius: 4px; background: var(--paper); color: var(--ink);\n }\n .admin-logs-toolbar label {\n font: 9.5px var(--av-font-mono); letter-spacing: 0.14em;\n text-transform: uppercase; color: var(--ink-soft);\n }\n .admin-logs-pane {\n background: #1c1a14; color: #d7cba8;\n font: 12px/1.5 var(--av-font-mono);\n padding: 14px 16px; border-radius: 8px;\n max-height: 65cqh; overflow: auto; white-space: pre-wrap;\n }\n .admin-actions-grid {\n display: grid; gap: 12px;\n grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));\n }\n .admin-action {\n background: var(--paper); border-radius: 8px;\n padding: 14px 16px; box-shadow: var(--edge);\n }\n .admin-action h4 { margin: 0 0 6px; font: 15px var(--av-font-display); color: var(--ink); }\n .admin-action p { margin: 0 0 10px; font: 12.5px var(--av-font-display); color: var(--ink-soft); }\n .admin-action button.run {\n font: 9.5px var(--av-font-mono); letter-spacing: 0.14em;\n text-transform: lowercase; padding: 6px 12px; border: 0;\n background: var(--ink); color: var(--paper); border-radius: 999px;\n cursor: pointer;\n }\n .admin-action button.run[disabled] { opacity: .5; cursor: wait; }\n .admin-action .out {\n margin-top: 10px; font: 10.5px var(--av-font-mono);\n color: var(--ink-soft); white-space: pre-wrap;\n }\n .admin-action.deploy pre {\n background: #1c1a14; color: #d7cba8;\n font: 11px var(--av-font-mono);\n padding: 12px 14px; border-radius: 6px;\n overflow: auto; white-space: pre-wrap; word-break: break-all;\n margin: 0;\n }\n .admin-action.deploy button.copy {\n font: 9.5px var(--av-font-mono); letter-spacing: 0.06em;\n text-transform: lowercase; padding: 5px 10px;\n background: var(--paper-2); color: var(--ink-soft);\n border: 0; border-radius: 4px; cursor: pointer; box-shadow: var(--raised);\n margin-top: 8px;\n }\n .admin-action.deploy button.copy:hover { color: var(--ink); }\n .admin-unreachable {\n background: var(--paper); border-radius: 8px;\n padding: 14px 16px; color: var(--accent);\n font: 12px var(--av-font-mono);\n margin-bottom: 18px; box-shadow: 0 0 0 1px var(--accent), var(--edge);\n }\n /* When admin screen is shown, the slider underneath is hidden so it\n doesn't compete for scrolling. The static title gets swapped out\n too - admin is its own section with its own visual identity.\n Menu button + time-window pill stay visible (still useful). */\n .av-shell.admin-on #views { display: none; }\n .av-shell.admin-on .nav-band,\n .av-shell.admin-on #slider { visibility: hidden; }\n .av-shell.admin-on .static-head { opacity: 0; pointer-events: none; transition: opacity 200ms ease; }\n\n /* Mobile compact: drawer takes near-full width, admin pads tighter,\n section heads + cards downsize. */\n @container av-card (max-width: 700px) {\n #menu-dd { top: 50px; right: 8px; left: 8px; width: auto; padding: 10px; }\n .admin-screen { inset: 52px 0 60px 0; }\n .admin-frame { padding: 0 14px 32px; }\n .admin-title { margin-bottom: 18px; }\n .admin-title h1 { font-size: 22px; }\n .admin-grid { gap: 10px; grid-template-columns: repeat(auto-fit, minmax(170px, 1fr)); }\n .admin-card { padding: 12px 14px; }\n .admin-card .v { font-size: 18px; }\n .admin-section-head { margin: 18px 0 8px; font-size: 10px; letter-spacing: 0.12em; }\n table.admin-tbl th, table.admin-tbl td { padding: 7px 8px; font-size: 11px; }\n .admin-actions-grid { gap: 10px; grid-template-columns: 1fr; }\n .admin-action { padding: 12px 14px; }\n /* Slider track bottom nav: tuck closer to bottom edge */\n .slider { bottom: 14px; padding: 3px; }\n .slider button { font-size: 9.5px; padding: 6px 11px; }\n }\n @container av-card (max-width: 700px) {\n /* Detail-modal: full-screen on small screens. */\n #detail-modal { padding: 12px; }\n /* Extra top padding = a clear strip for the close button, so the X\n sits in its own corner instead of on the artwork. */\n .modal-card { padding: 44px 18px 18px; border-radius: 10px; }\n .modal-close { top: 10px; right: 10px; }\n .modal-info h2 { font-size: 22px; }\n .modal-img { height: 160px; padding: 10px; }\n .modal-grid { gap: 18px; }\n /* Keep the description short so the recordings are in reach - the\n full text is one tap away on the wiki link. */\n .modal-info .desc {\n font-size: 12px; line-height: 1.5;\n display: -webkit-box; -webkit-line-clamp: 4;\n -webkit-box-orient: vertical; overflow: hidden;\n cursor: pointer;\n }\n .modal-info .desc.expanded { -webkit-line-clamp: unset; display: block; }\n .rec-row .when { font-size: 10.5px; }\n .rec-row .when small { font-size: 8.5px; }\n }\n\n /* ============ Detail modal (atlas card expansion) ============\n Click a bird card -> this fades in over a soft ink backdrop with\n the full sketch, taxonomic info, Wikipedia summary, and a\n scrollable list of every past audio capture for the species. */\n /* /about - a brief explainer popup floated over the index page.\n Reuses .modal-backdrop / .modal-close; opened by the #about hash. */\n #about-modal {\n position: absolute; inset: 0; z-index: 60;\n display: flex; align-items: center; justify-content: center;\n padding: 24px;\n pointer-events: none; opacity: 0;\n transition: opacity 200ms ease;\n }\n #about-modal[aria-hidden=\"false\"] { pointer-events: auto; opacity: 1; }\n .about-card {\n position: relative;\n max-width: 432px; width: 100%;\n background: var(--paper);\n border-radius: 14px;\n box-shadow:\n inset 0 0 0 1px rgba(255,255,255,0.6),\n 0 30px 80px rgba(26,22,18,0.24);\n padding: 34px 34px 28px;\n transform: translateY(10px) scale(0.985);\n transition: transform 260ms cubic-bezier(.32,.72,.32,1);\n }\n #about-modal[aria-hidden=\"false\"] .about-card { transform: none; }\n .about-card .about-eyebrow {\n margin: 0 0 7px;\n font: italic 400 13px/1 var(--av-font-display);\n color: var(--ink-2); letter-spacing: 0.06em;\n }\n .about-card h2 {\n margin: 0 0 13px;\n font: 700 22px/1.25 var(--av-font-display);\n letter-spacing: 0.01em; color: var(--ink);\n }\n .about-card .about-body {\n margin: 0;\n font: 400 14.5px/1.62 var(--av-font-display);\n color: var(--ink-2);\n }\n /* Plain inline link - same ink as the prose, a simple underline, no\n hover treatment. */\n .about-card .about-body a {\n color: inherit;\n text-decoration: underline;\n text-underline-offset: 2px;\n }\n .about-card .about-explore {\n margin-top: 20px;\n background: none; border: 0; padding: 0;\n font: 700 11px/1 var(--av-font-mono);\n letter-spacing: 0.08em; text-transform: lowercase;\n color: var(--ink-soft); cursor: pointer;\n transition: color 140ms ease;\n }\n .about-card .about-explore:hover { color: var(--ink); }\n #detail-modal {\n position: absolute; inset: 0; z-index: 50;\n display: flex; align-items: center; justify-content: center;\n padding: 24px;\n pointer-events: none; opacity: 0;\n /* One opacity fade for backdrop + card together; the card also\n morphs via transform (see morphModalOpen). Driven by .is-open so\n the fade can run *during* the close morph, not after it. */\n transition: opacity 260ms ease;\n }\n #detail-modal.is-open { pointer-events: auto; opacity: 1; }\n .modal-backdrop {\n position: absolute; inset: 0;\n background: rgba(26,22,18,0.32);\n backdrop-filter: blur(3px); -webkit-backdrop-filter: blur(3px);\n }\n .modal-card {\n position: relative;\n max-width: 920px; max-height: calc(100cqh - 48px);\n width: 100%;\n background: var(--paper);\n border-radius: 14px;\n /* A real border, not an inset shadow: borders paint outside the\n scrolling content box, so scrolled images/spectrograms can never\n slide over the card's outline. */\n border: 1px solid var(--hairline);\n box-shadow: 0 30px 80px rgba(26,22,18,0.22);\n padding: 30px 34px 26px;\n overflow: auto;\n /* The expand-from-card animation is driven by inline transform\n and opacity set on open/close - see openDetailModal. CSS\n transition handles the in-between glide. */\n transform-origin: 50% 50%;\n will-change: transform, opacity;\n }\n .modal-card.is-morphing {\n transition: transform 300ms cubic-bezier(.22,.61,.36,1);\n }\n .modal-close {\n position: absolute; top: 16px; right: 16px;\n background: var(--paper-2); border: 0;\n width: 30px; height: 30px; border-radius: 999px;\n font: 16px/1 var(--av-font-mono); color: var(--ink);\n box-shadow: var(--raised);\n cursor: pointer; z-index: 1;\n }\n .modal-close:hover { color: var(--accent-2); }\n .modal-grid {\n display: grid; grid-template-columns: 1fr 1.15fr; gap: 32px;\n align-items: start;\n }\n @container av-card (max-width: 700px) {\n .modal-grid { grid-template-columns: 1fr; gap: 22px; }\n .modal-card { padding: 22px; }\n }\n .modal-img {\n position: relative;\n background: var(--paper-2); border-radius: 10px;\n display: flex; align-items: center; justify-content: center;\n padding: 16px;\n /* Fixed frame: the illustration fits INSIDE it (object-fit) instead\n of the frame growing to the illustration's aspect. */\n height: 230px;\n box-shadow: var(--recess);\n }\n .modal-img img {\n max-width: 100%; max-height: 100%;\n width: auto; height: auto; object-fit: contain;\n filter: drop-shadow(0 2px 6px rgba(26,22,18,0.10));\n transition: opacity 220ms ease;\n }\n .modal-img img.swapping { opacity: 0; }\n /* Pose toggle - bottom-left corner of the modal sketch, icon strip.\n Same recipe as the time-window slider: recessed paper-2 well, a\n white sliding pill behind the active button (via .seg-pill), and\n a color shift on the icon itself. */\n .pose-toggle {\n position: absolute; left: 10px; bottom: 10px;\n display: inline-flex; padding: 3px;\n background: var(--paper-2); border-radius: 999px;\n box-shadow: var(--recess);\n z-index: 2;\n }\n .pose-toggle button {\n background: transparent; border: 0; color: var(--ink-soft);\n width: 30px; height: 26px; padding: 0; border-radius: 999px;\n cursor: pointer; position: relative; z-index: 1;\n display: inline-flex; align-items: center; justify-content: center;\n transition: color 200ms ease;\n }\n .pose-toggle button svg { width: 13px; height: 13px; display: block; }\n .pose-toggle button:hover { color: var(--ink); }\n .pose-toggle button[aria-current=\"true\"] { color: var(--ink); }\n .pose-toggle button .tip {\n position: absolute; bottom: calc(100% + 6px); left: 50%;\n transform: translateX(-50%); white-space: nowrap;\n font: 9px var(--av-font-mono); letter-spacing: 0.14em;\n text-transform: lowercase; color: var(--ink-soft);\n background: var(--paper); padding: 3px 8px; border-radius: 4px;\n box-shadow: var(--raised);\n opacity: 0; pointer-events: none; transition: opacity 160ms ease;\n }\n .pose-toggle button:hover .tip { opacity: 1; }\n .pose-toggle[data-unavailable=\"true\"] { display: none; }\n .pose-toggle button[data-unavailable=\"true\"] { display: none; }\n .modal-info h2 {\n margin: 4px 36px 0 0;\n font: 700 26px/1.1 var(--av-font-display);\n color: var(--ink); letter-spacing: 0.01em;\n }\n .modal-info .sci {\n font: italic 13.5px/1.3 var(--av-font-display);\n color: var(--ink-2); margin: 4px 0 6px;\n }\n .modal-stats {\n display: grid; grid-template-columns: repeat(3, 1fr); gap: 8px;\n margin-bottom: 18px;\n }\n .modal-stats > div {\n background: var(--paper-2);\n border-radius: 6px; padding: 10px 12px;\n box-shadow: var(--recess);\n }\n .modal-stats .n {\n display: block;\n font: 800 17px/1 var(--av-font-display);\n color: var(--ink); font-variant-numeric: tabular-nums;\n }\n .modal-stats .lbl {\n display: block; margin-top: 4px;\n font: 8.5px var(--av-font-mono);\n color: var(--ink-soft); letter-spacing: 0.12em;\n text-transform: uppercase;\n }\n .modal-info .desc {\n font: 13px/1.55 var(--av-font-display);\n color: var(--ink); margin: 0 0 10px;\n }\n .modal-info .desc.placeholder { color: var(--ink-soft); font-style: italic; }\n .modal-meta {\n display: flex; flex-wrap: wrap; gap: 16px;\n font: 9.5px var(--av-font-mono); color: var(--ink-soft);\n margin: 0 0 14px;\n }\n .modal-meta .k { letter-spacing: 0.12em; text-transform: uppercase; }\n .modal-meta .v { color: var(--ink); margin-left: 6px; }\n .modal-meta .v.rare { color: var(--accent-2); }\n .modal-recordings {\n margin-top: 14px; padding-top: 14px;\n box-shadow: inset 0 1px 0 rgba(26,22,18,0.07);\n }\n .modal-recordings .rec-head {\n display: flex; align-items: baseline; justify-content: space-between;\n margin-bottom: 8px;\n }\n .modal-recordings h3 {\n font: 700 10px var(--av-font-mono);\n letter-spacing: 0.18em; text-transform: uppercase;\n color: var(--ink); margin: 0;\n border-left: 2px solid var(--accent-2); padding-left: 10px;\n }\n .modal-recordings .rec-count {\n font: 9px var(--av-font-mono); color: var(--ink-soft);\n letter-spacing: 0.06em;\n }\n /* Reference-call (Xeno-Canto) button + its attribution line. */\n .modal-recordings .rec-head { gap: 8px; align-items: center; }\n .modal-recordings .rec-count { margin-left: auto; }\n .ref-call-btn {\n display: inline-flex; align-items: center; gap: 5px;\n font: 700 9px var(--av-font-mono); letter-spacing: 0.1em; text-transform: uppercase;\n color: var(--ink-soft); background: var(--paper); white-space: nowrap;\n border: 1px solid var(--hairline); border-radius: 999px;\n padding: 4px 11px 4px 9px; cursor: pointer;\n transition: color 160ms ease, background 160ms ease, border-color 160ms ease, transform 120ms ease;\n }\n .ref-call-btn svg { width: 9px; height: 9px; display: block; }\n .ref-call-btn:hover { color: var(--ink); background: var(--paper-2); transform: translateY(-1px); }\n .ref-call-btn[data-active=\"true\"] { color: var(--accent-2); border-color: var(--ink-soft); }\n .ref-call-btn.loading { opacity: 0.65; cursor: progress; }\n .ref-credit {\n font: 9px var(--av-font-mono); color: var(--ink-soft);\n letter-spacing: 0.04em; line-height: 1.55; margin: -2px 0 8px;\n }\n .ref-credit a { color: var(--ink-2); text-decoration: underline; text-underline-offset: 2px; }\n .ref-credit a:hover { color: var(--ink); }\n .modal-recordings ol {\n list-style: none; padding: 0; margin: 0;\n max-height: 220px; overflow-y: auto;\n display: flex; flex-direction: column; gap: 4px;\n padding-right: 4px;\n }\n .rec-row {\n display: grid; grid-template-columns: 30px 1fr auto auto; gap: 10px;\n align-items: center;\n padding: 6px 10px; border-radius: 6px;\n background: var(--paper);\n box-shadow: var(--edge);\n transition: background 160ms ease;\n }\n .rec-row:hover { background: var(--paper-2); }\n .rec-row.expanded { grid-template-columns: 30px 1fr auto; }\n /* Spectrogram strip - inline below the row header. Click on the row\n toggles open/closed independent of playback so you can scan\n spectrograms visually without listening. */\n .rec-spectro {\n grid-column: 1 / -1;\n margin-top: 8px;\n height: 0; overflow: hidden;\n border-radius: 4px;\n /* Paper background - the spectrogram canvas paints ink ON it,\n matching the sketch palette. */\n background: var(--paper-2);\n box-shadow: var(--recess);\n position: relative;\n transition: height 240ms cubic-bezier(.7,.05,.2,1);\n }\n .rec-row.expanded .rec-spectro { height: 88px; }\n /* The spectrogram is rendered client-side onto a canvas - we fetch\n the mp3, decode via Web Audio, run an STFT, and paint columns\n with our ink palette. No BirdNET-Pi PNG involved. */\n .rec-spectro canvas {\n position: absolute; inset: 0;\n width: 100%; height: 100%;\n display: block;\n opacity: 0; transition: opacity 240ms ease;\n }\n .rec-spectro canvas.ready { opacity: 1; }\n .rec-spectro .rec-spectro-loading {\n position: absolute; inset: 0;\n display: flex; align-items: center; justify-content: center;\n color: var(--ink-soft);\n font: 9px var(--av-font-mono); letter-spacing: 0.14em;\n text-transform: lowercase;\n pointer-events: none;\n }\n /* Played portion - soft ink wash over the paper ground. */\n .rec-spectro .rec-spectro-played {\n position: absolute; left: 0; top: 0; bottom: 0; width: 0%;\n background: linear-gradient(90deg, rgba(26,22,18,0.16), rgba(26,22,18,0.06));\n pointer-events: none;\n }\n /* Cursor - sliding paper pill with a raised shadow, same recipe as\n the time-window slider's seg-pill. Pops against both the bright\n paper regions of the spectrogram and the inked vocalization\n bands. */\n .rec-spectro .rec-spectro-cursor {\n position: absolute; top: 50%; left: 0;\n width: 14px; height: 20px;\n transform: translate(-50%, -50%);\n background: var(--paper);\n border-radius: 999px;\n box-shadow: 0 1px 3px rgba(26,22,18,0.30), inset 0 0 0 1px rgba(26,22,18,0.18);\n pointer-events: none;\n opacity: 0;\n }\n .rec-spectro .rec-spectro-cursor::after {\n content: '';\n position: absolute; left: 50%; top: -100%; bottom: -100%;\n width: 1.5px; transform: translateX(-50%);\n background: rgba(251,247,238,0.85);\n box-shadow: 0 0 0 0.5px rgba(26,22,18,0.20);\n }\n .rec-spectro.armed .rec-spectro-cursor { opacity: 1; }\n /* Dark mode: the spectrogram ground is charcoal (paintSpectrogram paints\n with --paper), so the dark played-veil and the --paper cursor pill would\n both vanish into it. Flip the veil light, and make the cursor a light\n pill so it still reads against the dark ground. */\n :host([data-theme=\"dark\"]) .rec-spectro .rec-spectro-played {\n background: linear-gradient(90deg, rgba(236,232,225,0.18), rgba(236,232,225,0.07));\n }\n :host([data-theme=\"dark\"]) .rec-spectro .rec-spectro-cursor {\n background: var(--ink);\n }\n .rec-spectro .rec-spectro-scrub {\n position: absolute; inset: 0;\n cursor: ew-resize;\n }\n .rec-row .play {\n background: var(--paper-2); border: 0; color: var(--ink);\n width: 24px; height: 24px; border-radius: 999px;\n display: inline-flex; align-items: center; justify-content: center;\n box-shadow: var(--raised); cursor: pointer;\n padding: 0;\n }\n .rec-row .play svg { width: 8px; height: 8px; }\n .rec-row .play[data-active=\"true\"] {\n background: var(--ink); color: var(--paper);\n }\n .rec-row .when {\n font: 11px/1.2 var(--av-font-mono); color: var(--ink);\n }\n .rec-row .when small {\n display: block; font-size: 9px; color: var(--ink-soft);\n letter-spacing: 0.06em; margin-top: 1px;\n }\n .rec-row .conf {\n font: 10px var(--av-font-mono);\n color: var(--ink-soft); letter-spacing: 0.06em;\n font-variant-numeric: tabular-nums;\n }\n .rec-empty {\n padding: 14px;\n font: 11px/1.5 var(--av-font-display); color: var(--ink-soft);\n text-align: center;\n }\n .modal-actions {\n display: flex; gap: 8px; margin-top: 18px;\n }\n .modal-actions .chip {\n flex: 0 0 auto;\n background: var(--paper-2);\n border: 0; color: var(--ink);\n padding: 7px 12px;\n border-radius: 4px;\n font: 10px/1 var(--av-font-mono);\n text-transform: lowercase; letter-spacing: 0.12em;\n text-decoration: none;\n display: inline-flex; align-items: center; gap: 5px;\n box-shadow: var(--raised);\n transition: transform 160ms ease;\n }\n .modal-actions .chip:hover { transform: translateY(-1px); }\n .modal-actions .chip.ext::after { content: '↗'; opacity: 0.6; }\n\n /* Spectrogram appears on first audio play. Recoloured via CSS filter\n so BirdNET-Pi's default colour spectrogram comes out as a clean\n monochrome ink strip, matching the rest of the chart. Click to\n replay; a thin progress bar tracks playback in real time. */\n .bird-card .spectro-wrap {\n position: relative;\n min-height: 0; margin: 6px 0 2px;\n cursor: pointer;\n overflow: hidden;\n border-radius: 3px;\n box-shadow: var(--recess);\n }\n .bird-card .spectro-wrap:empty { display: none; }\n /* Canvas is painted client-side in the active theme's paper/ink palette\n (paintSpectrogram reads data-theme), so it follows light/dark mode with\n no CSS filter - same renderer as the modal recording spectrograms. */\n .bird-card .spectro-wrap canvas {\n width: 100%; height: 44px; display: block;\n }\n /* Progress wash: a translucent veil that grows as the clip plays. Ink in\n light mode; in dark mode a light veil so it stays visible over the\n charcoal spectrogram ground. */\n .bird-card .spectro-wrap::after {\n content: '';\n position: absolute; top: 0; bottom: 0; left: 0;\n width: var(--prog, 0%);\n background: linear-gradient(to right, rgba(26,22,18,0.30), rgba(26,22,18,0.05));\n pointer-events: none;\n transition: width 60ms linear;\n }\n :host([data-theme=\"dark\"]) .bird-card .spectro-wrap::after {\n background: linear-gradient(to right, rgba(236,232,225,0.26), rgba(236,232,225,0.05));\n }\n /* Playhead - thin vertical line at the leading edge */\n .bird-card .spectro-wrap::before {\n content: '';\n position: absolute; top: 0; bottom: 0;\n left: var(--prog, 0%);\n width: 1px;\n background: var(--ink);\n opacity: var(--playhead, 0);\n pointer-events: none;\n transition: opacity 200ms ease;\n }\n .bird-card[data-playing=\"true\"] .spectro-wrap::before { --playhead: 0.6; }\n\n/* ============ AvianVisitors mode toggle ============\n * Default install (.av-shell.av-local): hide the lock-row, show drawer items\n * directly when the menu opens. The PHP shim at /avian/api/menu.php\n * returns items immediately with no auth, so the live JS skips the lock\n * flow entirely.\n *\n * Forwarded install (body.av-forwarded): show the lock row so users\n * type the basic-auth password before items unlock. Pair with\n * AV_REQUIRE_AUTH=1 in php-fpm env + Caddy basic_auth on /avian/api/.\n * See avian/forwarding/. */\n.av-shell.av-local #dd-locked { display: none; }\n.av-shell.av-local #dd-items { display: block; }\n\n/* ============ Wall-display widgets (HA build) ============\n Clock + weather as a quiet corner block INSIDE the collage view.\n renderCollage() stamps this element's box into the silhouette-packing\n grid, so when the flock grows the birds nest around the numerals the\n same way they nest around each other. Same editorial recipe as the\n rest of the page: a serif display line over a monospace, letterspaced\n micro-caption, on the paper/ink variables so dark theme inverts free.\n pointer-events:none keeps hover/click hit-testing on the birds. */\n.wall-widgets {\n position: absolute; z-index: 6;\n display: flex; flex-direction: column; gap: 10px;\n pointer-events: none;\n}\n.wall-widgets[hidden] { display: none; }\n.wall-widgets[data-corner=\"bottom-right\"] { right: 36px; bottom: 96px; align-items: flex-end; text-align: right; }\n.wall-widgets[data-corner=\"bottom-left\"] { left: 36px; bottom: 96px; align-items: flex-start; text-align: left; }\n.wall-widgets[data-corner=\"top-right\"] { right: 36px; top: 14px; align-items: flex-end; text-align: right; }\n.wall-widgets[data-corner=\"top-left\"] { left: 36px; top: 14px; align-items: flex-start; text-align: left; }\n.ww-block { display: flex; flex-direction: column; gap: 3px; }\n.wall-widgets[data-corner$=\"right\"] .ww-block { align-items: flex-end; }\n.wall-widgets[data-corner$=\"left\"] .ww-block { align-items: flex-start; }\n.ww-block[hidden] { display: none; }\n.ww-big {\n font: 600 clamp(26px, 3cqw, 40px)/1 var(--av-font-display);\n color: var(--ink);\n font-variant-numeric: tabular-nums;\n letter-spacing: 0.01em;\n}\n.ww-cap {\n font: 9.5px/1.45 var(--av-font-mono);\n letter-spacing: 0.16em; text-transform: uppercase;\n color: var(--ink-soft);\n}\n.ww-cap:empty { display: none; }\n.ww-rule {\n width: 44px; height: 1px;\n background: var(--hairline);\n}\n.ww-rule[hidden] { display: none; }\n@container av-card (max-width: 700px) {\n .wall-widgets { gap: 8px; }\n .wall-widgets[data-corner$=\"right\"] { right: 18px; }\n .wall-widgets[data-corner$=\"left\"] { left: 18px; }\n .wall-widgets[data-corner^=\"bottom\"] { bottom: 92px; }\n .ww-big { font-size: 20px; }\n .ww-cap { font-size: 8px; letter-spacing: 0.1em; }\n /* The sunrise/sunset caption is the first thing to go on a phone. */\n #wwSun { display: none; }\n}\n/* Kiosk cursor hiding - applied to by apt.js after idle when the\n wall options ask for it. */\n.av-shell.ww-cursor-hidden, .av-shell.ww-cursor-hidden * { cursor: none !important; }\n\n/* ---- Card build: configured title floats over the collage ---- */\n/* When a card sets a custom title, the heading is lifted out of the\n flex flow and overlaid on the collage top; renderCollage stamps the\n h1's box into the packing grid so birds nest around the words,\n clock-style. Stats/atlas keep headroom so their content doesn't\n slide under it. */\n.av-shell.av-title-overlay .static-head {\n position: absolute; top: 6px; left: 0; right: 0; z-index: 6;\n pointer-events: none; padding: 26px 32px 0;\n}\n.av-shell.av-title-overlay .view#v1,\n.av-shell.av-title-overlay .view#v2 { padding-top: 96px; }\n\n/* Card option: view picker pinned to the top edge instead of bottom. */\n.av-shell.av-picker-top .slider { top: 10px; bottom: auto; }\n.av-shell.av-picker-top .view { padding-bottom: 28px; }\n.av-shell.av-picker-top .view#v0, .av-shell.av-picker-top .view#v1 { padding-bottom: 24px; }\n\n/* ---- Recording-row review flag ---- */\n/* Writes a false-positive review back to BirdNET-Go. A quiet ghost x\n beside the confidence; first tap arms it into a labelled pill, second\n tap confirms - a stray touch can't flag anything. */\n.rec-row .flag {\n justify-self: end;\n min-width: 22px; height: 22px;\n display: inline-flex; align-items: center; justify-content: center;\n background: transparent; border: 0; border-radius: 999px;\n font: 10px/1 var(--av-font-mono); letter-spacing: 0;\n color: var(--ink-soft);\n padding: 0 6px; cursor: pointer; white-space: nowrap;\n transition: color 140ms ease, background 140ms ease;\n}\n.rec-row .flag:hover { color: var(--ink); background: var(--paper-2); }\n.rec-row .flag[data-state=\"armed\"] {\n background: var(--accent-2); color: var(--paper);\n font-size: 9px; letter-spacing: 0.08em; text-transform: uppercase;\n padding: 0 9px;\n}\n.rec-row .flag[data-state=\"done\"] { color: var(--ink); cursor: default; }\n.rec-row .flag[data-state=\"failed\"] {\n color: var(--accent-2); font-weight: 700;\n font-size: 8.5px; letter-spacing: 0.06em; text-transform: uppercase;\n}\n.rec-row .flag:disabled { cursor: default; }\n.rec-row.flagged .when, .rec-row.flagged .conf { text-decoration: line-through; opacity: 0.55; }\n\n/* ---- card-build additions ---- */\n:host {\n display: block; position: relative;\n width: 100%; height: 100%; min-height: 560px;\n overflow: hidden;\n border-radius: var(--ha-card-border-radius, 12px);\n /* The card box is the responsive container all @container rules and\n cq units resolve against. */\n container: av-card / size;\n}\n.av-shell { position: absolute; inset: 0; overflow: hidden; }\n/* Card default: transparent, so the collage sits directly on the HA\n dashboard. background: paper restores the page's warm ground. */\n.av-shell { background: transparent; }\n.av-shell.av-bg-paper { background: var(--paper); }\n/* font: system swaps the editorial serif/mono pairing for HA's own\n typeface (set per-theme by HA; Roboto stock). */\n.av-shell.av-font-system {\n --av-font-display: var(--ha-font-family-body, var(--mdc-typography-font-family, Roboto, \"Segoe UI\", sans-serif));\n --av-font-mono: var(--ha-font-family-body, var(--mdc-typography-font-family, Roboto, \"Segoe UI\", sans-serif));\n}\n/* The view picker hugs the card's bottom edge (the page floats it higher). */\n.slider { bottom: 10px; }\n/* The collage owns the WHOLE card - no reserved band for the picker\n (it's stamped into the packing grid as an obstacle instead), so the\n flock centres in the true middle of the card. */\n.view#v0 { padding: 12px 14px; }\n/* Clock/weather sit a finger's width off the card's true corners. */\n.wall-widgets[data-corner$=\"right\"] { right: 16px; }\n.wall-widgets[data-corner$=\"left\"] { left: 16px; }\n.wall-widgets[data-corner^=\"top\"] { top: 12px; }\n.wall-widgets[data-corner^=\"bottom\"] { bottom: 14px; }\n/* In system-font mode the empty state reads as a normal sentence in the\n configured font instead of editorial letterspaced small-caps. */\n.av-shell.av-font-system .empty {\n font: 14px/1.5 var(--av-font-mono);\n text-transform: none; letter-spacing: 0;\n}\n/* In system-font mode the picker drops the editorial small-caps treatment\n and reads like native HA tabs. */\n.av-shell.av-font-system .slider button {\n font: 500 13px/1 var(--av-font-mono);\n letter-spacing: 0; text-transform: capitalize;\n padding: 9px 16px;\n}\n/* BirdNET-Pi admin chrome has no backend here (same as the static HA build). */\n#menuBtn, #menu-dd, #returnToAtlas, #adminScreen { display: none !important; }\n"; +var HABIRD_CSS = " :host {\n color-scheme: light;\n /* Type is variable-driven so the card build can swap the editorial\n serif/mono pairing for the Home Assistant system font. */\n --av-font-display: ui-serif, \"Iowan Old Style\", \"Apple Garamond\", \"Bookman Old Style\", Georgia, serif;\n --av-font-mono: ui-monospace, \"SF Mono\", Menlo, monospace;\n --paper: #fcfcfb; /* near-pure white, faintest warm undertone */\n --paper-2: #f3f2ee; /* recessed track */\n --paper-3: #e8e6df; /* placeholder fill */\n --ink: #1a1612;\n --ink-2: #4a3f31;\n --ink-soft: #908576;\n --accent: #4a3f31; /* warm dark grey - matches ink-2, no longer red */\n --accent-2: #1a1612; /* near-black ink - same hue as the title text */\n --hairline: rgba(26,22,18,0.14); /* faint divider lines + chart gridlines */\n --pill: #fcfcfb; /* active-toggle sliding pill */\n /* Depth shadow recipes - replace flat 1px borders */\n --edge: inset 0 0 0 1px rgba(255,255,255,0.55), 0 0 0 1px rgba(26,22,18,0.04);\n --edge-lg: inset 0 0 0 1px rgba(255,255,255,0.6), 0 0 0 1px rgba(26,22,18,0.06), 0 8px 28px rgba(26,22,18,0.06);\n --recess: inset 0 1px 2px rgba(26,22,18,0.06), inset 0 0 0 1px rgba(26,22,18,0.04);\n --raised: inset 0 0 0 1px rgba(255,255,255,0.8), 0 0 0 1px rgba(26,22,18,0.05), 0 1px 2px rgba(26,22,18,0.07);\n }\n /* ---- Charcoal dark theme. Set via data-theme=\"dark\" on (an\n inline script in index.html applies the saved choice before paint;\n the switcher lives in Settings). The whole UI is variable-driven, so\n flipping ink<->paper inverts cleanly: text reads light, the stats\n squares become light marks on dark, the collage cutouts (transparent\n PNGs) sit on the charcoal directly. Shadow recipes are re-cut for a\n dark surface (faint light edges, deeper drop shadows). ---- */\n :host([data-theme=\"dark\"]) {\n color-scheme: dark;\n --paper: #1a1a1a; /* neutral charcoal page bg (no blue tint) */\n --paper-2: #242424; /* recessed track */\n --paper-3: #303030; /* placeholder fill */\n --ink: #ece8e1; /* warm off-white text / marks */\n --ink-2: #b7afa2; /* secondary text */\n --ink-soft: #837c70; /* muted text */\n --accent: #d8d2c6; /* light warm - pills read light-on-dark */\n --accent-2: #ece8e1;\n --hairline: rgba(236,232,225,0.20); /* faint light divider on charcoal */\n --pill: #404040; /* raised, clearly lighter than the track (neutral) */\n --edge: inset 0 0 0 1px rgba(255,255,255,0.05), 0 0 0 1px rgba(0,0,0,0.4);\n --edge-lg: inset 0 0 0 1px rgba(255,255,255,0.06), 0 0 0 1px rgba(0,0,0,0.5), 0 10px 30px rgba(0,0,0,0.5);\n --recess: inset 0 1px 2px rgba(0,0,0,0.5), inset 0 0 0 1px rgba(255,255,255,0.04);\n --raised: inset 0 0 0 1px rgba(255,255,255,0.07), 0 0 0 1px rgba(0,0,0,0.4), 0 1px 2px rgba(0,0,0,0.5);\n }\n * { box-sizing: border-box; }\n .av-shell { margin: 0; padding: 0; height: 100%; background: var(--paper); color: var(--ink); }\n .av-shell {\n font-family: var(--av-font-display);\n -webkit-font-smoothing: antialiased;\n overflow: hidden;\n }\n .mono { font-family: var(--av-font-mono); letter-spacing: 0.02em; }\n\n /* ============ Stage / views ============\n Stage is a flex column: a static title block sits on top, the views\n slider fills the remaining height. The title NEVER moves when the\n view changes - only its text fades when the new view's title differs\n from the current one. */\n .stage {\n position: absolute; inset: 0; overflow: hidden;\n display: flex; flex-direction: column;\n }\n .static-head {\n flex: 0 0 auto;\n padding: 84px 32px 22px;\n text-align: center; position: relative; z-index: 5;\n }\n .static-head .pre {\n display: inline-block;\n background: none; border: 0; padding: 0;\n font: italic 400 clamp(13px, 1.4cqw, 18px)/1 var(--av-font-display);\n color: var(--ink-2); letter-spacing: 0.06em; margin-bottom: 6px;\n cursor: pointer; transition: color 140ms ease;\n }\n .static-head .pre:hover {\n color: var(--ink);\n text-decoration: underline; text-underline-offset: 3px;\n text-decoration-thickness: 1px;\n }\n .static-head h1 {\n margin: 0; padding: 0;\n font: 700 clamp(24px, 3.2cqw, 40px)/1 var(--av-font-display);\n letter-spacing: 0.06em; color: var(--ink); text-transform: uppercase;\n font-style: normal;\n transition: opacity 240ms cubic-bezier(.7,.05,.2,1);\n }\n .static-head.swap-out h1 { opacity: 0; }\n /* Stats + Atlas hide the shared title so their content uses the full\n height (the collage keeps it). */\n body.av-view-stats .static-head, body.av-view-atlas .static-head { display: none; }\n @container av-card (max-width: 700px) {\n .static-head { padding: 96px 18px 22px; }\n }\n\n .views {\n flex: 1 1 auto; min-height: 0;\n position: relative; display: flex;\n transition: transform 480ms cubic-bezier(.7,.05,.2,1);\n }\n .view { flex: 0 0 100%; height: 100%; overflow: auto; padding: 8px 56px 132px; position: relative; }\n /* The collage view fits the viewport (no scroll). Atlas keeps normal\n scroll; stats scrolls when its grid outgrows the box (overridden at\n the .stats-grid rules below). */\n .view#v0, .view#v1 { overflow: hidden; padding: 8px 32px 88px; }\n .view#v0 { display: flex; }\n .view#v1 { display: flex; flex-direction: column; }\n @container av-card (max-width: 700px) { .view { padding: 8px 18px 132px; } .view#v0, .view#v1 { padding: 8px 16px 88px; } }\n\n /* ============ Top bar - window picker (left) + menu button (right). ============\n Visible on every view; the picker is the universal time-window control. */\n .top {\n position: absolute; top: 22px; left: 0; right: 0;\n display: flex; justify-content: space-between; align-items: center;\n padding: 0 28px; z-index: 30; pointer-events: none;\n }\n .top > * { pointer-events: auto; }\n .top .window-pick {\n display: inline-flex; gap: 0; padding: 4px;\n background: var(--paper-2); border-radius: 999px;\n box-shadow: var(--recess);\n position: relative;\n }\n .top .window-pick button {\n background: transparent; border: 0; color: var(--ink-soft);\n font: 10px/1 var(--av-font-mono); letter-spacing: 0.18em;\n padding: 9px 16px; border-radius: 999px; cursor: pointer; text-transform: uppercase;\n transition: color 200ms ease;\n position: relative; z-index: 1;\n }\n .top .window-pick button:hover { color: var(--ink); }\n .top .window-pick button[aria-current=\"true\"] { color: var(--ink); }\n .menu-btn {\n background: var(--paper); border: 0; color: var(--ink);\n font: 11px/1 var(--av-font-mono); padding: 9px 14px 8px;\n border-radius: 999px; cursor: pointer; letter-spacing: 0.18em; text-transform: lowercase;\n box-shadow: var(--raised);\n transition: transform 160ms ease;\n }\n .menu-btn:hover { transform: translateY(-1px); }\n .menu-btn:active { transform: translateY(0); }\n /* Compact top bar on mobile - the time-window pill goes from\n spacious to thumb-tight without losing legibility. */\n @container av-card (max-width: 700px) {\n .top { top: 12px; padding: 0 12px; }\n .top .window-pick { padding: 3px; }\n .top .window-pick button {\n font-size: 8.5px; padding: 6px 10px; letter-spacing: 0.12em;\n }\n .menu-btn { font-size: 9px; padding: 6px 11px 5px; letter-spacing: 0.14em; }\n .return-to-atlas { top: 10px; left: 10px; padding: 5px 10px; font-size: 8.5px; }\n }\n @container av-card (max-width: 420px) {\n .top .window-pick button { font-size: 8px; padding: 5px 7px; letter-spacing: 0.08em; }\n }\n\n /* ============ Bottom slider - recessed track, raised active pill ============ */\n .slider {\n position: absolute; bottom: 28px; left: 50%; transform: translateX(-50%);\n display: flex; gap: 0; padding: 4px; z-index: 30;\n background: var(--paper-2); border-radius: 999px;\n box-shadow: var(--recess);\n position: absolute; /* re-emit for the pill positioning */\n }\n .slider button {\n background: transparent; border: 0; color: var(--ink-soft);\n font: 10px/1 var(--av-font-mono); letter-spacing: 0.2em;\n padding: 11px 24px; border-radius: 999px; cursor: pointer; text-transform: uppercase;\n transition: color 200ms ease;\n position: relative; z-index: 1;\n }\n .slider button:hover { color: var(--ink); }\n .slider button[aria-current=\"true\"] { color: var(--ink); }\n\n /* Shared sliding pill - JS animates transform and width to the\n active button's position. The pill is the only element with the\n raised-paper look; buttons just toggle their text colour. */\n .seg-pill {\n position: absolute; top: 4px; bottom: 4px; left: 0;\n background: var(--pill); border-radius: 999px;\n box-shadow: var(--raised);\n transition: transform 320ms cubic-bezier(.7,.05,.2,1), width 320ms cubic-bezier(.7,.05,.2,1);\n will-change: transform, width;\n pointer-events: none; z-index: 0;\n }\n\n /* ============ Menu dropdown ============ */\n #menu-dd {\n position: absolute; top: 58px; right: 28px; z-index: 25;\n width: 360px; max-width: calc(100cqw - 24px);\n max-height: calc(100cqh - 80px); overflow: auto;\n background: var(--paper); border-radius: 12px;\n box-shadow: var(--edge-lg);\n padding: 14px; opacity: 0; transform: translateY(-6px);\n pointer-events: none; transition: opacity 200ms ease, transform 200ms ease;\n }\n #menu-dd.open { opacity: 1; transform: translateY(0); pointer-events: auto; }\n\n .lock-row {\n display: flex; gap: 6px; align-items: stretch;\n background: var(--paper-2); border-radius: 8px;\n box-shadow: var(--recess);\n padding: 4px;\n }\n .lock-row input {\n flex: 1; background: transparent; border: 0; outline: 0;\n color: var(--ink); padding: 8px 10px;\n font: 13px var(--av-font-mono);\n }\n .lock-row input::placeholder { color: var(--ink-soft); }\n .lock-row button {\n background: var(--paper); border: 0; color: var(--ink);\n width: 32px; padding: 0; border-radius: 6px; cursor: pointer;\n font: 14px/1 var(--av-font-mono);\n box-shadow: var(--raised);\n display: flex; align-items: center; justify-content: center;\n }\n .lock-row button:hover { color: var(--accent); }\n .lock-hint {\n margin: 8px 4px 0; font: 10px var(--av-font-mono);\n color: var(--ink-soft); letter-spacing: 0.04em;\n }\n .lock-err { color: var(--accent); }\n /* Tiny \"built by teddy\" attribution - appears under the lock-screen\n password field AND at the foot of the about-card. Same monospace\n micro-type used by .lock-hint so the two contexts read consistent. */\n .built-by {\n margin: 14px 4px 0;\n font: 10px var(--av-font-mono);\n color: var(--ink-soft);\n letter-spacing: 0.04em;\n }\n .built-by a {\n color: inherit;\n text-decoration: underline;\n text-underline-offset: 2px;\n }\n .built-by a:hover { color: var(--ink); }\n .about-card .built-by { margin-top: 18px; }\n\n .menu-items { padding: 4px 4px; display: none; }\n .menu-items.show { display: block; }\n .menu-items h3 {\n font: 9px/1 var(--av-font-mono); letter-spacing: 0.24em;\n color: var(--ink-soft); text-transform: uppercase; margin: 12px 4px 4px;\n }\n .menu-items a {\n display: block; padding: 9px 8px; color: var(--ink); text-decoration: none;\n font: 14px var(--av-font-display); border-radius: 6px;\n transition: background 120ms ease;\n }\n .menu-items a:hover { background: var(--paper-2); color: var(--accent); }\n\n /* ============ Settings cards inside the drawer ============\n Form controls match the time-picker's design language: recessed\n track + raised paper element. Toggles, sliders, and segmented\n pickers share the same depth-shadow recipe. */\n .menu-section {\n margin-top: 14px; padding-top: 12px;\n border-top: 1px solid rgba(26,22,18,0.07);\n }\n .menu-section:first-of-type { margin-top: 6px; border-top: 0; padding-top: 0; }\n .menu-section h3 {\n font: 700 9px var(--av-font-mono); letter-spacing: 0.20em;\n color: var(--ink); text-transform: uppercase;\n margin: 0 0 8px 4px;\n }\n /* Collapsible section - header is a button row with caret on the right.\n Default state is collapsed so accidental clicks don't touch settings. */\n .menu-section.collapsible > .section-header {\n display: flex; align-items: center; justify-content: space-between;\n cursor: pointer; user-select: none;\n padding: 4px 6px 6px 0;\n margin: 0 0 4px 0;\n }\n .menu-section.collapsible > .section-header h3 { margin: 0; }\n .menu-section.collapsible > .section-header .caret {\n width: 10px; height: 10px; transition: transform 200ms cubic-bezier(.7,.05,.2,1);\n color: var(--ink-soft);\n }\n .menu-section.collapsible[data-open=\"true\"] > .section-header .caret { transform: rotate(90deg); }\n .menu-section.collapsible > .section-body {\n max-height: 0; overflow: hidden;\n transition: max-height 280ms cubic-bezier(.7,.05,.2,1);\n }\n .menu-section.collapsible[data-open=\"true\"] > .section-body {\n max-height: 800px;\n }\n .menu-row {\n display: grid; grid-template-columns: 1fr auto; gap: 12px;\n align-items: center;\n padding: 8px 6px 8px 10px;\n border-radius: 6px;\n }\n .menu-row + .menu-row { box-shadow: inset 0 1px 0 rgba(26,22,18,0.06); }\n .menu-row .label {\n font: 12.5px var(--av-font-display); color: var(--ink);\n }\n .hint {\n display: block;\n font: 9px var(--av-font-mono);\n color: var(--ink-soft); letter-spacing: 0.04em; margin-top: 1px;\n }\n\n /* Toggle switch (raised pill that slides) */\n .switch {\n position: relative; width: 38px; height: 20px;\n background: var(--paper-2); border-radius: 999px;\n box-shadow: var(--recess);\n cursor: pointer;\n border: 0; padding: 0;\n }\n .switch::after {\n content: ''; position: absolute; top: 2px; left: 2px;\n width: 16px; height: 16px;\n background: var(--pill); border-radius: 999px;\n box-shadow: var(--raised);\n transition: transform 220ms cubic-bezier(.7,.05,.2,1), background 200ms ease;\n }\n .switch[aria-checked=\"true\"] {\n background: var(--ink-2);\n }\n .switch[aria-checked=\"true\"]::after {\n transform: translateX(18px);\n background: var(--pill);\n }\n\n /* Slider - title row (label + value badge with breathing room) and\n a track row underneath. Value sits at the far right with a wide\n gap so it never crowds the label text. */\n .slider-row {\n display: block;\n padding: 8px 6px 10px 10px;\n }\n .slider-row .head {\n display: flex; align-items: baseline; gap: 24px;\n margin-bottom: 6px;\n }\n .slider-row .head .label-block { flex: 1; min-width: 0; }\n .slider-row .label {\n font: 12.5px var(--av-font-display); color: var(--ink);\n }\n .slider-row .value {\n flex: 0 0 auto;\n font: 11px var(--av-font-mono);\n color: var(--ink); font-variant-numeric: tabular-nums;\n background: var(--paper-2); padding: 3px 10px; border-radius: 4px;\n box-shadow: var(--recess);\n min-width: 48px; text-align: center;\n }\n .slider-row .slider-track {\n margin-top: 2px;\n }\n .slider-track input[type=\"range\"] {\n -webkit-appearance: none; appearance: none;\n width: 100%; height: 18px; background: transparent; cursor: pointer;\n }\n .slider-track input[type=\"range\"]::-webkit-slider-runnable-track {\n height: 4px; background: var(--paper-2); border-radius: 999px;\n box-shadow: var(--recess);\n }\n .slider-track input[type=\"range\"]::-webkit-slider-thumb {\n -webkit-appearance: none; appearance: none;\n width: 16px; height: 16px; margin-top: -6px;\n background: var(--paper); border-radius: 999px;\n box-shadow: var(--raised);\n cursor: pointer;\n }\n .slider-track input[type=\"range\"]::-moz-range-track {\n height: 4px; background: var(--paper-2); border-radius: 999px;\n box-shadow: var(--recess);\n }\n .slider-track input[type=\"range\"]::-moz-range-thumb {\n width: 16px; height: 16px;\n background: var(--paper); border-radius: 999px;\n box-shadow: var(--raised); border: 0;\n cursor: pointer;\n }\n\n /* Segmented picker (same pill-on-recess pattern as the time picker) */\n .seg {\n display: inline-flex; padding: 4px;\n background: var(--paper-2); border-radius: 999px;\n box-shadow: var(--recess);\n position: relative;\n }\n .seg button {\n background: transparent; border: 0; color: var(--ink-soft);\n font: 9.5px var(--av-font-mono);\n letter-spacing: 0.16em; text-transform: uppercase;\n padding: 6px 12px; border-radius: 999px; cursor: pointer;\n position: relative; z-index: 1;\n transition: color 200ms ease;\n }\n .seg button[aria-current=\"true\"] {\n color: var(--ink);\n background: var(--pill);\n box-shadow: var(--raised);\n }\n\n /* Inline live-audio player - sits at the top of the unlocked drawer.\n A single play/stop button with a quiet running-indicator dot. */\n .live-audio {\n display: flex; align-items: center; gap: 12px;\n padding: 10px 12px; margin: 0 0 6px;\n background: var(--paper-2); border-radius: 8px;\n box-shadow: var(--recess);\n }\n .live-audio .pulse {\n width: 8px; height: 8px; border-radius: 50%;\n background: var(--ink-soft); flex-shrink: 0;\n transition: background 200ms ease;\n }\n .live-audio[data-on=\"true\"] .pulse {\n background: #c44a3b;\n animation: pulsate 1.6s ease-in-out infinite;\n }\n @keyframes pulsate {\n 0%, 100% { opacity: 0.4; transform: scale(1); }\n 50% { opacity: 1; transform: scale(1.25); }\n }\n .live-audio .label {\n flex: 1; font: 12.5px var(--av-font-display); color: var(--ink);\n }\n .live-audio .label .hint {\n font: 9px var(--av-font-mono); color: var(--ink-soft);\n letter-spacing: 0.04em;\n }\n .live-audio button {\n background: var(--paper); border: 0; color: var(--ink);\n padding: 6px 12px; border-radius: 4px;\n font: 9.5px var(--av-font-mono);\n letter-spacing: 0.14em; text-transform: uppercase; cursor: pointer;\n box-shadow: var(--raised);\n display: inline-flex; align-items: center; gap: 6px;\n }\n .live-audio button svg { width: 9px; height: 9px; }\n .live-audio button + button { margin-left: 4px; }\n /* Live spectrogram - hidden until the user clicks LISTEN. We\n collapse via max-height (not display:none) so the canvas inside\n keeps its layout dimensions and paints correctly on expand. */\n .live-spectro {\n width: 100%; height: 0;\n background: var(--paper-2); border-radius: 6px;\n margin: 0; overflow: hidden;\n box-shadow: var(--recess);\n transition: height 240ms cubic-bezier(.7,.05,.2,1), margin 240ms ease;\n }\n .live-audio[data-on=\"true\"] + .live-spectro {\n height: 70px; margin: 0 0 8px;\n }\n .live-status {\n font: 9.5px var(--av-font-mono); color: var(--ink-soft);\n letter-spacing: 0.04em; text-transform: lowercase;\n margin: -4px 0 6px 0; padding: 0 4px;\n min-height: 13px;\n }\n .live-status.err { color: #a83a2d; }\n .menu-links a .l-hint {\n display: block; font: 8.5px var(--av-font-display); text-transform: none;\n letter-spacing: 0; color: var(--ink-soft); margin-top: 2px;\n }\n .menu-links a.full { grid-column: 1 / -1; }\n\n /* External BirdNET pages - small grid of cards */\n .menu-links {\n display: grid; grid-template-columns: 1fr 1fr; gap: 6px;\n margin: 4px 0 2px;\n }\n .menu-links a {\n display: flex; align-items: center; justify-content: center;\n padding: 9px 10px;\n background: var(--paper-2);\n color: var(--ink); text-decoration: none;\n font: 10.5px var(--av-font-mono);\n letter-spacing: 0.10em; text-transform: lowercase;\n border-radius: 6px;\n box-shadow: var(--raised);\n transition: transform 160ms ease, color 160ms ease;\n }\n .menu-links a:hover { transform: translateY(-1px); color: var(--ink); background: var(--paper); }\n .menu-save-row {\n display: flex; gap: 8px; align-items: center; justify-content: flex-end;\n margin-top: 10px; padding: 0 4px;\n }\n .menu-save-row .save-state {\n flex: 1; font: 9px var(--av-font-mono);\n color: var(--ink-soft); letter-spacing: 0.04em;\n }\n .menu-save-row .save-state.ok { color: #2d5d3a; }\n .menu-save-row .save-state.err { color: var(--accent); }\n .menu-save-row button {\n background: var(--ink); color: var(--paper); border: 0;\n padding: 7px 14px; border-radius: 6px;\n font: 10px var(--av-font-mono); letter-spacing: 0.14em;\n text-transform: uppercase; cursor: pointer;\n box-shadow: var(--raised);\n }\n .menu-save-row button:disabled { opacity: 0.4; cursor: default; }\n\n /* ============ View 1 - Collage (Galliformes-style poster) ============\n Viewport-fit, no scroll. The shared static-head provides the title;\n this view's content is just the cluster of bird cutouts which fills\n the remaining viewport height. */\n .gcollage {\n /* No max-width: the packing area spans the full viewport so the flock\n can use the whole screen on wide laptops/monitors. */\n flex: 1 1 auto; width: 100%; margin: 0 auto;\n position: relative; min-height: 0;\n }\n /* Centre-out bloom: each tile fades + scales in; JS staggers the\n animation-delay by distance from the cluster centre. backwards\n fill-mode holds the hidden start state through the delay, and we keep\n no forwards transform so the hover scale still works afterward. */\n @keyframes gtile-in {\n from { opacity: 0; transform: scale(0.82); }\n to { opacity: 1; transform: scale(1); }\n }\n .gtile.entering {\n animation: gtile-in 420ms cubic-bezier(.2,.7,.3,1) backwards;\n }\n @media (prefers-reduced-motion: reduce) {\n .gtile.entering { animation: none; }\n }\n /* Atlas: cards rise + fade in, row by row (JS staggers by row). */\n @keyframes bird-card-in {\n from { opacity: 0; transform: translateY(16px) scale(0.96); }\n to { opacity: 1; transform: none; }\n }\n .bird-card.entering { animation: bird-card-in 480ms cubic-bezier(.2,.7,.3,1) backwards; }\n /* Stats: the heatmap populates top-to-bottom - the hour header leads,\n species rows stagger down the list; the side panel fades in just\n behind. Opacity only (sticky cells reposition themselves, so\n animating transform here would fight their layout). */\n @keyframes stats-fade-in { from { opacity: 0; } to { opacity: 1; } }\n .stats-hm-head.entering, .stats-hm-row.entering,\n .stats-side h3.entering, .stats-side small.entering, .stats-side li.entering {\n animation: stats-fade-in 340ms ease backwards;\n }\n @media (prefers-reduced-motion: reduce) {\n .bird-card.entering, .stats-hm-head.entering, .stats-hm-row.entering,\n .stats-side h3.entering, .stats-side small.entering, .stats-side li.entering { animation: none; }\n }\n .gtile {\n position: absolute;\n background: transparent; border: 0; padding: 0; margin: 0;\n overflow: visible; /* let drop-shadow extend past the bbox */\n /* transform stays quick for hover; the layout box glides so a\n repack (new bird, count change, resize) reads as the flock\n settling rather than a redraw. */\n transition: transform 130ms ease,\n left 760ms cubic-bezier(.3, 0, .2, 1),\n top 760ms cubic-bezier(.3, 0, .2, 1),\n width 760ms cubic-bezier(.3, 0, .2, 1),\n height 760ms cubic-bezier(.3, 0, .2, 1);\n /* Events are handled at the .collage level via alpha-mask\n hit-testing - the tile rectangles must not intercept them. */\n pointer-events: none;\n }\n /* Hover state is driven by JS alpha-mask hit-testing (.is-hover),\n NOT the CSS :hover pseudo - the rectangular tile bounding boxes\n overlap, so :hover would light up the wrong bird. .is-hover only\n ever sits on the one tile whose silhouette is actually under the\n cursor. */\n .gtile.is-hover { transform: scale(1.04); z-index: 3; }\n /* A bird leaving the window fades + shrinks away; renderCollage\n removes the node when the exit finishes. */\n .gtile.leaving {\n opacity: 0; transform: scale(.85);\n transition: opacity 520ms ease, transform 520ms ease;\n }\n .gtile img {\n width: 100%; height: 100%; object-fit: contain; display: block;\n /* Cutouts arrive as alpha-bbox-cropped transparent-bg PNGs, so\n object-fit: contain leaves no whitespace inside the tile - the\n bird touches all four sides. Soft drop shadow keeps it readable\n on paper. */\n filter: drop-shadow(0 2px 6px rgba(26,22,18,0.10));\n transition: filter 130ms ease;\n }\n .gtile.is-hover img {\n filter: drop-shadow(0 3px 10px rgba(26,22,18,0.26)) saturate(1.1);\n }\n /* Hover pill - surfaces the windowed count for the bird under the\n cursor. Confirms (Anna's-hummingbird-is-#1 paranoia) that the\n collage scaling reflects the selected window, not all-time. */\n /* JS anchors the pill to the hovered bird (left/top set per-hover) and\n keeps it clear of the bottom switcher, so it no longer hides behind\n it. translateX(-50%) centres it on the supplied point; z-index sits\n above the slider (30) as a backstop for any residual overlap. */\n .collage-tip {\n position: absolute; left: 0; top: 0;\n transform: translate(0, 0) translateX(-50%);\n padding: 6px 14px;\n background: var(--paper);\n border-radius: 999px;\n box-shadow: 0 6px 18px rgba(26,22,18,0.12),\n inset 0 0 0 1px rgba(255,255,255,0.6);\n font: italic 13px/1.3 var(--av-font-display);\n color: var(--ink);\n letter-spacing: 0.02em;\n opacity: 0; pointer-events: none;\n transition: opacity 150ms ease,\n transform 200ms cubic-bezier(.3, 0, .2, 1);\n white-space: nowrap;\n z-index: 31;\n }\n .collage-tip[aria-hidden=\"false\"] { opacity: 1; }\n .collage-tip .ct-name { font-style: normal; font-weight: 600; }\n .collage-tip .ct-n { font-style: normal; font-weight: 700; }\n .collage-tip .ct-w { color: var(--ink-soft); }\n\n .empty {\n color: var(--ink-soft); font: 10px var(--av-font-mono);\n padding: 120px 0; text-align: center; letter-spacing: 0.18em;\n text-transform: uppercase;\n }\n\n /* Page header is now shared at .stage > .static-head - see top of CSS. */\n /* Stats grid: the activity heatmap on the LEFT, text sections on the\n RIGHT. Each text section is a list with subtle separators. The\n heatmap scrolls within its panel; the view itself can also scroll\n if the whole grid outgrows the viewport. */\n .stats-grid {\n flex: 1 1 auto; min-height: 0;\n display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 48px;\n max-width: 1400px; margin: 0 auto; width: 100%;\n align-items: stretch;\n }\n /* Anything that doesn't fit (short desktop window, stacked mobile\n layout) scrolls instead of clipping. */\n .view#v1 { overflow-y: auto; overflow-x: hidden; }\n @container av-card (max-width: 900px) {\n /* Stacked single column: take the content's natural (tall) height instead\n of shrinking to the view (flex: 1 1 auto + min-height: 0 would let the\n grid collapse, clipping the heatmap), so #v1 actually scrolls on phones\n and portrait displays. */\n .stats-grid { grid-template-columns: 1fr; gap: 40px; flex: 0 0 auto; min-height: 0; }\n /* Stacked order: tables first, the heatmap underneath, and a buffer\n below it so its bottom row scrolls clear of the floating view\n switcher. */\n .stats-side { order: 1; }\n /* Full-height heatmap (no inner scroll) so the whole view is ONE scroll on\n a phone, not a nested one. Selector outranks the base max-height rule\n that follows it in source order. */\n .view#v1 .stats-timeline { order: 2; margin-bottom: 64px; max-height: none; }\n }\n /* Desktop two-column: size the grid to the side panel's content height\n (the heatmap pane stretches to match it, scrolling internally) and\n float it in the centre of the view. */\n @container av-card (min-width: 901px) {\n .stats-grid { flex: 0 1 auto; margin: auto; }\n }\n\n /* Activity heatmap: one row per species (most-heard first), one cell\n per hour of day, BirdNET-Go dashboard style. Cell tint deepens with\n detection count - mixed from paper toward ink so both themes work -\n and the count prints inside. The hour header sticks to the top of\n the scroll; species names stick to the left edge when the plot\n scrolls sideways on phones. */\n .stats-timeline {\n position: relative;\n flex: 1 1 auto; min-height: 120px;\n max-height: min(88cqh, 1600px);\n overflow: auto; -webkit-overflow-scrolling: touch;\n }\n .stats-hm {\n --hm-name: 200px;\n display: flex; flex-direction: column;\n min-width: 100%;\n }\n .stats-hm-head, .stats-hm-row {\n display: grid;\n grid-template-columns: var(--hm-name) repeat(24, minmax(22px, 1fr)) 48px;\n gap: 3px;\n padding: 1px 0;\n }\n .stats-hm-head {\n position: sticky; top: 0; z-index: 3;\n background: var(--paper);\n }\n .stats-hm-hr {\n font: 10px/1 var(--av-font-mono); color: var(--ink-soft);\n letter-spacing: 0.04em;\n text-align: center; padding: 3px 0 4px;\n }\n .stats-hm-name {\n position: sticky; left: 0; z-index: 2;\n background: var(--paper);\n display: flex; align-items: center;\n padding-right: 10px; overflow: hidden;\n transition: background 140ms ease;\n }\n .stats-hm-name .com {\n font: 600 14px/1.2 var(--av-font-display); color: var(--ink);\n white-space: nowrap; overflow: hidden; text-overflow: ellipsis;\n transition: color 130ms ease;\n }\n .stats-hm-cell {\n display: flex; align-items: center; justify-content: center;\n min-height: 26px;\n border-radius: 2px;\n background: var(--paper-2);\n font: 11px/1 var(--av-font-mono); color: var(--ink);\n font-variant-numeric: tabular-nums;\n overflow: hidden;\n }\n /* Hours outside the current sub-day window get no tray tint, so the\n window's extent reads as the band of filled trays. */\n .stats-hm-cell.off { background: transparent; box-shadow: inset 0 0 0 1px var(--paper-2); }\n /* Five count steps (log scale, set in renderActivity), paper -> ink. */\n .stats-hm-cell.l1 { background: color-mix(in srgb, var(--ink) 12%, var(--paper)); }\n .stats-hm-cell.l2 { background: color-mix(in srgb, var(--ink) 26%, var(--paper)); }\n .stats-hm-cell.l3 { background: color-mix(in srgb, var(--ink) 45%, var(--paper)); color: var(--paper); }\n .stats-hm-cell.l4 { background: color-mix(in srgb, var(--ink) 68%, var(--paper)); color: var(--paper); }\n .stats-hm-cell.l5 { background: var(--ink); color: var(--paper); }\n .stats-hm-tot {\n display: flex; align-items: center; justify-content: flex-end;\n padding-right: 2px;\n font: 12px/1 var(--av-font-mono); color: var(--ink-soft);\n font-variant-numeric: tabular-nums;\n }\n .stats-hm-head .stats-hm-tot {\n align-items: flex-start; padding-top: 3px;\n font-size: 9.5px; letter-spacing: 0.04em;\n }\n /* Cross-highlight: hovering a heatmap row or a table row lights up the\n matching species on the other side - the name shifts to the accent\n ink. Click opens the species' detail modal (data-sci delegation). */\n .stats-hm-row { cursor: pointer; }\n .stats-hm-row:hover .stats-hm-name,\n .stats-hm-row.sync-hi .stats-hm-name { background: var(--paper-2); }\n .stats-hm-row:hover .com,\n .stats-hm-row.sync-hi .com { color: var(--accent); }\n .stats-hm-empty {\n position: absolute; inset: 0;\n display: flex; align-items: center; justify-content: center;\n font: 11px var(--av-font-mono); color: var(--ink-soft);\n text-transform: lowercase; letter-spacing: 0.18em;\n }\n /* Caption under the matrix: what the columns cover (rolling window vs\n last-7-days aggregate) and whether the rows were trimmed. Sticky so\n it doesn't wander off when the plot scrolls sideways. */\n .stats-hm-cap {\n position: sticky; left: 0;\n padding: 8px 0 2px;\n font: 8.5px/1 var(--av-font-mono); color: var(--ink-soft);\n letter-spacing: 0.04em;\n }\n /* Phones: fixed-width cells -> the plot grows past the viewport and\n scrolls horizontally under the sticky name column. */\n @container av-card (max-width: 700px) {\n .stats-hm { --hm-name: 104px; min-width: max-content; }\n .stats-hm-head, .stats-hm-row {\n grid-template-columns: var(--hm-name) repeat(24, 26px) 40px;\n }\n }\n\n /* (Old chart-card / .stats-charts CSS removed - replaced by the\n activity heatmap above.) */\n\n .stats-side {\n display: flex; flex-direction: column; justify-content: center;\n align-self: stretch; max-height: 100%;\n padding: 6px 0;\n }\n .stats-side h3 {\n font: 700 11.5px var(--av-font-mono); color: var(--ink);\n letter-spacing: 0.18em; text-transform: uppercase; margin: 0 0 1px;\n border-left: 2px solid var(--accent); padding-left: 10px;\n }\n /* Tight, content-sized groups - they sit just below their headers\n rather than spreading to fill the column. */\n .stats-side .grp { flex: 0 0 auto; }\n .stats-side .grp + .grp { margin-top: 22px; }\n .stats-side .grp small {\n display: block; font: 10px var(--av-font-mono); color: var(--ink-soft);\n letter-spacing: 0.06em; margin: 0 0 3px 12px;\n }\n /* The whole list is one grid so counts share an aligned column, sized\n to the longest name (max-content) so that column sits right beside\n the names instead of stretching to a far edge. Each row is a subgrid\n so it still gets its own hover background + separator. */\n .stats-side ol {\n list-style: none; padding: 0; margin: 0;\n display: grid; grid-template-columns: 40px max-content 1fr;\n column-gap: 14px; align-content: start;\n }\n .stats-side li {\n display: grid; grid-template-columns: subgrid; grid-column: 1 / -1;\n align-items: baseline; padding: 3px 0;\n box-shadow: inset 0 -1px 0 rgba(26,22,18,0.05);\n font: 14px/1.3 var(--av-font-display); color: var(--ink);\n transition: background 140ms ease, color 140ms ease;\n }\n .stats-side li[data-sci] { cursor: pointer; }\n .stats-side li[data-sci]:hover { background: var(--paper-2); }\n /* Cross-highlight from a hovered timeline square. */\n .stats-side li.sync-hi { background: var(--paper-2); }\n .stats-side li.sync-hi span:not(.yr):not(.ct) { color: var(--accent); }\n .stats-side li .yr { font: 11px var(--av-font-mono); color: var(--accent-2); letter-spacing: 0.04em; }\n .stats-side li .ct { font: 13px var(--av-font-mono); color: var(--ink-soft); font-variant-numeric: tabular-nums; justify-self: end; }\n\n /* (Histogram strip removed - charts moved into the stats-grid LEFT\n column as full-bleed chart cards. See .stats-charts above.) */\n\n /* ============ View 3 - Atlas ============\n Field-guide grid: one card per species, each card holds the cutout,\n names, window/all-time detection counts, and small action chips for\n audio playback, Wikipedia, and eBird. */\n .atlas-controls {\n max-width: 1280px; margin: 0 auto 18px;\n display: flex; justify-content: flex-end; align-items: center;\n }\n .atlas-sort {\n display: inline-flex; padding: 3px; position: relative;\n background: var(--paper-2); border-radius: 999px;\n box-shadow: var(--recess);\n }\n .atlas-sort button {\n background: transparent; border: 0; color: var(--ink-soft);\n width: 30px; height: 26px; padding: 0; border-radius: 999px;\n cursor: pointer; position: relative; z-index: 1;\n display: inline-flex; align-items: center; justify-content: center;\n transition: color 200ms ease;\n }\n .atlas-sort button svg { width: 13px; height: 13px; display: block; }\n .atlas-sort button:hover { color: var(--ink); }\n .atlas-sort button[aria-current=\"true\"] { color: var(--ink); }\n .atlas-sort button .tip {\n position: absolute; top: calc(100% + 6px); left: 50%;\n transform: translateX(-50%); white-space: nowrap;\n font: 9px var(--av-font-mono); letter-spacing: 0.14em;\n text-transform: lowercase; color: var(--ink-soft);\n background: var(--paper); padding: 3px 8px; border-radius: 4px;\n box-shadow: var(--raised);\n opacity: 0; pointer-events: none; transition: opacity 160ms ease;\n }\n .atlas-sort button:hover .tip { opacity: 1; }\n .atlas-grid {\n display: grid;\n grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));\n gap: 22px;\n max-width: 1280px; margin: 0 auto;\n }\n /* Mobile atlas: a compact 2-up grid instead of one giant card per row,\n so you can scan many birds and tap easily. Inline actions are hidden -\n tapping the card opens the detail modal, which has play/wiki/ebird +\n recordings - making the whole card one big touch target. */\n @container av-card (max-width: 700px) {\n /* minmax(0,1fr) lets the columns shrink below their content's intrinsic\n width (otherwise the chips force overflow). Selectors are scoped under\n .atlas-grid so they out-specify the base .bird-card rules that appear\n later in the file. */\n .atlas-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 12px; }\n .atlas-grid .bird-card { min-height: 0; padding: 12px 12px 14px; border-radius: 9px; }\n .atlas-grid .bird-card .stat { top: 10px; left: 11px; font-size: 8px; }\n .atlas-grid .bird-card .stat .n { font-size: 11px; }\n .atlas-grid .bird-card .img-wrap { margin: 30px 0 8px; }\n .atlas-grid .bird-card .img-wrap img { max-height: 96px; }\n .atlas-grid .bird-card h3 { font-size: 12.5px; }\n .atlas-grid .bird-card .sci { font-size: 9.5px; }\n .atlas-grid .bird-card .actions { display: none; }\n .atlas-grid .lifer-badge { top: 9px; right: 9px; font-size: 7px; padding: 3px 5px; letter-spacing: 0.1em; }\n }\n .bird-card {\n position: relative;\n background: var(--paper);\n border-radius: 10px;\n padding: 16px 16px 12px;\n box-shadow: var(--edge-lg);\n display: flex; flex-direction: column;\n min-height: 320px;\n cursor: pointer;\n transition: transform 200ms ease, box-shadow 200ms ease;\n }\n .bird-card:hover { transform: translateY(-1px); box-shadow: var(--edge-xl, 0 6px 16px rgba(26,22,18,0.12)); }\n .bird-card .actions, .bird-card .spectro-wrap { cursor: auto; }\n .bird-card .actions button, .bird-card .actions a { cursor: pointer; }\n .bird-card .stat {\n position: absolute; top: 14px; left: 16px;\n font: 9px/1.5 var(--av-font-mono);\n color: var(--ink-soft); letter-spacing: 0.04em;\n }\n /* \"lifer\" badge - shown top-right when a species' first-ever detection\n falls inside the selected window (set in renderAtlas). pointer-events\n off so it never blocks the card's click-to-open. */\n .lifer-badge {\n position: absolute; top: 12px; right: 12px; z-index: 2;\n font: 700 8px/1 var(--av-font-mono);\n letter-spacing: 0.14em; text-transform: uppercase;\n color: var(--paper); background: var(--accent);\n padding: 4px 7px; border-radius: 999px;\n box-shadow: 0 2px 6px rgba(26,22,18,0.18);\n pointer-events: none;\n }\n .bird-card .stat .n {\n color: var(--ink); font: 700 13px/1 var(--av-font-display);\n font-variant-numeric: tabular-nums; margin-right: 4px;\n letter-spacing: 0;\n }\n .bird-card .stat .lbl { display: block; }\n .bird-card .img-wrap {\n flex: 1 1 auto; min-height: 0;\n display: flex; align-items: center; justify-content: center;\n margin: 38px 0 10px;\n padding: 4px;\n }\n .bird-card .img-wrap img {\n max-width: 100%; max-height: 160px; display: block;\n filter: drop-shadow(0 2px 6px rgba(26,22,18,0.10));\n }\n .bird-card h3 {\n margin: 0;\n font: 700 14.5px/1.15 var(--av-font-display);\n color: var(--ink); letter-spacing: 0.01em;\n }\n .bird-card .sci {\n font: italic 11px/1.2 var(--av-font-display);\n color: var(--ink-2); margin: 2px 0 0;\n }\n .bird-card .actions {\n display: flex; gap: 6px; align-items: stretch;\n margin-top: 12px; padding-top: 10px;\n box-shadow: inset 0 1px 0 rgba(26,22,18,0.07);\n }\n .bird-card .chip {\n flex: 0 0 auto;\n background: var(--paper-2);\n border: 0; color: var(--ink);\n padding: 5px 9px;\n border-radius: 4px;\n font: 9.5px/1 var(--av-font-mono);\n text-transform: lowercase; letter-spacing: 0.10em;\n text-decoration: none;\n cursor: pointer;\n display: inline-flex; align-items: center; gap: 5px;\n box-shadow: var(--raised);\n transition: transform 160ms ease, color 160ms ease;\n white-space: nowrap;\n }\n .bird-card .chip:hover {\n color: var(--ink); transform: translateY(-1px);\n }\n .bird-card .chip[data-active=\"true\"] {\n color: var(--paper);\n background: var(--ink);\n }\n .bird-card .chip svg { width: 10px; height: 10px; display: block; }\n .bird-card .chip.play { padding-left: 7px; }\n .bird-card .chip.ext::after { content: '↗'; opacity: 0.6; font-size: 11px; }\n\n /* Selected card highlight - matches the page-slider and time-window\n pill aesthetic: the active element is a slightly *brighter* paper\n surface (raised) on top of a slightly *recessed* background.\n No accent colour, no glowing ring - just the same depth shadow\n recipe the rest of the chrome uses. */\n .bird-card { transition: transform 280ms cubic-bezier(.7,.05,.2,1), box-shadow 280ms ease, background 200ms ease; }\n .bird-card[data-active=\"true\"] {\n background: var(--paper);\n box-shadow:\n inset 0 0 0 1px rgba(255,255,255,0.85),\n 0 0 0 1px rgba(26,22,18,0.06),\n 0 10px 26px rgba(26,22,18,0.10);\n transform: translateY(-1px);\n }\n .bird-card[data-pulse=\"true\"] {\n animation: bird-card-pulse 520ms cubic-bezier(.45,.05,.2,1);\n }\n /* Soft fade-in of the raised state, no ring pulse */\n @keyframes bird-card-pulse {\n 0% { transform: translateY(2px); }\n 100% { transform: translateY(-1px); }\n }\n\n .atlas-empty {\n grid-column: 1 / -1;\n padding: 80px 16px; text-align: center;\n font: 12px/1.6 var(--av-font-display); color: var(--ink-2);\n }\n .atlas-empty p { margin: 0 0 4px; }\n .atlas-empty p.hint {\n font: 10px var(--av-font-mono); color: var(--ink-soft);\n letter-spacing: 0.06em;\n }\n\n /* ============ Admin screen (system / logs / tools) ============\n Overlays the slider when activated via #admin=... hash. Lives\n inside the same shell as the rest of the app so the header,\n menu button, and time-window pill stay put - feels native. */\n .admin-screen {\n position: absolute; inset: 60px 0 60px 0;\n background: var(--bg);\n overflow-y: auto;\n z-index: 5;\n opacity: 0; pointer-events: none;\n transition: opacity 200ms ease;\n }\n .admin-screen[aria-hidden=\"false\"] { opacity: 1; pointer-events: auto; }\n .admin-frame {\n max-width: 1100px; margin: 0 auto; padding: 0 28px 40px;\n }\n .admin-title {\n text-align: center; margin: 0 auto 28px;\n }\n .admin-title h1 {\n margin: 0;\n font: 32px/1.1 var(--av-font-display);\n color: var(--ink); letter-spacing: 0.005em;\n }\n /* Return-to-atlas pill in the same slot the time-window pill\n normally lives - matches its visual language. */\n .return-to-atlas {\n position: absolute; top: 18px; left: 20px; z-index: 6;\n display: none; align-items: center; gap: 6px;\n padding: 7px 14px; border-radius: 999px;\n background: var(--paper-2);\n color: var(--ink-soft);\n font: 10px var(--av-font-mono); letter-spacing: 0.18em;\n text-transform: lowercase; cursor: pointer;\n text-decoration: none;\n box-shadow: var(--recess);\n transition: color 200ms ease;\n }\n .av-shell.admin-on .return-to-atlas { display: inline-flex; }\n .av-shell.admin-on #slider { visibility: hidden; }\n .av-shell.admin-on .static-head { opacity: 0; pointer-events: none; }\n /* Hide the time-window pill on admin pages (it doesn't apply\n there) - the return-to-atlas link takes its slot. */\n .av-shell.admin-on #winPick { visibility: hidden; }\n .return-to-atlas:hover { color: var(--ink); }\n .return-to-atlas svg { width: 10px; height: 10px; }\n .admin-body { padding-top: 4px; }\n .admin-grid {\n display: grid; gap: 14px;\n grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));\n margin-bottom: 22px;\n }\n .admin-card {\n background: var(--paper); border-radius: 8px;\n padding: 14px 16px; box-shadow: var(--edge);\n }\n .admin-card h3 {\n margin: 0 0 6px; font: 10px var(--av-font-mono);\n letter-spacing: 0.18em; text-transform: uppercase;\n color: var(--ink-soft); font-weight: 500;\n }\n .admin-card .v { font: 20px var(--av-font-display); color: var(--ink); }\n .admin-card .sub { font: 11px var(--av-font-mono); color: var(--ink-soft); margin-top: 4px; }\n .admin-card.alert { box-shadow: 0 0 0 1px var(--accent), var(--edge); }\n .admin-card.alert h3 { color: var(--accent); }\n .admin-card.warn { box-shadow: 0 0 0 1px #c47a1f, var(--edge); }\n .admin-card.warn h3 { color: #c47a1f; }\n .admin-section-head {\n font: 700 11px var(--av-font-mono);\n letter-spacing: 0.18em; text-transform: uppercase;\n color: var(--ink); margin: 24px 0 10px;\n border-left: 2px solid var(--accent-2); padding-left: 10px;\n }\n table.admin-tbl {\n width: 100%; border-collapse: collapse;\n font: 12px var(--av-font-mono);\n background: var(--paper); border-radius: 8px; overflow: hidden;\n box-shadow: var(--edge);\n }\n table.admin-tbl th, table.admin-tbl td {\n border-bottom: 1px solid var(--rule, #c8bfae);\n padding: 9px 12px; text-align: left;\n }\n table.admin-tbl tr:last-child th, table.admin-tbl tr:last-child td { border-bottom: 0; }\n table.admin-tbl th {\n color: var(--ink-soft); text-transform: uppercase;\n font-size: 9.5px; letter-spacing: 0.14em; font-weight: 500;\n }\n table.admin-tbl .pill {\n display: inline-block; padding: 1px 8px; border-radius: 999px;\n font-size: 9.5px; letter-spacing: 0.06em; text-transform: uppercase;\n }\n table.admin-tbl .pill.active { background: rgba(90,122,58,.18); color: #4d6730; }\n table.admin-tbl .pill.inactive { background: rgba(168,52,29,.18); color: var(--accent); }\n table.admin-tbl .pill.failed { background: rgba(168,52,29,.18); color: var(--accent); }\n table.admin-tbl button.restart {\n font: 9.5px var(--av-font-mono);\n letter-spacing: 0.04em; text-transform: lowercase;\n padding: 4px 10px; border: 0; background: var(--paper-2);\n border-radius: 4px; cursor: pointer; color: var(--ink-soft);\n box-shadow: var(--raised);\n }\n table.admin-tbl button.restart:hover { color: var(--ink); }\n table.admin-tbl button.restart[disabled] { opacity: .5; cursor: wait; }\n .admin-logs-toolbar {\n display: flex; gap: 8px; align-items: center; margin-bottom: 14px;\n }\n .admin-logs-toolbar select, .admin-logs-toolbar input[type=\"number\"] {\n font: 12px var(--av-font-mono);\n padding: 6px 10px; border: 1px solid var(--rule, #c8bfae);\n border-radius: 4px; background: var(--paper); color: var(--ink);\n }\n .admin-logs-toolbar label {\n font: 9.5px var(--av-font-mono); letter-spacing: 0.14em;\n text-transform: uppercase; color: var(--ink-soft);\n }\n .admin-logs-pane {\n background: #1c1a14; color: #d7cba8;\n font: 12px/1.5 var(--av-font-mono);\n padding: 14px 16px; border-radius: 8px;\n max-height: 65cqh; overflow: auto; white-space: pre-wrap;\n }\n .admin-actions-grid {\n display: grid; gap: 12px;\n grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));\n }\n .admin-action {\n background: var(--paper); border-radius: 8px;\n padding: 14px 16px; box-shadow: var(--edge);\n }\n .admin-action h4 { margin: 0 0 6px; font: 15px var(--av-font-display); color: var(--ink); }\n .admin-action p { margin: 0 0 10px; font: 12.5px var(--av-font-display); color: var(--ink-soft); }\n .admin-action button.run {\n font: 9.5px var(--av-font-mono); letter-spacing: 0.14em;\n text-transform: lowercase; padding: 6px 12px; border: 0;\n background: var(--ink); color: var(--paper); border-radius: 999px;\n cursor: pointer;\n }\n .admin-action button.run[disabled] { opacity: .5; cursor: wait; }\n .admin-action .out {\n margin-top: 10px; font: 10.5px var(--av-font-mono);\n color: var(--ink-soft); white-space: pre-wrap;\n }\n .admin-action.deploy pre {\n background: #1c1a14; color: #d7cba8;\n font: 11px var(--av-font-mono);\n padding: 12px 14px; border-radius: 6px;\n overflow: auto; white-space: pre-wrap; word-break: break-all;\n margin: 0;\n }\n .admin-action.deploy button.copy {\n font: 9.5px var(--av-font-mono); letter-spacing: 0.06em;\n text-transform: lowercase; padding: 5px 10px;\n background: var(--paper-2); color: var(--ink-soft);\n border: 0; border-radius: 4px; cursor: pointer; box-shadow: var(--raised);\n margin-top: 8px;\n }\n .admin-action.deploy button.copy:hover { color: var(--ink); }\n .admin-unreachable {\n background: var(--paper); border-radius: 8px;\n padding: 14px 16px; color: var(--accent);\n font: 12px var(--av-font-mono);\n margin-bottom: 18px; box-shadow: 0 0 0 1px var(--accent), var(--edge);\n }\n /* When admin screen is shown, the slider underneath is hidden so it\n doesn't compete for scrolling. The static title gets swapped out\n too - admin is its own section with its own visual identity.\n Menu button + time-window pill stay visible (still useful). */\n .av-shell.admin-on #views { display: none; }\n .av-shell.admin-on .nav-band,\n .av-shell.admin-on #slider { visibility: hidden; }\n .av-shell.admin-on .static-head { opacity: 0; pointer-events: none; transition: opacity 200ms ease; }\n\n /* Mobile compact: drawer takes near-full width, admin pads tighter,\n section heads + cards downsize. */\n @container av-card (max-width: 700px) {\n #menu-dd { top: 50px; right: 8px; left: 8px; width: auto; padding: 10px; }\n .admin-screen { inset: 52px 0 60px 0; }\n .admin-frame { padding: 0 14px 32px; }\n .admin-title { margin-bottom: 18px; }\n .admin-title h1 { font-size: 22px; }\n .admin-grid { gap: 10px; grid-template-columns: repeat(auto-fit, minmax(170px, 1fr)); }\n .admin-card { padding: 12px 14px; }\n .admin-card .v { font-size: 18px; }\n .admin-section-head { margin: 18px 0 8px; font-size: 10px; letter-spacing: 0.12em; }\n table.admin-tbl th, table.admin-tbl td { padding: 7px 8px; font-size: 11px; }\n .admin-actions-grid { gap: 10px; grid-template-columns: 1fr; }\n .admin-action { padding: 12px 14px; }\n /* Slider track bottom nav: tuck closer to bottom edge */\n .slider { bottom: 14px; padding: 3px; }\n .slider button { font-size: 9.5px; padding: 6px 11px; }\n }\n @container av-card (max-width: 700px) {\n /* Detail-modal: full-screen on small screens. */\n #detail-modal { padding: 12px; }\n /* Extra top padding = a clear strip for the close button, so the X\n sits in its own corner instead of on the artwork. */\n .modal-card { padding: 44px 18px 18px; border-radius: 10px; }\n .modal-close { top: 10px; right: 10px; }\n .modal-info h2 { font-size: 22px; }\n .modal-img { height: 160px; padding: 10px; }\n .modal-grid { gap: 18px; }\n /* Keep the description short so the recordings are in reach - the\n full text is one tap away on the wiki link. */\n .modal-info .desc {\n font-size: 12px; line-height: 1.5;\n display: -webkit-box; -webkit-line-clamp: 4;\n -webkit-box-orient: vertical; overflow: hidden;\n cursor: pointer;\n }\n .modal-info .desc.expanded { -webkit-line-clamp: unset; display: block; }\n .rec-row .when { font-size: 10.5px; }\n .rec-row .when small { font-size: 8.5px; }\n }\n\n /* ============ Detail modal (atlas card expansion) ============\n Click a bird card -> this fades in over a soft ink backdrop with\n the full sketch, taxonomic info, Wikipedia summary, and a\n scrollable list of every past audio capture for the species. */\n /* /about - a brief explainer popup floated over the index page.\n Reuses .modal-backdrop / .modal-close; opened by the #about hash. */\n #about-modal {\n position: absolute; inset: 0; z-index: 60;\n display: flex; align-items: center; justify-content: center;\n padding: 24px;\n pointer-events: none; opacity: 0;\n transition: opacity 200ms ease;\n }\n #about-modal[aria-hidden=\"false\"] { pointer-events: auto; opacity: 1; }\n .about-card {\n position: relative;\n max-width: 432px; width: 100%;\n background: var(--paper);\n border-radius: 14px;\n box-shadow:\n inset 0 0 0 1px rgba(255,255,255,0.6),\n 0 30px 80px rgba(26,22,18,0.24);\n padding: 34px 34px 28px;\n transform: translateY(10px) scale(0.985);\n transition: transform 260ms cubic-bezier(.32,.72,.32,1);\n }\n #about-modal[aria-hidden=\"false\"] .about-card { transform: none; }\n .about-card .about-eyebrow {\n margin: 0 0 7px;\n font: italic 400 13px/1 var(--av-font-display);\n color: var(--ink-2); letter-spacing: 0.06em;\n }\n .about-card h2 {\n margin: 0 0 13px;\n font: 700 22px/1.25 var(--av-font-display);\n letter-spacing: 0.01em; color: var(--ink);\n }\n .about-card .about-body {\n margin: 0;\n font: 400 14.5px/1.62 var(--av-font-display);\n color: var(--ink-2);\n }\n /* Plain inline link - same ink as the prose, a simple underline, no\n hover treatment. */\n .about-card .about-body a {\n color: inherit;\n text-decoration: underline;\n text-underline-offset: 2px;\n }\n .about-card .about-explore {\n margin-top: 20px;\n background: none; border: 0; padding: 0;\n font: 700 11px/1 var(--av-font-mono);\n letter-spacing: 0.08em; text-transform: lowercase;\n color: var(--ink-soft); cursor: pointer;\n transition: color 140ms ease;\n }\n .about-card .about-explore:hover { color: var(--ink); }\n #detail-modal {\n position: absolute; inset: 0; z-index: 50;\n display: flex; align-items: center; justify-content: center;\n padding: 24px;\n pointer-events: none; opacity: 0;\n /* One opacity fade for backdrop + card together; the card also\n morphs via transform (see morphModalOpen). Driven by .is-open so\n the fade can run *during* the close morph, not after it. */\n transition: opacity 260ms ease;\n }\n #detail-modal.is-open { pointer-events: auto; opacity: 1; }\n .modal-backdrop {\n position: absolute; inset: 0;\n background: rgba(26,22,18,0.32);\n backdrop-filter: blur(3px); -webkit-backdrop-filter: blur(3px);\n }\n .modal-card {\n position: relative;\n /* Cap to the overlay, NOT the viewport. #detail-modal is inset:0 with\n 24px padding, so 100% here == its content box == the old\n calc(100cqh - 48px) on a normal viewport - but robustly. In the card\n build 100cqh becomes 100cqh, and when Home Assistant gives the card an\n indefinite height (masonry / content-sized slots) Blink resolves cqh\n to 0, so calc(100cqh - 48px) collapsed to 0 and the modal shrank to a\n strip of padding (Chrome / HA companion WebView only; Firefox & Safari\n resolved it fine). % of the definite inset:0 overlay sidesteps that. */\n max-width: 920px; max-height: 100%;\n width: 100%;\n background: var(--paper);\n border-radius: 14px;\n /* A real border, not an inset shadow: borders paint outside the\n scrolling content box, so scrolled images/spectrograms can never\n slide over the card's outline. */\n border: 1px solid var(--hairline);\n box-shadow: 0 30px 80px rgba(26,22,18,0.22);\n padding: 30px 34px 26px;\n overflow: auto;\n /* The expand-from-card animation is driven by inline transform\n and opacity set on open/close - see openDetailModal. CSS\n transition handles the in-between glide. */\n transform-origin: 50% 50%;\n will-change: transform, opacity;\n }\n .modal-card.is-morphing {\n transition: transform 300ms cubic-bezier(.22,.61,.36,1);\n }\n .modal-close {\n position: absolute; top: 16px; right: 16px;\n background: var(--paper-2); border: 0;\n width: 30px; height: 30px; border-radius: 999px;\n font: 16px/1 var(--av-font-mono); color: var(--ink);\n box-shadow: var(--raised);\n cursor: pointer; z-index: 1;\n }\n .modal-close:hover { color: var(--accent-2); }\n .modal-grid {\n display: grid; grid-template-columns: 1fr 1.15fr; gap: 32px;\n align-items: start;\n }\n @container av-card (max-width: 700px) {\n .modal-grid { grid-template-columns: 1fr; gap: 22px; }\n .modal-card { padding: 22px; }\n }\n .modal-img {\n position: relative;\n background: var(--paper-2); border-radius: 10px;\n display: flex; align-items: center; justify-content: center;\n padding: 16px;\n /* Fixed frame: the illustration fits INSIDE it (object-fit) instead\n of the frame growing to the illustration's aspect. */\n height: 230px;\n box-shadow: var(--recess);\n }\n .modal-img img {\n max-width: 100%; max-height: 100%;\n width: auto; height: auto; object-fit: contain;\n filter: drop-shadow(0 2px 6px rgba(26,22,18,0.10));\n transition: opacity 220ms ease;\n }\n .modal-img img.swapping { opacity: 0; }\n /* Pose toggle - bottom-left corner of the modal sketch, icon strip.\n Same recipe as the time-window slider: recessed paper-2 well, a\n white sliding pill behind the active button (via .seg-pill), and\n a color shift on the icon itself. */\n .pose-toggle {\n position: absolute; left: 10px; bottom: 10px;\n display: inline-flex; padding: 3px;\n background: var(--paper-2); border-radius: 999px;\n box-shadow: var(--recess);\n z-index: 2;\n }\n .pose-toggle button {\n background: transparent; border: 0; color: var(--ink-soft);\n width: 30px; height: 26px; padding: 0; border-radius: 999px;\n cursor: pointer; position: relative; z-index: 1;\n display: inline-flex; align-items: center; justify-content: center;\n transition: color 200ms ease;\n }\n .pose-toggle button svg { width: 13px; height: 13px; display: block; }\n .pose-toggle button:hover { color: var(--ink); }\n .pose-toggle button[aria-current=\"true\"] { color: var(--ink); }\n .pose-toggle button .tip {\n position: absolute; bottom: calc(100% + 6px); left: 50%;\n transform: translateX(-50%); white-space: nowrap;\n font: 9px var(--av-font-mono); letter-spacing: 0.14em;\n text-transform: lowercase; color: var(--ink-soft);\n background: var(--paper); padding: 3px 8px; border-radius: 4px;\n box-shadow: var(--raised);\n opacity: 0; pointer-events: none; transition: opacity 160ms ease;\n }\n .pose-toggle button:hover .tip { opacity: 1; }\n .pose-toggle[data-unavailable=\"true\"] { display: none; }\n .pose-toggle button[data-unavailable=\"true\"] { display: none; }\n .modal-info h2 {\n margin: 4px 36px 0 0;\n font: 700 26px/1.1 var(--av-font-display);\n color: var(--ink); letter-spacing: 0.01em;\n }\n .modal-info .sci {\n font: italic 13.5px/1.3 var(--av-font-display);\n color: var(--ink-2); margin: 4px 0 6px;\n }\n .modal-stats {\n display: grid; grid-template-columns: repeat(3, 1fr); gap: 8px;\n margin-bottom: 18px;\n }\n .modal-stats > div {\n background: var(--paper-2);\n border-radius: 6px; padding: 10px 12px;\n box-shadow: var(--recess);\n }\n .modal-stats .n {\n display: block;\n font: 800 17px/1 var(--av-font-display);\n color: var(--ink); font-variant-numeric: tabular-nums;\n }\n .modal-stats .lbl {\n display: block; margin-top: 4px;\n font: 8.5px var(--av-font-mono);\n color: var(--ink-soft); letter-spacing: 0.12em;\n text-transform: uppercase;\n }\n .modal-info .desc {\n font: 13px/1.55 var(--av-font-display);\n color: var(--ink); margin: 0 0 10px;\n }\n .modal-info .desc.placeholder { color: var(--ink-soft); font-style: italic; }\n .modal-meta {\n display: flex; flex-wrap: wrap; gap: 16px;\n font: 9.5px var(--av-font-mono); color: var(--ink-soft);\n margin: 0 0 14px;\n }\n .modal-meta .k { letter-spacing: 0.12em; text-transform: uppercase; }\n .modal-meta .v { color: var(--ink); margin-left: 6px; }\n .modal-meta .v.rare { color: var(--accent-2); }\n .modal-recordings {\n margin-top: 14px; padding-top: 14px;\n box-shadow: inset 0 1px 0 rgba(26,22,18,0.07);\n }\n .modal-recordings .rec-head {\n display: flex; align-items: baseline; justify-content: space-between;\n margin-bottom: 8px;\n }\n .modal-recordings h3 {\n font: 700 10px var(--av-font-mono);\n letter-spacing: 0.18em; text-transform: uppercase;\n color: var(--ink); margin: 0;\n border-left: 2px solid var(--accent-2); padding-left: 10px;\n }\n .modal-recordings .rec-count {\n font: 9px var(--av-font-mono); color: var(--ink-soft);\n letter-spacing: 0.06em;\n }\n /* Reference-call (Xeno-Canto) button + its attribution line. */\n .modal-recordings .rec-head { gap: 8px; align-items: center; }\n .modal-recordings .rec-count { margin-left: auto; }\n .ref-call-btn {\n display: inline-flex; align-items: center; gap: 5px;\n font: 700 9px var(--av-font-mono); letter-spacing: 0.1em; text-transform: uppercase;\n color: var(--ink-soft); background: var(--paper); white-space: nowrap;\n border: 1px solid var(--hairline); border-radius: 999px;\n padding: 4px 11px 4px 9px; cursor: pointer;\n transition: color 160ms ease, background 160ms ease, border-color 160ms ease, transform 120ms ease;\n }\n .ref-call-btn svg { width: 9px; height: 9px; display: block; }\n .ref-call-btn:hover { color: var(--ink); background: var(--paper-2); transform: translateY(-1px); }\n .ref-call-btn[data-active=\"true\"] { color: var(--accent-2); border-color: var(--ink-soft); }\n .ref-call-btn.loading { opacity: 0.65; cursor: progress; }\n .ref-credit {\n font: 9px var(--av-font-mono); color: var(--ink-soft);\n letter-spacing: 0.04em; line-height: 1.55; margin: -2px 0 8px;\n }\n .ref-credit a { color: var(--ink-2); text-decoration: underline; text-underline-offset: 2px; }\n .ref-credit a:hover { color: var(--ink); }\n .modal-recordings ol {\n list-style: none; padding: 0; margin: 0;\n max-height: 220px; overflow-y: auto;\n display: flex; flex-direction: column; gap: 4px;\n padding-right: 4px;\n }\n .rec-row {\n display: grid; grid-template-columns: 30px 1fr auto auto; gap: 10px;\n align-items: center;\n padding: 6px 10px; border-radius: 6px;\n background: var(--paper);\n box-shadow: var(--edge);\n transition: background 160ms ease;\n }\n .rec-row:hover { background: var(--paper-2); }\n .rec-row.expanded { grid-template-columns: 30px 1fr auto; }\n /* Spectrogram strip - inline below the row header. Click on the row\n toggles open/closed independent of playback so you can scan\n spectrograms visually without listening. */\n .rec-spectro {\n grid-column: 1 / -1;\n margin-top: 8px;\n height: 0; overflow: hidden;\n border-radius: 4px;\n /* Paper background - the spectrogram canvas paints ink ON it,\n matching the sketch palette. */\n background: var(--paper-2);\n box-shadow: var(--recess);\n position: relative;\n transition: height 240ms cubic-bezier(.7,.05,.2,1);\n }\n .rec-row.expanded .rec-spectro { height: 88px; }\n /* The spectrogram is rendered client-side onto a canvas - we fetch\n the mp3, decode via Web Audio, run an STFT, and paint columns\n with our ink palette. No BirdNET-Pi PNG involved. */\n .rec-spectro canvas {\n position: absolute; inset: 0;\n width: 100%; height: 100%;\n display: block;\n opacity: 0; transition: opacity 240ms ease;\n }\n .rec-spectro canvas.ready { opacity: 1; }\n .rec-spectro .rec-spectro-loading {\n position: absolute; inset: 0;\n display: flex; align-items: center; justify-content: center;\n color: var(--ink-soft);\n font: 9px var(--av-font-mono); letter-spacing: 0.14em;\n text-transform: lowercase;\n pointer-events: none;\n }\n /* Played portion - soft ink wash over the paper ground. */\n .rec-spectro .rec-spectro-played {\n position: absolute; left: 0; top: 0; bottom: 0; width: 0%;\n background: linear-gradient(90deg, rgba(26,22,18,0.16), rgba(26,22,18,0.06));\n pointer-events: none;\n }\n /* Cursor - sliding paper pill with a raised shadow, same recipe as\n the time-window slider's seg-pill. Pops against both the bright\n paper regions of the spectrogram and the inked vocalization\n bands. */\n .rec-spectro .rec-spectro-cursor {\n position: absolute; top: 50%; left: 0;\n width: 14px; height: 20px;\n transform: translate(-50%, -50%);\n background: var(--paper);\n border-radius: 999px;\n box-shadow: 0 1px 3px rgba(26,22,18,0.30), inset 0 0 0 1px rgba(26,22,18,0.18);\n pointer-events: none;\n opacity: 0;\n }\n .rec-spectro .rec-spectro-cursor::after {\n content: '';\n position: absolute; left: 50%; top: -100%; bottom: -100%;\n width: 1.5px; transform: translateX(-50%);\n background: rgba(251,247,238,0.85);\n box-shadow: 0 0 0 0.5px rgba(26,22,18,0.20);\n }\n .rec-spectro.armed .rec-spectro-cursor { opacity: 1; }\n /* Dark mode: the spectrogram ground is charcoal (paintSpectrogram paints\n with --paper), so the dark played-veil and the --paper cursor pill would\n both vanish into it. Flip the veil light, and make the cursor a light\n pill so it still reads against the dark ground. */\n :host([data-theme=\"dark\"]) .rec-spectro .rec-spectro-played {\n background: linear-gradient(90deg, rgba(236,232,225,0.18), rgba(236,232,225,0.07));\n }\n :host([data-theme=\"dark\"]) .rec-spectro .rec-spectro-cursor {\n background: var(--ink);\n }\n .rec-spectro .rec-spectro-scrub {\n position: absolute; inset: 0;\n cursor: ew-resize;\n }\n .rec-row .play {\n background: var(--paper-2); border: 0; color: var(--ink);\n width: 24px; height: 24px; border-radius: 999px;\n display: inline-flex; align-items: center; justify-content: center;\n box-shadow: var(--raised); cursor: pointer;\n padding: 0;\n }\n .rec-row .play svg { width: 8px; height: 8px; }\n .rec-row .play[data-active=\"true\"] {\n background: var(--ink); color: var(--paper);\n }\n .rec-row .when {\n font: 11px/1.2 var(--av-font-mono); color: var(--ink);\n }\n .rec-row .when small {\n display: block; font-size: 9px; color: var(--ink-soft);\n letter-spacing: 0.06em; margin-top: 1px;\n }\n .rec-row .conf {\n font: 10px var(--av-font-mono);\n color: var(--ink-soft); letter-spacing: 0.06em;\n font-variant-numeric: tabular-nums;\n }\n .rec-empty {\n padding: 14px;\n font: 11px/1.5 var(--av-font-display); color: var(--ink-soft);\n text-align: center;\n }\n .modal-actions {\n display: flex; gap: 8px; margin-top: 18px;\n }\n .modal-actions .chip {\n flex: 0 0 auto;\n background: var(--paper-2);\n border: 0; color: var(--ink);\n padding: 7px 12px;\n border-radius: 4px;\n font: 10px/1 var(--av-font-mono);\n text-transform: lowercase; letter-spacing: 0.12em;\n text-decoration: none;\n display: inline-flex; align-items: center; gap: 5px;\n box-shadow: var(--raised);\n transition: transform 160ms ease;\n }\n .modal-actions .chip:hover { transform: translateY(-1px); }\n .modal-actions .chip.ext::after { content: '↗'; opacity: 0.6; }\n\n /* Spectrogram appears on first audio play. Recoloured via CSS filter\n so BirdNET-Pi's default colour spectrogram comes out as a clean\n monochrome ink strip, matching the rest of the chart. Click to\n replay; a thin progress bar tracks playback in real time. */\n .bird-card .spectro-wrap {\n position: relative;\n min-height: 0; margin: 6px 0 2px;\n cursor: pointer;\n overflow: hidden;\n border-radius: 3px;\n box-shadow: var(--recess);\n }\n .bird-card .spectro-wrap:empty { display: none; }\n /* Canvas is painted client-side in the active theme's paper/ink palette\n (paintSpectrogram reads data-theme), so it follows light/dark mode with\n no CSS filter - same renderer as the modal recording spectrograms. */\n .bird-card .spectro-wrap canvas {\n width: 100%; height: 44px; display: block;\n }\n /* Progress wash: a translucent veil that grows as the clip plays. Ink in\n light mode; in dark mode a light veil so it stays visible over the\n charcoal spectrogram ground. */\n .bird-card .spectro-wrap::after {\n content: '';\n position: absolute; top: 0; bottom: 0; left: 0;\n width: var(--prog, 0%);\n background: linear-gradient(to right, rgba(26,22,18,0.30), rgba(26,22,18,0.05));\n pointer-events: none;\n transition: width 60ms linear;\n }\n :host([data-theme=\"dark\"]) .bird-card .spectro-wrap::after {\n background: linear-gradient(to right, rgba(236,232,225,0.26), rgba(236,232,225,0.05));\n }\n /* Playhead - thin vertical line at the leading edge */\n .bird-card .spectro-wrap::before {\n content: '';\n position: absolute; top: 0; bottom: 0;\n left: var(--prog, 0%);\n width: 1px;\n background: var(--ink);\n opacity: var(--playhead, 0);\n pointer-events: none;\n transition: opacity 200ms ease;\n }\n .bird-card[data-playing=\"true\"] .spectro-wrap::before { --playhead: 0.6; }\n\n/* ============ AvianVisitors mode toggle ============\n * Default install (.av-shell.av-local): hide the lock-row, show drawer items\n * directly when the menu opens. The PHP shim at /avian/api/menu.php\n * returns items immediately with no auth, so the live JS skips the lock\n * flow entirely.\n *\n * Forwarded install (body.av-forwarded): show the lock row so users\n * type the basic-auth password before items unlock. Pair with\n * AV_REQUIRE_AUTH=1 in php-fpm env + Caddy basic_auth on /avian/api/.\n * See avian/forwarding/. */\n.av-shell.av-local #dd-locked { display: none; }\n.av-shell.av-local #dd-items { display: block; }\n\n/* ============ Wall-display widgets (HA build) ============\n Clock + weather as a quiet corner block INSIDE the collage view.\n renderCollage() stamps this element's box into the silhouette-packing\n grid, so when the flock grows the birds nest around the numerals the\n same way they nest around each other. Same editorial recipe as the\n rest of the page: a serif display line over a monospace, letterspaced\n micro-caption, on the paper/ink variables so dark theme inverts free.\n pointer-events:none keeps hover/click hit-testing on the birds. */\n.wall-widgets {\n position: absolute; z-index: 6;\n display: flex; flex-direction: column; gap: 10px;\n pointer-events: none;\n}\n.wall-widgets[hidden] { display: none; }\n.wall-widgets[data-corner=\"bottom-right\"] { right: 36px; bottom: 96px; align-items: flex-end; text-align: right; }\n.wall-widgets[data-corner=\"bottom-left\"] { left: 36px; bottom: 96px; align-items: flex-start; text-align: left; }\n.wall-widgets[data-corner=\"top-right\"] { right: 36px; top: 14px; align-items: flex-end; text-align: right; }\n.wall-widgets[data-corner=\"top-left\"] { left: 36px; top: 14px; align-items: flex-start; text-align: left; }\n.ww-block { display: flex; flex-direction: column; gap: 3px; }\n.wall-widgets[data-corner$=\"right\"] .ww-block { align-items: flex-end; }\n.wall-widgets[data-corner$=\"left\"] .ww-block { align-items: flex-start; }\n.ww-block[hidden] { display: none; }\n.ww-big {\n font: 600 clamp(26px, 3cqw, 40px)/1 var(--av-font-display);\n color: var(--ink);\n font-variant-numeric: tabular-nums;\n letter-spacing: 0.01em;\n}\n.ww-cap {\n font: 9.5px/1.45 var(--av-font-mono);\n letter-spacing: 0.16em; text-transform: uppercase;\n color: var(--ink-soft);\n}\n.ww-cap:empty { display: none; }\n.ww-rule {\n width: 44px; height: 1px;\n background: var(--hairline);\n}\n.ww-rule[hidden] { display: none; }\n@container av-card (max-width: 700px) {\n .wall-widgets { gap: 8px; }\n .wall-widgets[data-corner$=\"right\"] { right: 18px; }\n .wall-widgets[data-corner$=\"left\"] { left: 18px; }\n .wall-widgets[data-corner^=\"bottom\"] { bottom: 92px; }\n .ww-big { font-size: 20px; }\n .ww-cap { font-size: 8px; letter-spacing: 0.1em; }\n /* The sunrise/sunset caption is the first thing to go on a phone. */\n #wwSun { display: none; }\n}\n/* Kiosk cursor hiding - applied to by apt.js after idle when the\n wall options ask for it. */\n.av-shell.ww-cursor-hidden, .av-shell.ww-cursor-hidden * { cursor: none !important; }\n\n/* ---- Card build: configured title floats over the collage ---- */\n/* When a card sets a custom title, the heading is lifted out of the\n flex flow and overlaid on the collage top; renderCollage stamps the\n h1's box into the packing grid so birds nest around the words,\n clock-style. Stats/atlas keep headroom so their content doesn't\n slide under it. */\n.av-shell.av-title-overlay .static-head {\n position: absolute; top: 6px; left: 0; right: 0; z-index: 6;\n pointer-events: none; padding: 26px 32px 0;\n}\n.av-shell.av-title-overlay .view#v1,\n.av-shell.av-title-overlay .view#v2 { padding-top: 96px; }\n\n/* Card option: view picker pinned to the top edge instead of bottom. */\n.av-shell.av-picker-top .slider { top: 10px; bottom: auto; }\n.av-shell.av-picker-top .view { padding-bottom: 28px; }\n.av-shell.av-picker-top .view#v0, .av-shell.av-picker-top .view#v1 { padding-bottom: 24px; }\n\n/* ---- Recording-row review flag ---- */\n/* Writes a false-positive review back to BirdNET-Go. A quiet ghost x\n beside the confidence; first tap arms it into a labelled pill, second\n tap confirms - a stray touch can't flag anything. */\n.rec-row .flag {\n justify-self: end;\n min-width: 22px; height: 22px;\n display: inline-flex; align-items: center; justify-content: center;\n background: transparent; border: 0; border-radius: 999px;\n font: 10px/1 var(--av-font-mono); letter-spacing: 0;\n color: var(--ink-soft);\n padding: 0 6px; cursor: pointer; white-space: nowrap;\n transition: color 140ms ease, background 140ms ease;\n}\n.rec-row .flag:hover { color: var(--ink); background: var(--paper-2); }\n.rec-row .flag[data-state=\"armed\"] {\n background: var(--accent-2); color: var(--paper);\n font-size: 9px; letter-spacing: 0.08em; text-transform: uppercase;\n padding: 0 9px;\n}\n.rec-row .flag[data-state=\"done\"] { color: var(--ink); cursor: default; }\n.rec-row .flag[data-state=\"failed\"] {\n color: var(--accent-2); font-weight: 700;\n font-size: 8.5px; letter-spacing: 0.06em; text-transform: uppercase;\n}\n.rec-row .flag:disabled { cursor: default; }\n.rec-row.flagged .when, .rec-row.flagged .conf { text-decoration: line-through; opacity: 0.55; }\n\n/* ---- card-build additions ---- */\n:host {\n display: block; position: relative;\n width: 100%; height: 100%; min-height: 560px;\n overflow: hidden;\n border-radius: var(--ha-card-border-radius, 12px);\n /* The card box is the responsive container all @container rules and\n cq units resolve against. */\n container: av-card / size;\n}\n.av-shell { position: absolute; inset: 0; overflow: hidden; }\n/* Card default: transparent, so the collage sits directly on the HA\n dashboard. background: paper restores the page's warm ground. */\n.av-shell { background: transparent; }\n.av-shell.av-bg-paper { background: var(--paper); }\n/* font: system swaps the editorial serif/mono pairing for HA's own\n typeface (set per-theme by HA; Roboto stock). */\n.av-shell.av-font-system {\n --av-font-display: var(--ha-font-family-body, var(--mdc-typography-font-family, Roboto, \"Segoe UI\", sans-serif));\n --av-font-mono: var(--ha-font-family-body, var(--mdc-typography-font-family, Roboto, \"Segoe UI\", sans-serif));\n}\n/* The view picker hugs the card's bottom edge (the page floats it higher). */\n.slider { bottom: 10px; }\n/* The collage owns the WHOLE card - no reserved band for the picker\n (it's stamped into the packing grid as an obstacle instead), so the\n flock centres in the true middle of the card. */\n.view#v0 { padding: 12px 14px; }\n/* Clock/weather sit a finger's width off the card's true corners. */\n.wall-widgets[data-corner$=\"right\"] { right: 16px; }\n.wall-widgets[data-corner$=\"left\"] { left: 16px; }\n.wall-widgets[data-corner^=\"top\"] { top: 12px; }\n.wall-widgets[data-corner^=\"bottom\"] { bottom: 14px; }\n/* In system-font mode the empty state reads as a normal sentence in the\n configured font instead of editorial letterspaced small-caps. */\n.av-shell.av-font-system .empty {\n font: 14px/1.5 var(--av-font-mono);\n text-transform: none; letter-spacing: 0;\n}\n/* In system-font mode the picker drops the editorial small-caps treatment\n and reads like native HA tabs. */\n.av-shell.av-font-system .slider button {\n font: 500 13px/1 var(--av-font-mono);\n letter-spacing: 0; text-transform: capitalize;\n padding: 9px 16px;\n}\n/* BirdNET-Pi admin chrome has no backend here (same as the static HA build). */\n#menuBtn, #menu-dd, #returnToAtlas, #adminScreen { display: none !important; }\n"; // Generated by avian/scripts/build_masks.py - DO NOT EDIT BY HAND. // DIMS[slug] = [w, h]; MASKS[slug] = { w, h, bits } 1-bit silhouettes @@ -5080,7 +5080,7 @@ function runHABirdApp(__root, __shell, __cardConfig, __imgBase) { // you copied the artwork locally (homeassistant/install.sh layout). var HABIRD_CDN_ASSETS = 'https://cdn.jsdelivr.net/gh/adamoberley/HABirdDashboard@HABirdDashboard/avian/assets/'; -var HABIRD_VERSION = '1.2.6'; +var HABIRD_VERSION = '1.2.7'; var HABIRD_EDITOR_SCHEMA = [ { name: 'dashboard', type: 'expandable', flatten: true, title: 'Dashboard', expanded: true, schema: [ diff --git a/homeassistant/card/build.js b/homeassistant/card/build.js index ec35f8a9..f728c51e 100644 --- a/homeassistant/card/build.js +++ b/homeassistant/card/build.js @@ -197,7 +197,7 @@ const wrapper = ` // you copied the artwork locally (homeassistant/install.sh layout). var HABIRD_CDN_ASSETS = 'https://cdn.jsdelivr.net/gh/adamoberley/HABirdDashboard@HABirdDashboard/avian/assets/'; -var HABIRD_VERSION = '1.2.6'; +var HABIRD_VERSION = '1.2.7'; var HABIRD_EDITOR_SCHEMA = [ { name: 'dashboard', type: 'expandable', flatten: true, title: 'Dashboard', expanded: true, schema: [ diff --git a/homeassistant/www/styles.css b/homeassistant/www/styles.css index e51cfaab..1d33889e 100644 --- a/homeassistant/www/styles.css +++ b/homeassistant/www/styles.css @@ -1276,7 +1276,15 @@ } .modal-card { position: relative; - max-width: 920px; max-height: calc(100vh - 48px); + /* Cap to the overlay, NOT the viewport. #detail-modal is inset:0 with + 24px padding, so 100% here == its content box == the old + calc(100vh - 48px) on a normal viewport - but robustly. In the card + build 100vh becomes 100cqh, and when Home Assistant gives the card an + indefinite height (masonry / content-sized slots) Blink resolves cqh + to 0, so calc(100cqh - 48px) collapsed to 0 and the modal shrank to a + strip of padding (Chrome / HA companion WebView only; Firefox & Safari + resolved it fine). % of the definite inset:0 overlay sidesteps that. */ + max-width: 920px; max-height: 100%; width: 100%; background: var(--paper); border-radius: 14px; diff --git a/package-lock.json b/package-lock.json index fdedac2c..3d03f1f6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "habird-dashboard", - "version": "1.2.6", + "version": "1.2.7", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "habird-dashboard", - "version": "1.2.6", + "version": "1.2.7", "license": "CC-BY-NC-SA-4.0", "devDependencies": { "jsdom": "^24.0.0" diff --git a/package.json b/package.json index 37f0594f..69d11b46 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "habird-dashboard", - "version": "1.2.6", + "version": "1.2.7", "private": true, "description": "Bird Card - a live bird collage card for Home Assistant, fed by BirdNET-Go", "license": "CC-BY-NC-SA-4.0",