refined readme

This commit is contained in:
Twarner491
2026-05-28 15:23:26 -07:00
parent 87981a62fb
commit ec167eac7b
8 changed files with 154 additions and 220 deletions
-2
View File
@@ -76,8 +76,6 @@ See [`avian/forwarding/`](avian/forwarding/) for three independent recipes:
- **Home Assistant REST sensor** that exposes the latest detection. - **Home Assistant REST sensor** that exposes the latest detection.
- **MQTT bridge** that publishes every new detection. - **MQTT bridge** that publishes every new detection.
If anyone other than you can reach the Pi at the network layer — a shared apartment LAN, dorm wifi, an open hotspot, a forwarded port — read [`SECURITY.md`](SECURITY.md) first. The default install assumes a trusted home network; the admin overlay's settings/system/logs panels need a Caddy basic_auth gate before they're safe outside that.
--- ---
## Repo layout ## Repo layout
-64
View File
@@ -1,64 +0,0 @@
# Security
AvianVisitors is designed for a Raspberry Pi listening on a trusted home network. If anyone other than you can reach the Pi at the network layer — a shared apartment LAN, dorm wifi, an open hotspot, a forwarded port — read this first.
## What the default install leaves open
The public-facing endpoints (the collage HTML, the read-only birdnet-api.php aggregations, cutout/spectrogram/recording PHPs, the wiki proxy) are intentionally unauthenticated. They serve display data and a few thousand bytes of JSON each. Treat them as public.
The **admin endpoints** are not safe to expose to untrusted networks:
- `/avian/api/config.php` — reads and writes a whitelisted slice of `birdnet.conf` and restarts the analyzer when settings change.
- `/avian/api/birdnet-status.php` — returns system metrics (CPU, mem, disk, uptime), service state, journalctl output, and accepts `?action=restart` to bounce a whitelisted unit.
By default these are reachable on the same `http://birdnet.local/` listener as everything else. On a shared LAN, anyone on the same network can hit them. The frontend's "lock screen" is cosmetic without a Caddy basic_auth gate in front.
## What can go wrong
The upstream BirdNET-Pi installer drops a `caddy ALL=(ALL) NOPASSWD: ALL` sudoers rule (see `scripts/install_services.sh`). PHP-FPM runs as the `caddy` user. That means anything reachable from a PHP shim has full root via `sudo`.
The shims here use a tight allowlist for `systemctl restart` and `journalctl -u`, and they validate every input against a whitelist + `escapeshellarg`. The string fields written to `birdnet.conf` are escaped against `$`, backtick, backslash, and double-quote, and rejected outright if they contain anything outside `[A-Za-z0-9 _.,'-]`. So even on a flat-trust LAN, the public-by-default admin endpoints don't *currently* offer an obvious path to RCE.
But the surface is wide. New shims, future config keys, or a regression in `quote_val` would be enough. The safe default is: **lock the admin endpoints down on every install that isn't a fully trusted home network**.
## Locking it down
Add a Caddy basic_auth block in front of the admin shims. Example, dropped into the bottom of `/etc/caddy/Caddyfile` (or a snippet in `conf.d/`):
```
basicauth /avian/api/config.php* /avian/api/birdnet-status.php* {
birdnet $2a$14$... # caddy hash-password --plaintext '<your-password>'
}
```
Then in `/etc/avian/env` (or your php-fpm pool env block):
```
AV_REQUIRE_AUTH=1
```
The env flag makes the PHP shims refuse to respond unless an `Authorization` header reached them — which it will, when basic_auth in Caddy passes the request through, and won't, when basic_auth rejects it.
Reload Caddy and php-fpm:
```
sudo systemctl reload caddy
sudo systemctl reload "$(systemctl list-unit-files 'php*-fpm.service' --no-legend | awk '{print $1; exit}')"
```
After that:
- The collage and read-only APIs still serve to anyone.
- The admin overlay's settings/system/logs/tools panels prompt for the password the first time the drawer opens, then the browser caches it.
- `curl http://birdnet.local/avian/api/birdnet-status.php?action=diag` returns 401 without credentials.
If you also need the Pi reachable from outside the home network (Cloudflare Tunnel, reverse proxy, port-forward), the basic_auth gate above is the bare minimum — consider also limiting `/avian/api/config.php` and `/avian/api/birdnet-status.php` to specific source IPs at the Caddy layer, or moving them onto a separate listener that's only bound to a Tailscale interface.
## What I'd love a contribution on
- Auto-generating a unique basic_auth password during `install_services.sh`, writing it to a file the user reads once after install, and emitting the matching Caddy block — so the secure default is the path of least resistance.
- A smaller-radius sudoers replacement that drops the blanket `caddy ALL=(ALL) NOPASSWD: ALL` rule and grants only the specific commands AvianVisitors actually needs.
## Reporting
If you find a security issue, open a GitHub issue with the `security` label or email `teddy@theodore.net`. There's no bug bounty — this is a side project — but I'll prioritize a fix and credit you in the release notes.
+1 -1
View File
@@ -114,7 +114,7 @@ function read_audio(): array {
for ($i = 0; $i < count($lines); $i += 2) { for ($i = 0; $i < count($lines); $i += 2) {
$head = trim($lines[$i]); $head = trim($lines[$i]);
$detail = isset($lines[$i + 1]) ? trim($lines[$i + 1]) : ''; $detail = isset($lines[$i + 1]) ? trim($lines[$i + 1]) : '';
$cards[] = $detail !== '' ? "$head $detail" : $head; $cards[] = $detail !== '' ? "$head - $detail" : $head;
} }
$usb = shellout('lsusb'); $usb = shellout('lsusb');
return [ return [
+2 -2
View File
@@ -47,8 +47,8 @@ if ($file !== '') {
exit; exit;
} }
// BirdNET-Pi names the spectrogram as the FULL mp3 filename plus // BirdNET-Pi names the spectrogram as the FULL mp3 filename plus
// ".png" - e.g. "American_Crow-82--20:25:29.mp3" pairs with // ".png" - e.g. "American_Crow-82-...-20:25:29.mp3" pairs with
// "American_Crow-82--20:25:29.mp3.png" (not "-20:25:29.png"). // "American_Crow-82-...-20:25:29.mp3.png" (not "...-20:25:29.png").
// Accept either form gracefully. // Accept either form gracefully.
if (substr($file, -4) === '.png') { if (substr($file, -4) === '.png') {
$png = $file; $png = $file;
+93 -93
View File
File diff suppressed because one or more lines are too long
+11 -11
View File
@@ -51,12 +51,12 @@
</header> </header>
<div class="views" id="views"> <div class="views" id="views">
<!-- ========== View 1 Collage ========== --> <!-- ========== View 1 - Collage ========== -->
<section class="view" id="v0" aria-label="Bird collage"> <section class="view" id="v0" aria-label="Bird collage">
<div class="gcollage" id="collage"></div> <div class="gcollage" id="collage"></div>
</section> </section>
<!-- ========== View 2 Stats ========== --> <!-- ========== View 2 - Stats ========== -->
<section class="view" id="v1" aria-label="Stats"> <section class="view" id="v1" aria-label="Stats">
<div class="stats-grid"> <div class="stats-grid">
<div class="stats-timeline" id="statsTimeline"> <div class="stats-timeline" id="statsTimeline">
@@ -86,7 +86,7 @@
</div> </div>
</section> </section>
<!-- ========== View 3 Atlas ========== --> <!-- ========== View 3 - Atlas ========== -->
<section class="view" id="v2" aria-label="Atlas"> <section class="view" id="v2" aria-label="Atlas">
<div class="atlas-controls"> <div class="atlas-controls">
<div class="atlas-sort" id="atlasSort" role="tablist" aria-label="sort atlas"> <div class="atlas-sort" id="atlasSort" role="tablist" aria-label="sort atlas">
@@ -150,17 +150,17 @@
</div> </div>
</div> </div>
<div class="modal-info"> <div class="modal-info">
<h2 id="modalCommon"></h2> <h2 id="modalCommon">-</h2>
<p class="sci" id="modalSci"></p> <p class="sci" id="modalSci"></p>
<div class="modal-stats"> <div class="modal-stats">
<div><span class="n" id="modalAllTime"></span><span class="lbl">all time</span></div> <div><span class="n" id="modalAllTime">-</span><span class="lbl">all time</span></div>
<div id="modalWindowStat"><span class="n" id="modalWindow"></span><span class="lbl" id="modalWindowLbl">window</span></div> <div id="modalWindowStat"><span class="n" id="modalWindow">-</span><span class="lbl" id="modalWindowLbl">window</span></div>
<div><span class="n" id="modalFirstSeen"></span><span class="lbl">first heard</span></div> <div><span class="n" id="modalFirstSeen">-</span><span class="lbl">first heard</span></div>
</div> </div>
<p class="desc placeholder" id="modalDesc">Loading description</p> <p class="desc placeholder" id="modalDesc">Loading description...</p>
<div class="modal-meta"> <div class="modal-meta">
<span class="meta-item"><span class="k">genus</span><span class="v" id="modalGenus"></span></span> <span class="meta-item"><span class="k">genus</span><span class="v" id="modalGenus">-</span></span>
<span class="meta-item"><span class="k">rarity</span><span class="v" id="modalRarity"></span></span> <span class="meta-item"><span class="k">rarity</span><span class="v" id="modalRarity">-</span></span>
</div> </div>
</div> </div>
</div> </div>
@@ -170,7 +170,7 @@
<span class="rec-count" id="modalRecCount"></span> <span class="rec-count" id="modalRecCount"></span>
</div> </div>
<ol id="modalRecordings"> <ol id="modalRecordings">
<li class="rec-empty">Loading recordings</li> <li class="rec-empty">Loading recordings...</li>
</ol> </ol>
</div> </div>
<div class="modal-actions"> <div class="modal-actions">
+46 -46
View File
@@ -6,9 +6,9 @@
--ink: #1a1612; --ink: #1a1612;
--ink-2: #4a3f31; --ink-2: #4a3f31;
--ink-soft: #908576; --ink-soft: #908576;
--accent: #4a3f31; /* warm dark grey matches ink-2, no longer red */ --accent: #4a3f31; /* warm dark grey - matches ink-2, no longer red */
--accent-2: #1a1612; /* near-black ink same hue as the title text */ --accent-2: #1a1612; /* near-black ink - same hue as the title text */
/* Depth shadow recipes replace flat 1px borders */ /* Depth shadow recipes - replace flat 1px borders */
--edge: inset 0 0 0 1px rgba(255,255,255,0.55), 0 0 0 1px rgba(26,22,18,0.04); --edge: inset 0 0 0 1px rgba(255,255,255,0.55), 0 0 0 1px rgba(26,22,18,0.04);
--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); --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);
--recess: inset 0 1px 2px rgba(26,22,18,0.06), inset 0 0 0 1px rgba(26,22,18,0.04); --recess: inset 0 1px 2px rgba(26,22,18,0.06), inset 0 0 0 1px rgba(26,22,18,0.04);
@@ -26,7 +26,7 @@
/* ============ Stage / views ============ /* ============ Stage / views ============
Stage is a flex column: a static title block sits on top, the views Stage is a flex column: a static title block sits on top, the views
slider fills the remaining height. The title NEVER moves when the slider fills the remaining height. The title NEVER moves when the
view changes only its text fades when the new view's title differs view changes - only its text fades when the new view's title differs
from the current one. */ from the current one. */
.stage { .stage {
position: fixed; inset: 0; overflow: hidden; position: fixed; inset: 0; overflow: hidden;
@@ -74,7 +74,7 @@
.view#v1 { display: flex; flex-direction: column; } .view#v1 { display: flex; flex-direction: column; }
@media (max-width: 700px) { .view { padding: 8px 18px 132px; } .view#v0, .view#v1 { padding: 8px 16px 88px; } } @media (max-width: 700px) { .view { padding: 8px 18px 132px; } .view#v0, .view#v1 { padding: 8px 16px 88px; } }
/* ============ Top bar window picker (left) + menu button (right). ============ /* ============ Top bar - window picker (left) + menu button (right). ============
Visible on every view; the picker is the universal time-window control. */ Visible on every view; the picker is the universal time-window control. */
.top { .top {
position: fixed; top: 22px; left: 0; right: 0; position: fixed; top: 22px; left: 0; right: 0;
@@ -106,7 +106,7 @@
} }
.menu-btn:hover { transform: translateY(-1px); } .menu-btn:hover { transform: translateY(-1px); }
.menu-btn:active { transform: translateY(0); } .menu-btn:active { transform: translateY(0); }
/* Compact top bar on mobile the time-window pill goes from /* Compact top bar on mobile - the time-window pill goes from
spacious to thumb-tight without losing legibility. */ spacious to thumb-tight without losing legibility. */
@media (max-width: 700px) { @media (max-width: 700px) {
.top { top: 12px; padding: 0 12px; } .top { top: 12px; padding: 0 12px; }
@@ -121,7 +121,7 @@
.top .window-pick button { font-size: 8px; padding: 5px 7px; letter-spacing: 0.08em; } .top .window-pick button { font-size: 8px; padding: 5px 7px; letter-spacing: 0.08em; }
} }
/* ============ Bottom slider recessed track, raised active pill ============ */ /* ============ Bottom slider - recessed track, raised active pill ============ */
.slider { .slider {
position: fixed; bottom: 28px; left: 50%; transform: translateX(-50%); position: fixed; bottom: 28px; left: 50%; transform: translateX(-50%);
display: flex; gap: 0; padding: 4px; z-index: 30; display: flex; gap: 0; padding: 4px; z-index: 30;
@@ -139,7 +139,7 @@
.slider button:hover { color: var(--ink); } .slider button:hover { color: var(--ink); }
.slider button[aria-current="true"] { color: var(--ink); } .slider button[aria-current="true"] { color: var(--ink); }
/* Shared sliding pill JS animates transform and width to the /* Shared sliding pill - JS animates transform and width to the
active button's position. The pill is the only element with the active button's position. The pill is the only element with the
raised-paper look; buttons just toggle their text colour. */ raised-paper look; buttons just toggle their text colour. */
.seg-pill { .seg-pill {
@@ -188,7 +188,7 @@
color: var(--ink-soft); letter-spacing: 0.04em; color: var(--ink-soft); letter-spacing: 0.04em;
} }
.lock-err { color: var(--accent); } .lock-err { color: var(--accent); }
/* Tiny "built by teddy" attribution appears under the lock-screen /* Tiny "built by teddy" attribution - appears under the lock-screen
password field AND at the foot of the about-card. Same monospace password field AND at the foot of the about-card. Same monospace
micro-type used by .lock-hint so the two contexts read consistent. */ micro-type used by .lock-hint so the two contexts read consistent. */
.built-by { .built-by {
@@ -232,7 +232,7 @@
color: var(--ink); text-transform: uppercase; color: var(--ink); text-transform: uppercase;
margin: 0 0 8px 4px; margin: 0 0 8px 4px;
} }
/* Collapsible section header is a button row with caret on the right. /* Collapsible section - header is a button row with caret on the right.
Default state is collapsed so accidental clicks don't touch settings. */ Default state is collapsed so accidental clicks don't touch settings. */
.menu-section.collapsible > .section-header { .menu-section.collapsible > .section-header {
display: flex; align-items: center; justify-content: space-between; display: flex; align-items: center; justify-content: space-between;
@@ -292,7 +292,7 @@
background: var(--paper); background: var(--paper);
} }
/* Slider title row (label + value badge with breathing room) and /* Slider - title row (label + value badge with breathing room) and
a track row underneath. Value sits at the far right with a wide a track row underneath. Value sits at the far right with a wide
gap so it never crowds the label text. */ gap so it never crowds the label text. */
.slider-row { .slider-row {
@@ -365,7 +365,7 @@
box-shadow: var(--raised); box-shadow: var(--raised);
} }
/* Inline live-audio player sits at the top of the unlocked drawer. /* Inline live-audio player - sits at the top of the unlocked drawer.
A single play/stop button with a quiet running-indicator dot. */ A single play/stop button with a quiet running-indicator dot. */
.live-audio { .live-audio {
display: flex; align-items: center; gap: 12px; display: flex; align-items: center; gap: 12px;
@@ -403,7 +403,7 @@
} }
.live-audio button svg { width: 9px; height: 9px; } .live-audio button svg { width: 9px; height: 9px; }
.live-audio button + button { margin-left: 4px; } .live-audio button + button { margin-left: 4px; }
/* Live spectrogram hidden until the user clicks LISTEN. We /* Live spectrogram - hidden until the user clicks LISTEN. We
collapse via max-height (not display:none) so the canvas inside collapse via max-height (not display:none) so the canvas inside
keeps its layout dimensions and paints correctly on expand. */ keeps its layout dimensions and paints correctly on expand. */
.live-spectro { .live-spectro {
@@ -429,7 +429,7 @@
} }
.menu-links a.full { grid-column: 1 / -1; } .menu-links a.full { grid-column: 1 / -1; }
/* External BirdNET pages small grid of cards */ /* External BirdNET pages - small grid of cards */
.menu-links { .menu-links {
display: grid; grid-template-columns: 1fr 1fr; gap: 6px; display: grid; grid-template-columns: 1fr 1fr; gap: 6px;
margin: 4px 0 2px; margin: 4px 0 2px;
@@ -465,7 +465,7 @@
} }
.menu-save-row button:disabled { opacity: 0.4; cursor: default; } .menu-save-row button:disabled { opacity: 0.4; cursor: default; }
/* ============ View 1 Collage (Galliformes-style poster) ============ /* ============ View 1 - Collage (Galliformes-style poster) ============
Viewport-fit, no scroll. The shared static-head provides the title; Viewport-fit, no scroll. The shared static-head provides the title;
this view's content is just the cluster of bird cutouts which fills this view's content is just the cluster of bird cutouts which fills
the remaining viewport height. */ the remaining viewport height. */
@@ -479,11 +479,11 @@
overflow: visible; /* let drop-shadow extend past the bbox */ overflow: visible; /* let drop-shadow extend past the bbox */
transition: transform 130ms ease; transition: transform 130ms ease;
/* Events are handled at the .collage level via alpha-mask /* Events are handled at the .collage level via alpha-mask
hit-testing the tile rectangles must not intercept them. */ hit-testing - the tile rectangles must not intercept them. */
pointer-events: none; pointer-events: none;
} }
/* Hover state is driven by JS alpha-mask hit-testing (.is-hover), /* Hover state is driven by JS alpha-mask hit-testing (.is-hover),
NOT the CSS :hover pseudo the rectangular tile bounding boxes NOT the CSS :hover pseudo - the rectangular tile bounding boxes
overlap, so :hover would light up the wrong bird. .is-hover only overlap, so :hover would light up the wrong bird. .is-hover only
ever sits on the one tile whose silhouette is actually under the ever sits on the one tile whose silhouette is actually under the
cursor. */ cursor. */
@@ -491,7 +491,7 @@
.gtile img { .gtile img {
width: 100%; height: 100%; object-fit: contain; display: block; width: 100%; height: 100%; object-fit: contain; display: block;
/* Cutouts arrive as alpha-bbox-cropped transparent-bg PNGs, so /* Cutouts arrive as alpha-bbox-cropped transparent-bg PNGs, so
object-fit: contain leaves no whitespace inside the tile the object-fit: contain leaves no whitespace inside the tile - the
bird touches all four sides. Soft drop shadow keeps it readable bird touches all four sides. Soft drop shadow keeps it readable
on paper. */ on paper. */
filter: drop-shadow(0 2px 6px rgba(26,22,18,0.10)); filter: drop-shadow(0 2px 6px rgba(26,22,18,0.10));
@@ -500,7 +500,7 @@
.gtile.is-hover img { .gtile.is-hover img {
filter: drop-shadow(0 3px 10px rgba(26,22,18,0.26)) saturate(1.1); filter: drop-shadow(0 3px 10px rgba(26,22,18,0.26)) saturate(1.1);
} }
/* Hover pill surfaces the windowed count for the bird under the /* Hover pill - surfaces the windowed count for the bird under the
cursor. Confirms (Anna's-hummingbird-is-#1 paranoia) that the cursor. Confirms (Anna's-hummingbird-is-#1 paranoia) that the
collage scaling reflects the selected window, not all-time. */ collage scaling reflects the selected window, not all-time. */
.collage-tip { .collage-tip {
@@ -530,7 +530,7 @@
text-transform: uppercase; text-transform: uppercase;
} }
/* Page header is now shared at .stage > .static-head see top of CSS. */ /* Page header is now shared at .stage > .static-head - see top of CSS. */
/* Stats grid: charts stack on the LEFT, text sections on the RIGHT. /* Stats grid: charts stack on the LEFT, text sections on the RIGHT.
Each chart is a full-bleed card; each text section is a list with Each chart is a full-bleed card; each text section is a list with
subtle separators. Sized to fit the viewport without scrolling. */ subtle separators. Sized to fit the viewport without scrolling. */
@@ -553,7 +553,7 @@
square positioned vertically by count (y = quantity), columns square positioned vertically by count (y = quantity), columns
ordered by detection time (x = time). Rotated two-line label ordered by detection time (x = time). Rotated two-line label
sits just above its square. Y-axis count ticks on the left, sits just above its square. Y-axis count ticks on the left,
X-axis time labels on the bottom. Fits the viewport never scrolls. */ X-axis time labels on the bottom. Fits the viewport - never scrolls. */
.stats-timeline { .stats-timeline {
position: relative; position: relative;
flex: 1 1 auto; min-height: 0; flex: 1 1 auto; min-height: 0;
@@ -585,7 +585,7 @@
transform: translateX(-50%); transform: translateX(-50%);
cursor: pointer; cursor: pointer;
} }
/* Faint vertical hairlines at every x-axis tick the "no detections /* Faint vertical hairlines at every x-axis tick - the "no detections
in this slice" period reads as the empty space between marks. */ in this slice" period reads as the empty space between marks. */
.stats-tl-gridline { .stats-tl-gridline {
position: absolute; top: 0; bottom: 0; position: absolute; top: 0; bottom: 0;
@@ -601,7 +601,7 @@
} }
.stats-tl-col { cursor: pointer; } .stats-tl-col { cursor: pointer; }
/* Cross-highlight: hovering a square or a table row lights up the /* Cross-highlight: hovering a square or a table row lights up the
matching species on the other side the square scales up and its matching species on the other side - the square scales up and its
label scales + shifts to the accent ink, so the whole column reads label scales + shifts to the accent ink, so the whole column reads
as selected. */ as selected. */
.stats-tl-col.sync-hi .stats-tl-square { .stats-tl-col.sync-hi .stats-tl-square {
@@ -614,7 +614,7 @@
.stats-tl-col.sync-hi .stats-tl-label .com, .stats-tl-col.sync-hi .stats-tl-label .com,
.stats-tl-col.sync-hi .stats-tl-label .sci { color: var(--accent); } .stats-tl-col.sync-hi .stats-tl-label .sci { color: var(--accent); }
/* Two-line species label, rotated to read bottom-to-top like the /* Two-line species label, rotated to read bottom-to-top like the
reference exhibition checklist. Sits just above the square reference exhibition checklist. Sits just above the square -
rotate(-90deg) then translateY(50%) re-centres the stack on the rotate(-90deg) then translateY(50%) re-centres the stack on the
column regardless of label height. Common name and scientific column regardless of label height. Common name and scientific
name sit as two adjacent columns of vertical text. The sync-hi name sit as two adjacent columns of vertical text. The sync-hi
@@ -652,7 +652,7 @@
font: 11px ui-monospace, Menlo, monospace; color: var(--ink-soft); font: 11px ui-monospace, Menlo, monospace; color: var(--ink-soft);
text-transform: lowercase; letter-spacing: 0.18em; text-transform: lowercase; letter-spacing: 0.18em;
} }
/* Trim note shown when the plot can't fit every species column and /* Trim note - shown when the plot can't fit every species column and
falls back to the most-heard subset. A paper background keeps it falls back to the most-heard subset. A paper background keeps it
legible even if a tall label reaches the corner behind it. */ legible even if a tall label reaches the corner behind it. */
.stats-tl-cap { .stats-tl-cap {
@@ -662,7 +662,7 @@
letter-spacing: 0.04em; letter-spacing: 0.04em;
} }
/* (Old chart-card / .stats-charts CSS removed replaced by the /* (Old chart-card / .stats-charts CSS removed - replaced by the
editorial .stats-timeline above.) */ editorial .stats-timeline above.) */
.stats-side { .stats-side {
@@ -675,7 +675,7 @@
letter-spacing: 0.18em; text-transform: uppercase; margin: 0 0 1px; letter-spacing: 0.18em; text-transform: uppercase; margin: 0 0 1px;
border-left: 2px solid var(--accent); padding-left: 10px; border-left: 2px solid var(--accent); padding-left: 10px;
} }
/* Tight, content-sized groups they sit just below their headers /* Tight, content-sized groups - they sit just below their headers
rather than spreading to fill the column. */ rather than spreading to fill the column. */
.stats-side .grp { flex: 0 0 auto; } .stats-side .grp { flex: 0 0 auto; }
.stats-side .grp + .grp { margin-top: 22px; } .stats-side .grp + .grp { margin-top: 22px; }
@@ -700,10 +700,10 @@
.stats-side li .yr { font: 9.5px ui-monospace, Menlo, monospace; color: var(--accent-2); letter-spacing: 0.04em; } .stats-side li .yr { font: 9.5px ui-monospace, Menlo, monospace; color: var(--accent-2); letter-spacing: 0.04em; }
.stats-side li .ct { font: 11px ui-monospace, Menlo, monospace; color: var(--ink-soft); font-variant-numeric: tabular-nums; } .stats-side li .ct { font: 11px ui-monospace, Menlo, monospace; color: var(--ink-soft); font-variant-numeric: tabular-nums; }
/* (Histogram strip removed charts moved into the stats-grid LEFT /* (Histogram strip removed - charts moved into the stats-grid LEFT
column as full-bleed chart cards. See .stats-charts above.) */ column as full-bleed chart cards. See .stats-charts above.) */
/* ============ View 3 Atlas ============ /* ============ View 3 - Atlas ============
Field-guide grid: one card per species, each card holds the cutout, Field-guide grid: one card per species, each card holds the cutout,
names, window/all-time detection counts, and small action chips for names, window/all-time detection counts, and small action chips for
audio playback, Wikipedia, and eBird. */ audio playback, Wikipedia, and eBird. */
@@ -820,10 +820,10 @@
.bird-card .chip.play { padding-left: 7px; } .bird-card .chip.play { padding-left: 7px; }
.bird-card .chip.ext::after { content: '↗'; opacity: 0.6; font-size: 11px; } .bird-card .chip.ext::after { content: '↗'; opacity: 0.6; font-size: 11px; }
/* Selected card highlight matches the page-slider and time-window /* Selected card highlight - matches the page-slider and time-window
pill aesthetic: the active element is a slightly *brighter* paper pill aesthetic: the active element is a slightly *brighter* paper
surface (raised) on top of a slightly *recessed* background. surface (raised) on top of a slightly *recessed* background.
No accent colour, no glowing ring just the same depth shadow No accent colour, no glowing ring - just the same depth shadow
recipe the rest of the chrome uses. */ recipe the rest of the chrome uses. */
.bird-card { transition: transform 280ms cubic-bezier(.7,.05,.2,1), box-shadow 280ms ease, background 200ms ease; } .bird-card { transition: transform 280ms cubic-bezier(.7,.05,.2,1), box-shadow 280ms ease, background 200ms ease; }
.bird-card[data-active="true"] { .bird-card[data-active="true"] {
@@ -857,7 +857,7 @@
/* ============ Admin screen (system / logs / tools) ============ /* ============ Admin screen (system / logs / tools) ============
Overlays the slider when activated via #admin=... hash. Lives Overlays the slider when activated via #admin=... hash. Lives
inside the same shell as the rest of the app so the header, inside the same shell as the rest of the app so the header,
menu button, and time-window pill stay put feels native. */ menu button, and time-window pill stay put - feels native. */
.admin-screen { .admin-screen {
position: fixed; inset: 60px 0 60px 0; position: fixed; inset: 60px 0 60px 0;
background: var(--bg); background: var(--bg);
@@ -879,7 +879,7 @@
color: var(--ink); letter-spacing: 0.005em; color: var(--ink); letter-spacing: 0.005em;
} }
/* Return-to-atlas pill in the same slot the time-window pill /* Return-to-atlas pill in the same slot the time-window pill
normally lives matches its visual language. */ normally lives - matches its visual language. */
.return-to-atlas { .return-to-atlas {
position: fixed; top: 18px; left: 20px; z-index: 6; position: fixed; top: 18px; left: 20px; z-index: 6;
display: none; align-items: center; gap: 6px; display: none; align-items: center; gap: 6px;
@@ -896,7 +896,7 @@
body.admin-on #slider { visibility: hidden; } body.admin-on #slider { visibility: hidden; }
body.admin-on .static-head { opacity: 0; pointer-events: none; } body.admin-on .static-head { opacity: 0; pointer-events: none; }
/* Hide the time-window pill on admin pages (it doesn't apply /* Hide the time-window pill on admin pages (it doesn't apply
there) the return-to-atlas link takes its slot. */ there) - the return-to-atlas link takes its slot. */
body.admin-on #winPick { visibility: hidden; } body.admin-on #winPick { visibility: hidden; }
.return-to-atlas:hover { color: var(--ink); } .return-to-atlas:hover { color: var(--ink); }
.return-to-atlas svg { width: 10px; height: 10px; } .return-to-atlas svg { width: 10px; height: 10px; }
@@ -1020,7 +1020,7 @@
} }
/* When admin screen is shown, the slider underneath is hidden so it /* When admin screen is shown, the slider underneath is hidden so it
doesn't compete for scrolling. The static title gets swapped out doesn't compete for scrolling. The static title gets swapped out
too admin is its own section with its own visual identity. too - admin is its own section with its own visual identity.
Menu button + time-window pill stay visible (still useful). */ Menu button + time-window pill stay visible (still useful). */
body.admin-on #views { display: none; } body.admin-on #views { display: none; }
body.admin-on .nav-band, body.admin-on .nav-band,
@@ -1061,7 +1061,7 @@
Click a bird card → this fades in over a soft ink backdrop with Click a bird card → this fades in over a soft ink backdrop with
the full sketch, taxonomic info, Wikipedia summary, and a the full sketch, taxonomic info, Wikipedia summary, and a
scrollable list of every past audio capture for the species. */ scrollable list of every past audio capture for the species. */
/* /about a brief explainer popup floated over the index page. /* /about - a brief explainer popup floated over the index page.
Reuses .modal-backdrop / .modal-close; opened by the #about hash. */ Reuses .modal-backdrop / .modal-close; opened by the #about hash. */
#about-modal { #about-modal {
position: fixed; inset: 0; z-index: 60; position: fixed; inset: 0; z-index: 60;
@@ -1099,7 +1099,7 @@
font: 400 14.5px/1.62 ui-serif, "Iowan Old Style", Georgia, serif; font: 400 14.5px/1.62 ui-serif, "Iowan Old Style", Georgia, serif;
color: var(--ink-2); color: var(--ink-2);
} }
/* Plain inline link same ink as the prose, a simple underline, no /* Plain inline link - same ink as the prose, a simple underline, no
hover treatment. */ hover treatment. */
.about-card .about-body a { .about-card .about-body a {
color: inherit; color: inherit;
@@ -1140,7 +1140,7 @@
padding: 30px 34px 26px; padding: 30px 34px 26px;
overflow: auto; overflow: auto;
/* The expand-from-card animation is driven by inline transform /* The expand-from-card animation is driven by inline transform
and opacity set on open/close see openDetailModal. CSS and opacity set on open/close - see openDetailModal. CSS
transition handles the in-between glide. */ transition handles the in-between glide. */
transform-origin: 50% 50%; transform-origin: 50% 50%;
will-change: transform, opacity; will-change: transform, opacity;
@@ -1151,7 +1151,7 @@
opacity 220ms ease; opacity 220ms ease;
} }
/* The static aria-hidden=false rule used to override transform. It /* The static aria-hidden=false rule used to override transform. It
now conflicts with our inline FLIP morph removed. The morph now conflicts with our inline FLIP morph - removed. The morph
handler explicitly sets the destination transform. */ handler explicitly sets the destination transform. */
.modal-close { .modal-close {
position: absolute; top: 16px; right: 16px; position: absolute; top: 16px; right: 16px;
@@ -1184,7 +1184,7 @@
transition: opacity 220ms ease; transition: opacity 220ms ease;
} }
.modal-img img.swapping { opacity: 0; } .modal-img img.swapping { opacity: 0; }
/* Pose toggle bottom-left corner of the modal sketch, icon strip. /* Pose toggle - bottom-left corner of the modal sketch, icon strip.
Same recipe as the time-window slider: recessed paper-2 well, a Same recipe as the time-window slider: recessed paper-2 well, a
white sliding pill behind the active button (via .seg-pill), and white sliding pill behind the active button (via .seg-pill), and
a color shift on the icon itself. */ a color shift on the icon itself. */
@@ -1292,7 +1292,7 @@
} }
.rec-row:hover { background: var(--paper-2); } .rec-row:hover { background: var(--paper-2); }
.rec-row.expanded { grid-template-columns: 30px 1fr auto; } .rec-row.expanded { grid-template-columns: 30px 1fr auto; }
/* Spectrogram strip inline below the row header. Click on the row /* Spectrogram strip - inline below the row header. Click on the row
toggles open/closed independent of playback so you can scan toggles open/closed independent of playback so you can scan
spectrograms visually without listening. */ spectrograms visually without listening. */
.rec-spectro { .rec-spectro {
@@ -1300,7 +1300,7 @@
margin-top: 8px; margin-top: 8px;
height: 0; overflow: hidden; height: 0; overflow: hidden;
border-radius: 4px; border-radius: 4px;
/* Paper background the spectrogram canvas paints ink ON it, /* Paper background - the spectrogram canvas paints ink ON it,
matching the sketch palette. */ matching the sketch palette. */
background: var(--paper-2); background: var(--paper-2);
box-shadow: var(--recess); box-shadow: var(--recess);
@@ -1308,7 +1308,7 @@
transition: height 240ms cubic-bezier(.7,.05,.2,1); transition: height 240ms cubic-bezier(.7,.05,.2,1);
} }
.rec-row.expanded .rec-spectro { height: 88px; } .rec-row.expanded .rec-spectro { height: 88px; }
/* The spectrogram is rendered client-side onto a canvas we fetch /* The spectrogram is rendered client-side onto a canvas - we fetch
the mp3, decode via Web Audio, run an STFT, and paint columns the mp3, decode via Web Audio, run an STFT, and paint columns
with our ink palette. No BirdNET-Pi PNG involved. */ with our ink palette. No BirdNET-Pi PNG involved. */
.rec-spectro canvas { .rec-spectro canvas {
@@ -1326,13 +1326,13 @@
text-transform: lowercase; text-transform: lowercase;
pointer-events: none; pointer-events: none;
} }
/* Played portion soft ink wash over the paper ground. */ /* Played portion - soft ink wash over the paper ground. */
.rec-spectro .rec-spectro-played { .rec-spectro .rec-spectro-played {
position: absolute; left: 0; top: 0; bottom: 0; width: 0%; position: absolute; left: 0; top: 0; bottom: 0; width: 0%;
background: linear-gradient(90deg, rgba(26,22,18,0.16), rgba(26,22,18,0.06)); background: linear-gradient(90deg, rgba(26,22,18,0.16), rgba(26,22,18,0.06));
pointer-events: none; pointer-events: none;
} }
/* Cursor sliding paper pill with a raised shadow, same recipe as /* Cursor - sliding paper pill with a raised shadow, same recipe as
the time-window slider's seg-pill. Pops against both the bright the time-window slider's seg-pill. Pops against both the bright
paper regions of the spectrogram and the inked vocalization paper regions of the spectrogram and the inked vocalization
bands. */ bands. */
@@ -1435,7 +1435,7 @@
pointer-events: none; pointer-events: none;
transition: width 60ms linear; transition: width 60ms linear;
} }
/* Playhead thin vertical line at the leading edge */ /* Playhead - thin vertical line at the leading edge */
.bird-card .spectro-wrap::before { .bird-card .spectro-wrap::before {
content: ''; content: '';
position: absolute; top: 0; bottom: 0; position: absolute; top: 0; bottom: 0;
+1 -1
View File
@@ -212,7 +212,7 @@ def main() -> int:
if not ek: if not ek:
print("error: --ebird-region requires --ebird-key or EBIRD_API_KEY", file=sys.stderr) print("error: --ebird-region requires --ebird-key or EBIRD_API_KEY", file=sys.stderr)
return 2 return 2
print(f"[ebird] filtering {len(species)} species against {args.ebird_region}") print(f"[ebird] filtering {len(species)} species against {args.ebird_region}...")
species = ebird_filter(species, args.ebird_region, ek) species = ebird_filter(species, args.ebird_region, ek)
if args.limit: if args.limit: