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.
- **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
-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) {
$head = trim($lines[$i]);
$detail = isset($lines[$i + 1]) ? trim($lines[$i + 1]) : '';
$cards[] = $detail !== '' ? "$head $detail" : $head;
$cards[] = $detail !== '' ? "$head - $detail" : $head;
}
$usb = shellout('lsusb');
return [
+2 -2
View File
@@ -47,8 +47,8 @@ if ($file !== '') {
exit;
}
// BirdNET-Pi names the spectrogram as the FULL mp3 filename plus
// ".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").
// ".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").
// Accept either form gracefully.
if (substr($file, -4) === '.png') {
$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>
<div class="views" id="views">
<!-- ========== View 1 Collage ========== -->
<!-- ========== View 1 - Collage ========== -->
<section class="view" id="v0" aria-label="Bird collage">
<div class="gcollage" id="collage"></div>
</section>
<!-- ========== View 2 Stats ========== -->
<!-- ========== View 2 - Stats ========== -->
<section class="view" id="v1" aria-label="Stats">
<div class="stats-grid">
<div class="stats-timeline" id="statsTimeline">
@@ -86,7 +86,7 @@
</div>
</section>
<!-- ========== View 3 Atlas ========== -->
<!-- ========== View 3 - Atlas ========== -->
<section class="view" id="v2" aria-label="Atlas">
<div class="atlas-controls">
<div class="atlas-sort" id="atlasSort" role="tablist" aria-label="sort atlas">
@@ -150,17 +150,17 @@
</div>
</div>
<div class="modal-info">
<h2 id="modalCommon"></h2>
<h2 id="modalCommon">-</h2>
<p class="sci" id="modalSci"></p>
<div class="modal-stats">
<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><span class="n" id="modalFirstSeen"></span><span class="lbl">first heard</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><span class="n" id="modalFirstSeen">-</span><span class="lbl">first heard</span></div>
</div>
<p class="desc placeholder" id="modalDesc">Loading description</p>
<p class="desc placeholder" id="modalDesc">Loading description...</p>
<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">rarity</span><span class="v" id="modalRarity"></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>
</div>
</div>
</div>
@@ -170,7 +170,7 @@
<span class="rec-count" id="modalRecCount"></span>
</div>
<ol id="modalRecordings">
<li class="rec-empty">Loading recordings</li>
<li class="rec-empty">Loading recordings...</li>
</ol>
</div>
<div class="modal-actions">
+46 -46
View File
@@ -6,9 +6,9 @@
--ink: #1a1612;
--ink-2: #4a3f31;
--ink-soft: #908576;
--accent: #4a3f31; /* warm dark grey matches ink-2, no longer red */
--accent-2: #1a1612; /* near-black ink same hue as the title text */
/* Depth shadow recipes replace flat 1px borders */
--accent: #4a3f31; /* warm dark grey - matches ink-2, no longer red */
--accent-2: #1a1612; /* near-black ink - same hue as the title text */
/* 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-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);
@@ -26,7 +26,7 @@
/* ============ Stage / 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
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. */
.stage {
position: fixed; inset: 0; overflow: hidden;
@@ -74,7 +74,7 @@
.view#v1 { display: flex; flex-direction: column; }
@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. */
.top {
position: fixed; top: 22px; left: 0; right: 0;
@@ -106,7 +106,7 @@
}
.menu-btn:hover { transform: translateY(-1px); }
.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. */
@media (max-width: 700px) {
.top { top: 12px; padding: 0 12px; }
@@ -121,7 +121,7 @@
.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 {
position: fixed; bottom: 28px; left: 50%; transform: translateX(-50%);
display: flex; gap: 0; padding: 4px; z-index: 30;
@@ -139,7 +139,7 @@
.slider button:hover { 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
raised-paper look; buttons just toggle their text colour. */
.seg-pill {
@@ -188,7 +188,7 @@
color: var(--ink-soft); letter-spacing: 0.04em;
}
.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
micro-type used by .lock-hint so the two contexts read consistent. */
.built-by {
@@ -232,7 +232,7 @@
color: var(--ink); text-transform: uppercase;
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. */
.menu-section.collapsible > .section-header {
display: flex; align-items: center; justify-content: space-between;
@@ -292,7 +292,7 @@
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
gap so it never crowds the label text. */
.slider-row {
@@ -365,7 +365,7 @@
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. */
.live-audio {
display: flex; align-items: center; gap: 12px;
@@ -403,7 +403,7 @@
}
.live-audio button svg { width: 9px; height: 9px; }
.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
keeps its layout dimensions and paints correctly on expand. */
.live-spectro {
@@ -429,7 +429,7 @@
}
.menu-links a.full { grid-column: 1 / -1; }
/* External BirdNET pages small grid of cards */
/* External BirdNET pages - small grid of cards */
.menu-links {
display: grid; grid-template-columns: 1fr 1fr; gap: 6px;
margin: 4px 0 2px;
@@ -465,7 +465,7 @@
}
.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;
this view's content is just the cluster of bird cutouts which fills
the remaining viewport height. */
@@ -479,11 +479,11 @@
overflow: visible; /* let drop-shadow extend past the bbox */
transition: transform 130ms ease;
/* 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;
}
/* 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
ever sits on the one tile whose silhouette is actually under the
cursor. */
@@ -491,7 +491,7 @@
.gtile img {
width: 100%; height: 100%; object-fit: contain; display: block;
/* 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
on paper. */
filter: drop-shadow(0 2px 6px rgba(26,22,18,0.10));
@@ -500,7 +500,7 @@
.gtile.is-hover img {
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
collage scaling reflects the selected window, not all-time. */
.collage-tip {
@@ -530,7 +530,7 @@
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.
Each chart is a full-bleed card; each text section is a list with
subtle separators. Sized to fit the viewport without scrolling. */
@@ -553,7 +553,7 @@
square positioned vertically by count (y = quantity), columns
ordered by detection time (x = time). Rotated two-line label
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 {
position: relative;
flex: 1 1 auto; min-height: 0;
@@ -585,7 +585,7 @@
transform: translateX(-50%);
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. */
.stats-tl-gridline {
position: absolute; top: 0; bottom: 0;
@@ -601,7 +601,7 @@
}
.stats-tl-col { cursor: pointer; }
/* 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
as selected. */
.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 .sci { color: var(--accent); }
/* 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
column regardless of label height. Common name and scientific
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);
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
legible even if a tall label reaches the corner behind it. */
.stats-tl-cap {
@@ -662,7 +662,7 @@
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.) */
.stats-side {
@@ -675,7 +675,7 @@
letter-spacing: 0.18em; text-transform: uppercase; margin: 0 0 1px;
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. */
.stats-side .grp { flex: 0 0 auto; }
.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 .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.) */
/* ============ View 3 Atlas ============
/* ============ View 3 - Atlas ============
Field-guide grid: one card per species, each card holds the cutout,
names, window/all-time detection counts, and small action chips for
audio playback, Wikipedia, and eBird. */
@@ -820,10 +820,10 @@
.bird-card .chip.play { padding-left: 7px; }
.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
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. */
.bird-card { transition: transform 280ms cubic-bezier(.7,.05,.2,1), box-shadow 280ms ease, background 200ms ease; }
.bird-card[data-active="true"] {
@@ -857,7 +857,7 @@
/* ============ Admin screen (system / logs / tools) ============
Overlays the slider when activated via #admin=... hash. Lives
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 {
position: fixed; inset: 60px 0 60px 0;
background: var(--bg);
@@ -879,7 +879,7 @@
color: var(--ink); letter-spacing: 0.005em;
}
/* 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 {
position: fixed; top: 18px; left: 20px; z-index: 6;
display: none; align-items: center; gap: 6px;
@@ -896,7 +896,7 @@
body.admin-on #slider { visibility: hidden; }
body.admin-on .static-head { opacity: 0; pointer-events: none; }
/* 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; }
.return-to-atlas:hover { color: var(--ink); }
.return-to-atlas svg { width: 10px; height: 10px; }
@@ -1020,7 +1020,7 @@
}
/* When admin screen is shown, the slider underneath is hidden so it
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). */
body.admin-on #views { display: none; }
body.admin-on .nav-band,
@@ -1061,7 +1061,7 @@
Click a bird card → this fades in over a soft ink backdrop with
the full sketch, taxonomic info, Wikipedia summary, and a
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. */
#about-modal {
position: fixed; inset: 0; z-index: 60;
@@ -1099,7 +1099,7 @@
font: 400 14.5px/1.62 ui-serif, "Iowan Old Style", Georgia, serif;
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. */
.about-card .about-body a {
color: inherit;
@@ -1140,7 +1140,7 @@
padding: 30px 34px 26px;
overflow: auto;
/* 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. */
transform-origin: 50% 50%;
will-change: transform, opacity;
@@ -1151,7 +1151,7 @@
opacity 220ms ease;
}
/* 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. */
.modal-close {
position: absolute; top: 16px; right: 16px;
@@ -1184,7 +1184,7 @@
transition: opacity 220ms ease;
}
.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
white sliding pill behind the active button (via .seg-pill), and
a color shift on the icon itself. */
@@ -1292,7 +1292,7 @@
}
.rec-row:hover { background: var(--paper-2); }
.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
spectrograms visually without listening. */
.rec-spectro {
@@ -1300,7 +1300,7 @@
margin-top: 8px;
height: 0; overflow: hidden;
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. */
background: var(--paper-2);
box-shadow: var(--recess);
@@ -1308,7 +1308,7 @@
transition: height 240ms cubic-bezier(.7,.05,.2,1);
}
.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
with our ink palette. No BirdNET-Pi PNG involved. */
.rec-spectro canvas {
@@ -1326,13 +1326,13 @@
text-transform: lowercase;
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 {
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));
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
paper regions of the spectrogram and the inked vocalization
bands. */
@@ -1435,7 +1435,7 @@
pointer-events: none;
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 {
content: '';
position: absolute; top: 0; bottom: 0;
+1 -1
View File
@@ -212,7 +212,7 @@ def main() -> int:
if not ek:
print("error: --ebird-region requires --ebird-key or EBIRD_API_KEY", file=sys.stderr)
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)
if args.limit: