refined readme
This commit is contained in:
@@ -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
@@ -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.
|
|
||||||
@@ -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 [
|
||||||
|
|||||||
@@ -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
File diff suppressed because one or more lines are too long
+11
-11
@@ -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
@@ -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;
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
Reference in New Issue
Block a user