Swatch-only blue read as unfinished next to the other five fully-
coloured entries. The word now carries the colour like every other
key, same currentColor mechanism as the rest — accepting blue's
~2.4:1 contrast on black as a known, deliberate exception to the
4.5:1 bar everything else here clears.
Colours: "if the intent is to have them all different colors than
lets have them all different colors." The locked eight only give five
safe as full text (blue fails contrast, black's invisible on black),
so the other three get their own treatments instead of sharing plain
ink: Watch gets blue as a swatch-only accent (the button cap's colour,
not the label), Activity gets an ordered-dither swatch (the other
legal way to mark something without a ninth colour), and Settings
alone keeps plain ink deliberately, as the one page with no live
status of its own.
Accessibility (items 1 and 3 from the design review, item 2 skipped
per request — the header's download figure stays live and unmuted):
- Added a skip-link, off-screen until focused, jumping past the
8-item top nav straight to each page's #main-content.
- Renamed the nav's aria-label from "Fastext" (meaningless without
the visual teletext context) to "Page navigation".
Plain ink for Watch/Requests/Activity/Settings read as flat and
unfinished, not deliberate — user feedback: "the new colors are
awful and basically the same color." Requests gets magenta
(--pending) back: not an arbitrary reuse, it's the same colour a
request already carries once approved elsewhere in the app, and
Requests genuinely is "waiting on a human." That's the real ceiling
though — the locked eight teletext colours leave exactly five legible
as text on black (blue fails contrast, black is invisible on black),
so Watch/Activity/Settings still share plain ink rather than a sixth
colour that doesn't exist in this palette.
From a frontend-design skill review of all pages, after last session's
chrome changes (top nav, all-tabs nav, header download row):
- The top/closing fastext nav now mutes the entry matching the current
page (.fastext-here) instead of rendering a link to the page you're
already on.
- Colour in fastext is reserved for the four entries that still carry
a real keyboard shortcut (r/g/y/c); the other four render in plain
ink instead of reusing k-red/k-green/k-yellow/k-cyan for a
destination they don't semantically mean (e.g. Requests no longer
shows in the app's own off-air red).
- Discover's badge slot meant "critic score" on its RT tiles and
"release year" on its new search tiles at the same time. Search
results (Library and Discover) now fold year/held-state into the
sub-line instead, leaving the badge exclusively for critic score.
- System's Transmission rows now carry a small ↗ glyph, since they
silently changed from in-app navigation to opening each service's
own UI in a new tab with no visual cue.
- SABnzbd polling drops from 5s to 20s on pages that only show the
header's download figure (everywhere but System/Activity, which
keep 5s for their own live figures) — a number idle 95% of the time
doesn't need second-level freshness sitewide.
- DESIGN.md's fastext description, now-current per this session's nav
changes.
- Discover's search now hits Radarr's/Sonarr's own lookup endpoints
directly (the same ones /add-to-radarr and /add-to-sonarr already
use), instead of Ombi — one fewer round trip, and a result always
resolves the same way its own Add button will. The Ombi-based
ombi_search/_tmdb_poster helpers are gone.
- The top nav on every page but the index now lists all eight
destinations (FULL_FASTEXT) instead of the original four; the index
keeps the original four-entry row as its own closing line
(HOME_FASTEXT), since its body already lists every destination once.
- System page's Transmission rows are now links to each service's own
reverse-proxied instance (service_urls, already injected into every
template but unused until now).
- system.html crashed with a 500 whenever SABnzbd history loaded: the
trend() macro compared the formatted "avg_speed" string (e.g. "900
KB/s") against a number. Added a real numeric avg_speed_kbps field
and pointed the macro at that instead.
- Replaced "held"/"not held" wording with "in library"/"not in
library" on Home and Discover.
- Moved CLAUDE.md's Deployment/reverse-proxy and account-management
sections into two on-demand skills (deploy-arr-summary,
manage-accounts) so they only load when actually relevant, with a
one-line pointer left in their place.
- Updated PRODUCT.md's restructure note now that meridian-marquee has
merged to main.
- Dropped two stale/broken entries from settings.local.json's
permission allowlist (a rule from a different machine/path, and one
with a corrupted token).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NBw1QodG8ZMYHrGeQfqyDS
Replaces the neutral dark card-based theme with "Page 100": teletext's
locked eight colours at full saturation on pure black, numbered pages
reachable by typing the number (or the r/g/y/c fastext keys), and no
borders, boxes, or radius anywhere — grouping is carried by full-bleed
colour fields and row weight instead.
Chosen via the impeccable skill's replacement-world flow: seven visual
candidates derived from the audience's own world, a mandatory roll
assigned Ceefax/US-cable-guide teletext, six catalog challengers weighed
against it, and the user locked the assigned direction on the decision
page (seed aab4510e).
- New templates for all nine pages (home/watch/library/discover/
requests/system/activity/login/settings) behind a shared _shell.html
carrying the direction contract and _macros.html for the row/bar/tile
vocabulary; deletes the old single-page dashboard.html and its
dedicated stylesheets/script.
- static/css/base.css + teletext.css: the locked palette, a two-height
type scale (Chivo Mono / DotGothic16), and the five disciplines the
system is held to (no in-between tone, no enclosure, one shared bar
ruler, trend only where real history exists, pages weighted by state).
- static/js/service.js: header clock, page-number jump, fastext keys,
two-tap arm/confirm add, guarded live polling — replaces dashboard.js.
- app.py: nav_items now carry page numbers; fastext defaults added to
the page-chrome context processor; version bumped to 2.0.0.
- DESIGN.md + .impeccable/design.json regenerated from the built world
by the documenter pass; PRODUCT.md and CLAUDE.md updated to describe
the new system in place of the retired one.
- Fixed a live bug found along the way: settings.html called
url_for('dashboard'), an endpoint removed earlier in the branch, which
would have 500'd the Settings page.
- Fixed a One-Ruler violation the finish review's recapture request
surfaced: --bar-cells disagreed with the bar() macro's hardcoded cell
count below the 760px breakpoint, so a fill could land mid-cell on a
phone. Removed the mobile override; the ruler is now 20 cells at every
width, matching the macro unconditionally.
Passed two inspection rounds (desktop + true-390px mobile, via an
iframe harness since macOS Chrome clamps --window-size) and a finish
review; QA screenshots and interview logs are gitignored as scratch,
not shipped in the repo.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NBw1QodG8ZMYHrGeQfqyDS
The dashboard had one shared password and no concept of a user. This
adds real accounts: a username field on the login page, per-user
credentials, and the signed-in user recorded on the session, which is
the groundwork for per-user permissions later.
Credentials are salted pbkdf2 hashes in settings.json's `users` map.
Three deliberate choices there:
- settings.json rather than an env var, because a pbkdf2 hash contains
'$' and docker compose interpolates that when it reads .env. It's also
already the bind-mounted file that survives `up -d --build`.
- pbkdf2 rather than Werkzeug's default scrypt, which needs a hashlib
built against OpenSSL — the macOS system Python has no hashlib.scrypt
and would raise at hash time.
- load_users() re-reads the file per check, so adding a user or changing
a password is live in every gunicorn worker with no restart. The old
_runtime_password was per-worker and only ever half-applied.
Multi-user activates only once an account exists. Until then the
original LOGIN_PASSWORD path is used unchanged and the username field is
hidden, so deploying this can't lock the existing install out.
manage_users.py prompts for passwords via getpass — never echoed, never
a CLI argument — and only ever writes hashes; it refuses to remove the
last account, which would silently drop the dashboard back to the shared
password.
Two fixes carried along: login no longer requires the LOGIN_PASSWORD env
var specifically, so a settings.json-only password works instead of
locking you out; and the legacy comparison now uses hmac.compare_digest,
which the old docstring claimed but '==' didn't provide.
Also gitignores settings.json and data/ — they hold API keys and now
password hashes, were excluded from the deploy rsync but not from git.
Verified: legacy fallback still signs in with no accounts configured; a
9-case credential matrix (right/wrong user x right/wrong password, plus
the retired shared password); per-user change-password scoped to the
signed-in account and live without restart; and the add/passwd/remove
guards.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
app.py's dev server and gunicorn both hardcoded 5000, which is awkward
locally — macOS ControlCenter already holds that port, so the app can't
bind on the dev laptop without editing source.
Both now read PORT, defaulting to 5000 so EXPOSE, the compose
`${HOST_PORT}:5000` mapping and the deployed container are unchanged.
Read at run time rather than import time so a dev harness can assign a
free port.
Also tracks .claude/launch.json with autoPort set. It doesn't actually
launch on this machine — the preview sandbox can't read files under
~/Desktop — so CLAUDE.md notes that running from a shell with an
explicit PORT is the working local path.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The footer version is the honest check that a deploy landed, so cut
1.4.0 for the Storage table.
Also record something that has now cost two sessions: the dev machine
isn't on the media stack's network, so every service is offline and the
live-poll endpoints 500 on a timer. That's the normal local baseline,
not a regression. Notes the monkey-patch pattern for exercising a card's
real rendering, plus the two traps that go with it — Jinja's auto_reload
follows app.debug (off), so template edits need a restart rather than a
browser reload, and LOGIN_PASSWORD has to be set explicitly.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Ten disks stacked at --row-h each turned one third of the "Right now"
strip into a ~950px tower. The row is a grid, so Now playing and
Downloads stretched to match and held roughly 1500px of empty card
between them.
The information needed width, not a shorter list. "Right now" drops to
two columns and Storage moves to a full-width row beneath it, where the
same ten disks are a six-column table — disk, used, size, free, usage
bar, temp — at ~270px total. Nothing is hidden and nothing scrolls
except the table itself, which gets its own overflow container below
720px rather than wrapping the sizes into mush.
Also: the array total moves to a bar above the table and the summary
into the card header, so the hero figure isn't repeating what every row
already says. The header summary is a sentence rather than a badge, so
it needs .card-meta-wrap to opt out of the global nowrap — without it
that one string pushed the whole page sideways on a phone.
Local-fallback (no per-disk data) and error states keep the previous
hero-figure treatment. CLAUDE.md now carries the dead-space rule this
came from.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Three inline style="..." attributes were pure styling with no per-request
data behind them (a hero-block spacing tweak, a trailing note's margin, a
gap between two stacked rows) — moved each into dashboard.css as a scoped
structural selector so future adjustments happen in one place instead of
being split between the template and the stylesheet.
Also bumped .stream-title/.stream-user/.stream-icon in the Now Playing
card one step up the existing --fs-* scale (13px->15px, 12px->13px,
15px->18px) rather than introducing a one-off size.
Documented the "styles belong in CSS, not inline" rule in CLAUDE.md,
with the actual exception: per-request computed values (a meter's width,
a threshold colour) and JS-toggled initial visibility still can't live
in a static stylesheet and are fine to stay inline.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
The architecture notes still described one :root token block shared
inside the three templates, which stopped being true when CSS and JS
moved to static/. Document base.css as the single source of truth, the
four-hue + color-mix rule that replaced hardcoded literals, and the
<body data-*> convention for passing server config to JS.
Also documents the footer version line under the container-restart
section it exists to serve — it is the quickest honest check that a
deploy landed, given the Unraid Docker tab's cached container IDs.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Records what wasn't discoverable from the repo alone after today's
outage: Cox blocks inbound port 80 so certs must use DNS-01, the apex
proxy host's destination has drifted before, the NPM UI can show a cert
as valid/in-use while nginx presents none on disk, and restarting via
the Unraid Docker UI throws "No such container" after every deploy
because Compose gives the container a fresh ID each time.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
The previous commit made Settings-saved API keys override .env, but the
key still never survived: settings.json was written to the container
filesystem, and every deploy runs `up -d --build`, which recreates the
container and discards it. Saving a rotated SABnzbd key and restarting
therefore still fell back to the stale .env value.
SETTINGS_FILE is now configurable and pointed at /data/settings.json on
a ./data bind mount, so runtime settings live on the host. The deploy
rsync also excludes data/ — it runs --delete and would otherwise wipe
the directory for not being in the repo, reintroducing the same bug.
This affected everything the Settings page writes, not just API keys:
timezone and a UI-changed login password were being reverted on every
deploy too.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Replaces the five novelty themes with one professional dark theme built
on a shared token set (surfaces, 6-step type scale, semantic colours,
44px tap target) used across dashboard, login and settings.
Layout now matches the cache tiers in app.py: a "Right now" strip with
live streams, the download queue and array usage sits above the fold,
with library, discovery, requests and watch insights below. Previously
the page opened on rarely-actionable disk stats and buried active
streams under twenty recommendation rows.
Also from the design review:
- Discover lists show 5 with a Show more toggle instead of 10 each
- RT descriptions render inline and clamped, not hover-only tooltips
that touch devices could never reach
- Adding a title is now two-step (tap to arm, tap to confirm) since it
queues a real download
- role=alert on errors, aria-live on polled regions, semantic dl/dt/dd
stats, labelled search inputs
- every text/surface pair verified at >= 5:1 contrast
Drops the dead delete-modal JS, which threw a TypeError on every load,
and the stale root-level template copies that Flask never rendered.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>