Commit graph

2307 commits

Author SHA1 Message Date
Roland
a4db2aceac
ci: test on Python 3.13, the version every deployment actually runs
CI has been testing 3.11 while nothing ships it.  docker/api/Dockerfile and
docker/help/Dockerfile are python:3.13-trixie, docker/controller is
python:3.13-slim-trixie, Trixie's system interpreter is 3.13, and Debian 12 --
the last place 3.11 was current -- is pinned to the debian12-final tag and takes
no new code.  So every Python job here exercised an interpreter no user has.

All four CircleCI Python jobs and the GitHub Actions controller job move to 3.13.
The cache key goes with them: it stores the whole venv, so restoring a
deps-3.11 venv into a 3.13 container would hand it an interpreter that no longer
exists.

The disabled api-tests-3-12 job is deleted rather than repointed.  It was
switched off with the note "Sanic does not yet support 3.12", which 3.13 disproves,
and carrying a second version around is what let this drift open in the first
place.

Verified on 3.13.12 / pytest 9.0.3 before changing anything: the backend suite
passes (1709 passed, 5 skipped), alembic upgrade runs and `db check` reports no
new operations, and the controller suite passes bar two docker-mode dashboard
tests that fail identically on 3.11 here -- pre-existing and environment-dependent,
not a 3.13 regression.

`sanic-api-start` is the one job I could not verify locally: wrolpi/api_utils.py
sets `Sanic.start_method = "fork"` and macOS has defaulted to spawn since 3.8, so
that job fails here on any Python version.  Linux CI is unaffected -- fork is
still the default there on 3.13 -- and the test suite already runs the Sanic app
in-process, which is the substantive check.  Worth knowing that 3.14 changes the
Linux default away from fork, so that line has a deadline on it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-08 18:05:43 -06:00
Roland
40587ebe98
build: require pytest-timeout, so a hung test fails instead of hanging CI
pytest.ini has carried `timeout = 30` and `timeout_method = signal` with a comment
asking that it not be removed, but pytest-timeout was never declared in
requirements.txt.  CI installs only that file, so the options were unknown there,
pytest warned and carried on, and the timeout has never once applied in CI.  A
hung test ran until CircleCI killed the job, which reports no test name at all --
and when the hang took an xdist worker with it, the rest of that worker's tests
came back as a wall of EOFError, burying the actual cause.

It goes in requirements.txt rather than a dev-only file because this project has
no testing-only dependencies: a WROLPi is expected to run, test, and develop on
the device, so the four Dockerfiles that install this file get it too.

Verified: the unknown-option warnings are gone, a deliberately hung test now fails
with "Timeout (>30.0s) from pytest-timeout" naming the test, and the full suite
passes with the timeout in force -- 1709 passed under -nauto, so no legitimate
test is near the limit locally.

Worth watching: this is a behaviour change for CI, which has never enforced it.
CircleCI runs on 4 vCPU with -nauto, so a test that takes ten seconds here could
take longer there.  If timeouts appear in CI the number is the thing to raise, not
the plugin to drop.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-08 18:05:08 -06:00
Roland
5fd6ba264d
test: check declared types, not just field names, and fix the three that were wrong
Greptile's point on #592 was right and generalized further than the case it
raised.  The guard compared property names only, so it passed while the published
contract disagreed with the endpoint about what those properties contain.

Three wrong types, all pre-existing, all now caught by a test:

StatusResponse.downloads was `int` while the handler has always sent the download
manager's summary object -- the spec told a generated client to expect a number
where it gets a dict.  It is now DownloadsSummaryResponse, with the six fields
get_summary() actually returns.

StatusResponse.wrol_mode was `str`; wrol_mode_enabled() returns a bool.

SettingsResponse.log_level was `str` while the handler sends a Python logging
integer -- SettingsRequest.log_level already said int, and the frontend converts
it numerically through fromApiLogLevel(). settingsFixture was carrying 'info',
which that function maps to undefined, so every Settings test has rendered a blank
log-level label where production shows "Info".  The fixture now carries 20.  This
came from the same hand-written cypress body as cpu_percent did.

statusFixture's nested downloads was also short a field: daily_limit_reached, one
the API has always sent.  /api/status returns an empty downloads object while the
database is down, which it is for much of the suite, so the endpoint cannot vouch
for its own nested shape -- get_summary() is the authority and is now compared
directly.

The type check runs against both endpoints rather than only the one the review
named; that is how log_level surfaced.  Comparison is limited to properties the
spec gives a type for and the handler actually sent, since a null from an optional
field says nothing about its declared type.

Fixture values are still compared by name only: they are read from JS source
rather than executed, so their types are not reliably available.  Said plainly in
the test rather than left to look like coverage.

1709 pytest, 986 jest, 46 e2e.  One unrelated flake in one backend run (the known
filename-formatting tests under -nauto), clean across two further full runs.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-08 18:05:08 -06:00
Roland
fb4677bcd6
ci: a named check for fixture drift, and run jest on pull requests again
Two gaps, one of them wide.

The contract tests added in c349e15c already ran inside `pytest -nauto`, but
fixture drift is silent by nature -- the frontend suite passes against a body the
API stopped sending -- so it deserves a check whose name says what broke.
`api-contract-tests` restores the same dependency cache as the other Python jobs,
skips the ffmpeg/catdoc install it does not need (verified), and runs the one
file, so it answers in under a minute instead of somewhere inside seventeen
hundred results.

The wide one: jest-tests.yml triggered on `pull_request: branches: [master]`, so
a pull request into anything else ran neither jest nor the webpack build.  The
entire Semantic UI to Mantine migration went in that way -- seventy-five files
across nine pull requests into feature/custom-theme, not one of which ran either.
The build step is the one that matters most: jest and tsc both pass on a named
import that does not exist and only webpack rejects it, which is exactly how a
broken Archive.js survived a "clean" run earlier in this migration.  The suite
did run on the branch after each merge, which is the wrong side of the merge to
learn about it.

`pull_request` is now unfiltered rather than listing branches, so the next
long-lived branch does not have to remember to add itself.  It stays
`pull_request`, not `pull_request_target`: a fork's run gets a read-only token
and no secrets, and this workflow interpolates no event data, so widening the
trigger adds no injection or exfiltration path.

Worth knowing separately: none of this gates anything.  master has no branch
protection and the only ruleset covers `release` (deletion, non-fast-forward), so
every check here is advisory.  That is a repository setting, and Roland's to make.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-08 18:05:08 -06:00
Roland
1f2fcce089
test: hold the frontend fixtures to the API's own OpenAPI spec
The React specs stub API responses with app/src/test-fixtures.js, and nothing
connected those to the API.  They were a snapshot of whatever each endpoint
returned when someone last wrote one out by hand, and they had drifted.

The chain is now guarded in two links, because the spec was not trustworthy
either:

    handler  <->  OpenAPI spec  <->  test-fixtures.js

What the guards found on their first run:

/api/settings publishes a spec declaring hotspot_status and throttle_status,
which moved to the Controller and which the handler had long stopped returning --
/docs was promising callers two fields the endpoint never sends -- while omitting
four it does send.  settingsFixture was missing seven real fields, so any spec
intercepting that endpoint handed the app a body with seven holes in it and every
assertion about the rest still passed.

/api/status was further gone: the spec declared seven fields the handler does not
return and omitted seven it does.  statusFixture was missing seventeen and
invented two -- cpu_percent and memory_percent, which no endpoint has ever
returned and no component has ever read.  They came from a hand-written cypress
body and had been copied onwards from there, including by me last week.

Both response dataclasses now describe what the handlers actually return.  Half
of /api/status is assembled from shared_ctx.status, which is what made it look
undeclarable, but that dict is seeded with a fixed set of keys in contexts.py and
the worker only ever updates those -- so it is declarable, and now declared.

Uncoverable fixtures are listed rather than left looking covered: domainFixture
and tagFixture stand in for endpoints whose @openapi.definition carries a
description and no response=, so the spec promises nothing about their bodies,
and fileWorkerStatusFixture has no declared shape at either end because that
context's default is null by design (useFileWorkerStatus relies on it to detect a
missing provider).  Declaring a response on one of those endpoints is how its
line comes off the list.

Both links were verified by adding a field to the handler and watching them fire
in sequence: first the spec check, then -- once the schema was updated -- the
fixture check.  The fixture reader itself had a bug the guards caught: its regex
excluded digits, so it reported is_rpi4 and is_rpi5 as missing from a fixture
that had them.  Its own guard now covers that.

1706 pytest, 986 jest, 46 e2e.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-08 18:04:04 -06:00
Roland
40a3886eaf
test: the theme drives the colour scheme, and mockModule takes the module
Both of Greptile's findings on #591 were real, and the first was a plain miss:
mockModule shipped with no caller, so its documented usage had never once been
executed and could not have worked.  It called jest.requireActual on a path
passed in by the spec, which resolves relative to test-utils.js rather than to
the caller -- for any spec in a subdirectory the documented
'../hooks/customHooks' pointed outside src altogether.  It now takes the module
the spec has already resolved, and throws on a string rather than quietly
resolving to the wrong one, which was the original failure.

It also has a caller now.  FileBrowser.test.js listed six hooks in a hand-written
factory and returned only those, so every other export of customHooks was
undefined for anything it rendered -- the exact landmine the helper exists to
remove.

The second: `inverted` drove Mantine's colour scheme on its own, so asking for
night or amber through the new theme option produced a tree that reported a dark
theme while Mantine rendered its light one.  Production cannot reach that
combination, and a harness that invents states the app never has is worse than
one that is merely incomplete -- what a test proves about such a tree is true of
nothing.  The scheme is now derived from the theme name via isDarkTheme, and
data-theme is stamped on <html>, without which a component test cannot exercise
any token table or the night-mode treatment at all.  isDark remains overridable
so a spec can still make the two disagree deliberately.

test-utils.test.js covers the harness itself, which had none.  Eight of its
twelve assertions were red against the code above.

986 jest, 46 e2e, clean cache-busted build, tsc clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-08 18:04:04 -06:00
Roland
17d658fc0b
test: one place for fixtures, real providers instead of mocked context modules
Specs had no shared vocabulary for the app's data or its contexts, so each one
rebuilt both.  A settings object was written out by hand wherever one was needed,
twenty-odd keys at a time, and nothing held those copies to each other or to the
API.  Contexts were worse: to render a consumer a spec mocked the whole context
module, which replaces every export including the providers, so each spec
reinvented the pieces it still needed.  One supplies a `Media` that always
reports desktop -- quietly leaving that component's mobile branch untested -- and
one hand-writes a context as a bare `_currentValue` with no provider, working only
because useContext reads that React internal.

test-fixtures.js is now the single definition of both, shared by jest, the
Cypress component specs, and e2e intercept bodies, so the three cannot disagree
about what the API returns.  Every fixture is a function taking overrides; a
shared mutable object lets one test change what the next one sees.

The drift this prevents was already real.  The theme fixture supplied `i`, `s`
and `t` holding hardcoded Semantic greys for months after ThemeProvider stopped
supplying them, and ThemeContext still declared all four.  So the fixture agreed
with the tests, and with nothing else: FileBrowser was reading `inverted` off the
context and pasting the resulting `undefined` into a className, which is why the
file browser's footer -- near-white #f9fafb, with a `.inverted` dark variant that
nothing could select any more -- stayed light in dark, night and amber.  A white
bar pinned to the bottom of the screen in night mode costs the user exactly the
dark adaptation the theme exists to protect.  It takes tokens now.

test-fixtures.test.js holds each fixture to its context's declared shape in both
directions, so a property added to one and forgotten in the other fails a test.

Two failed attempts are recorded in test-utils.js because both were instructive.
Importing Tags.js and FileWorkerStatusContext.js so the harness could supply
those providers broke eight suites: it put both into the module graph of every
spec ahead of that spec's own mocks, and components bound to a second instance of
a mocked module -- a different jest.fn() than the test had configured, so a hook
mocked to return a value returned undefined, in a file no test mentions.
Requiring them lazily fixed that and not its mirror image: useContext read a
different TagsContext than the harness had required, so the provider was ignored
and the context default won.  A harness must not change what a spec imports, so
it owns only contexts/contexts.js and a spec passes any other context itself.

DomainTagging is converted off the `_currentValue` fake as proof, and now asserts
the tag name a user sees rather than a test id its own mock invented.  The five
remaining specs are listed in a ratchet so the count can only fall.

974 jest, 15 browser component, 46 e2e, clean cache-busted build, tsc clean.
Fixtures are confirmed absent from the shipped bundle.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-08 18:04:04 -06:00
Roland
79a7a475fb
ui: name the directory fields, announce their help text, let long prefixes shrink
Greptile's three findings on #590, all confirmed real before fixing, and each one
verified red against the original code afterwards.

P1, the directory fields had no accessible names.  All five rendered their
visible label as a bare <label> with no htmlFor while passing no label prop, so
their inputs had a description ("/media/wrolpi/") and no name at all -- five
fields in one grid, holding similar relative paths, indistinguishable to a
screen reader.  The labels now belong to the inputs, InfoPopup content and all.
This predates PathInput; the old TextInput call sites had the same gap, and
moving to a component that supports labels is what made it fixable.

P1, description and error were rendered but never referenced.  aria-describedby
pointed only at the prefix, so the help text listing the variables a path
accepts, and the reason a path was rejected, were both invisible to assistive
technology.  Both are now in the description, and an errored field reports
aria-invalid.

P2 was worse than reported: at `flex: none` a long prefix took the entire row
and left the input eighteen pixels wide -- not "consumes the editable area" but
a field nobody can type in.  The media directory is whatever the API reports, so
its length was never ours to assume.  The prefix now shrinks and ellipsises at
half the control, with the full path on `title` and in the accessible
description.

The naming fault could not have been caught where the others were.  jsdom cannot
see layout, and the Settings page needs enough mocking that it had no test at
all -- which is why these five fields were uncovered when they broke.  The new
e2e spec intercepts the API and lets the real page render, so it measures the
names and the geometry a user actually gets.  Reverting one field to a bare
label fails it; a long prefix fails the browser spec at 18px.

962 jest, 15 browser component, 46 e2e, clean cache-busted build, tsc clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-08 18:04:04 -06:00
Roland
00d12e22e9
ui: a browser layer for the library, and the .media reset that never applied
The PathInput overlap could not have been caught by jest -- jsdom computes no
layout -- so the only guard was a grep for one spelling of one mistake.  This
adds the layer that can actually see it, and it found a real bug immediately.

ui-layout.cy.js mounts library components in a real browser through a new
cy.mountUI(), which supplies Mantine's provider and a stamped data-theme and
nothing else.  That spartan setup is the point: these components read no
ambient state, so a spec can assert what jsdom cannot compute -- real geometry,
resolved custom properties, applied filters -- across all four themes.

It reproduces the leftSectionWidth='auto' overlap on purpose and asserts it
still overlaps, because a geometry check that has never gone red is
indistinguishable from one that cannot fail.

The bug it found: the rule resetting `filter` on leaf media inside a `.media`
wrapper scored (0,2,2) against the (0,3,1) rule it was correcting, so it lost
every time and never applied to anything.  Every image inside a filtered
wrapper has been through the luminance matrix twice since that rule landed --
markedly darker than its surroundings, which is exactly what it was written to
prevent.  tokens.test.js asserted only that the rule's text was present, and so
stayed green throughout; it now compares the two selectors' specificity, which
is computable without a browser and goes red on the old selector.

Three guards in ui.test.js keep the library mock-free by construction rather
than by habit: no jest.mock in its own tests, no fetch in any library file, and
no useContext outside the theme controls, whose job is to read the theme.  All
three were verified red against planted violations.

959 jest tests, 14 new browser tests, clean cache-busted build.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-08 18:04:04 -06:00
Roland
50cf89b20a
ui: PathInput, and the media directory stops printing over the value
The five Special Directories fields on the Settings page were unreadable: the
media directory printed straight through the value, so Archive Directory showed
`a/media/wrolpi/hain_tag)s/%(domain)s`.

Cause: `leftSectionWidth='auto'`.  Mantine turns that prop into
`--input-padding-inline-start`, and `padding-inline-start: auto` is not valid
CSS -- it resolves to zero, so the input reserved no room for the section
positioned on top of it.  It fails only in CSS, only at runtime, and nothing
warns.

Fixed by expressing the field in the library instead of computing a width.
PathInput puts the prefix and the input side by side in a flex row sharing one
outline, so there is no width to get wrong and nothing to overlap -- the failure
is structurally impossible rather than merely corrected.  It also sizes to any
prefix, which matters because the media directory is `/media/wrolpi` on a Pi and
something else under Docker.

Written test-first, and the tests need no mocks at all -- no context, no fetch,
no router, just the provider test-utils already supplies.  That is the point:
Settings.js needs a pile of provider and API mocks, so its inputs were never
covered, which is how five broken fields shipped.  The tests assert the things
that actually matter and can be checked without layout: the prefix is a sibling
rather than a child, the value never contains it, onChange reports only what was
typed, the prefix is the field's accessible description so a screen-reader user
knows what the path is relative to, and disabled reaches the input rather than
just the wrapper.

Plus a source guard, since jsdom cannot see an overlap: nothing may pass
`leftSectionWidth='auto'` or `rightSectionWidth='auto'`.  It strips comments
first -- PathInput documents the mistake by name, and prose about a mistake is
not the mistake.
2026-08-08 18:04:03 -06:00
Roland
8ba73e64cd
build: uninstall Semantic UI
`semantic-ui-react`, `react-semantic-toasts-2` and `semantic-ui-offline` are
gone from package.json, the lockfile and node_modules -- 19 MB of dependencies
that nothing had imported since the migration finished.

Four things still referenced them and would have broken on uninstall.  None was
caught by a build or by jest:

  Four test files still ran `jest.mock('react-semantic-toasts-2', ...)` for
  components that no longer use it.  A jest.mock of a module that does not exist
  throws, so the packages could not have come out without this.

  `src/components/Download.cy.js` and `cypress/support/component.js` both
  imported semantic.min.css.  The support file now loads what the app loads --
  index.css, the font and token stylesheets, and Mantine's.

The ratchet changes job rather than being deleted.  An import of a missing module
fails loudly now, but three quieter mistakes would not: a `className="ui button"`
that styles nothing, a file reaching back through Theme.tsx, and someone
reinstalling a package to "just use one Semantic component".  It asserts all
three, plus that package.json names nothing matching /semantic/i.

Two pre-existing problems in the cypress component harness surfaced while
checking the uninstall had not broken it, both fixed because that file had to be
edited anyway:

  It imported `react-router-dom`, which is not installed -- this app is on
  react-router v7.  Dates to 5621d473, so the component suite has been
  un-runnable since well before this migration.

  Its mount helper supplied no MantineProvider, so every library component threw
  -- the same gap test-utils.js had for jest.

The component specs now load and four pass; eleven assertions are still written
against markup from before they stopped running.  Repairing those is its own
piece of work, deliberately not bundled in here.  The e2e suite is unaffected:
43/43.
2026-08-08 18:04:03 -06:00
Roland
3c82291db3
ui: video cards were missing the mimetype accent
Videos.js has its own VideoCard, separate from the file cards in Files.js, so
restoring the accent there missed it -- archives and documents showed the edge
and videos did not.

Audited every remaining `<Card>` rather than fixing the one that was reported.
The only two without an accent are the map's place card and the loading
placeholder, and neither has a mimetype to colour, so both are correct.

Videos show blue and audio-only entries show violet, which is right rather than
inconsistent: both come from `mimetypeColor`, the same helper that colours the
file icons, so a card and its icon can never disagree.
2026-08-08 18:04:03 -06:00
Roland
9ec81bd199
ui: put the card mimetype accent on the bottom edge
Roland's call, and the better one: along the top the accent sat immediately
against the poster's own top border, so the two edges competed.  On the bottom it
closes the card and reads as a property of the whole thing rather than a header
rule.
2026-08-08 18:04:03 -06:00
Roland
6b1e05be86
ui: bring back the card mimetype accent
Two agents independently dropped it, each because our Card had no `color` prop
and each citing the other's precedent -- which is how a deliberate feature
quietly becomes a convention.  Roland wanted it back.

Card takes a token colour name and draws it as a 3px edge along the top rather
than tinting the surface: surfaces stay flat per the design rules, and an edge
survives night mode without becoming a bright patch.  Because it is a token, the
accent remaps per theme -- four hues in light and dark, a single one in night and
amber, which is the point of those themes.

Restored on every card that shows a file: the video, image and generic file cards
in Files.js, and the archive card.  All of them take it from
`mimetypeColor(mimetype, path)`, the same helper that colours the file icons, so
a card and its icon agree.
2026-08-08 18:04:03 -06:00
Roland
51f9c8a987
ui: the last Semantic UI file, and the stylesheet with it
ControllerPage was the final call site; Theme.tsx then had nothing left to wrap,
so it drops from 861 lines to 338 -- the provider that resolves the theme, stamps
`data-theme` and `data-media-filter`, persists both and hands Mantine our tokens.
Everything below that was Semantic wrappers.

The dark-mode compatibility props went with them.  `i`, `s`, `t` and `inverted`
existed so unmigrated components could borrow Semantic's dark treatment, and
they carried hardcoded greys -- #1B1C1D, #dddddd, #eeeeee -- which is exactly
what the token themes replaced.  Three call sites in Common.js were still
injecting the #eeeeee one into links.

Then `semantic-ui-offline/semantic.min.css` came out of App.js.  That took more
with it than expected, and looking at the running app is the only reason it was
caught:

  Every link lost its colour and gained the browser's underline and visited
  purple.  The dashboard's load averages and section headings were underlined
  and purple.  Links now take `--blue`, which remaps per theme, and underline
  only on hover -- many of them wrap cards, posters and icons.

  `box-sizing: border-box` disappeared, which silently changes the size of every
  padded element in the app.  Semantic's reset had been providing it.

  The navigation bar overflowed a 1000px viewport with `nowrap` and cut "Admin"
  off the right edge -- the one link a user needs to reach the settings that fix
  a broken WROLPi.  Semantic's menu wrapped; ours does now too.

The gzipped CSS bundle is 47.5 kB, and one call site was still leaning on
Semantic's grid classes -- now a flex column, since all three of its callers
pass library components.

The ratchet stays rather than being deleted.  At zero it asserts four things: no
file imports Semantic UI, none calls its toast library, none leans on its
stylesheet, and no file reaches back through Theme.tsx.  It can go when the
packages are uninstalled.

Theme.test.js is deleted, but not before porting the two behaviours it
protected: that Button forwards a ref to the real DOM element (a class-component
ref once crashed the Control page, because portals call `node.contains`), and
that the progress percentage stays readable on every bar colour.
2026-08-08 18:04:03 -06:00
Roland
04544434b0
ui: migrate ControllerPage.js off Semantic UI onto the token component library
Replaces semantic-ui-react and Theme.tsx imports with components from
components/ui (Panel, Table, Modal, Confirm, Status, Select, TextInput,
Checkbox, IconButton, Message). Preserves every confirmation, disabled
state, and polling interval; service status now maps onto Status'
complete/active/pending/failed kinds instead of a raw color. Repairs
ControllerPage.test.js to render inside MantineProvider and to find the
unmount Confirm button by dialog role instead of DOM-order position,
since Mantine portals the modal ahead of the page content. Drops
ControllerPage.js from semantic-baseline.json now that it's migrated.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-08 18:04:03 -06:00
Roland
0bcece43b1
ui: Nav off Semantic, and the memory warning starts working
The navigation bar was the last visibly-Semantic surface, so this finishes the
visual migration.  Its two fragile features survive intact: the navbar colour
still comes from the user's `nav_color` setting, now as `var(--<name>)` since
our token tables define all fourteen Semantic colour names, with text taking
`--btn-text` so every theme stays legible; and the warning-icon contrast swap
is unchanged, including the case where `lowWarningColor` is null and must mean
"no override" rather than `var(--null)`.

Fixed a pre-existing bug the migration exposed: the memory indicator passed its
colour to `Link`, which ignores it, so that icon never changed colour -- 81% and
95% RAM looked identical.  Every sibling indicator colours the Icon; now this
one does too.

The agent that migrated this file also gave names to the warning indicators,
which were unlabelled icons inside links, so a screen reader now announces what
each one is.  The three data-testid hooks are unchanged.

Baseline 15 -> 2.  Only ControllerPage and Theme.tsx remain; Theme.tsx is last
by design, since it can only go once nothing imports its Semantic wrappers.
2026-08-08 18:04:02 -06:00
Roland
0a520001e7
ui: Nav.js off Semantic UI onto the token-driven component library
Replaces semantic-ui-react's Menu/Dropdown/Icon/Popup with plain flexbox
elements plus the Mantine-based Menu (for the mobile hamburger and desktop
"More" overflow), Tooltip, and Icon from ./ui. Nav color and the
warning-icon contrast swap keep their exact logic, now resolved through
CSS custom properties (var(--{navColor}), var(--btn-text)) instead of
Semantic color props. Adds accessible labels to previously-unlabelled
warning icons while preserving their data-testid hooks and threshold/
priority logic. New nav CSS lives in App.css (wrolpi-navbar*).
2026-08-08 18:04:02 -06:00
Roland
19ea268bf8
ui: migrate Flasher.js off Semantic UI React
Replace semantic-ui-react/Theme.js imports with the token-driven ui/
library: Tab -> Tabs, Segment -> Panel, Message/Label/Progress/Loader ->
their ui equivalents, Table.* compound API, Confirm-free flows preserved
exactly. Remove obsolete ThemeContext i/s/t spreads. Drop Flasher.js from
semantic-baseline.json now that it no longer imports Semantic UI.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-08 18:04:02 -06:00
Roland
15fbc78210
ui: three silent library defects the agents' reports surfaced
Each of these was reported by an agent working around it rather than patched in
one file, which is why all three are fixed once, for every call site.

Button accepted an `href` and dropped it.  Mantine renders an anchor only when
it also gets `component='a'`; Semantic spelled that `as='a'`.  So a migrated
call site that kept just the href produced something that looks like a download
link and navigates nowhere -- silently, with no warning.  Button and IconButton
now infer the anchor from the href.

The media filter ran twice on nested media.  A `.media` wrapper filters its
subtree as one unit -- the audio player needs that, because its control glyphs
are SVG and the element-type rule does not reach them -- but the poster image
inside also matched that rule.  Two passes of a luminance-to-red matrix is not
one: the second takes 0.2126 of an already-red pixel, so the poster came out
markedly darker than everything around it.  Leaf media inside a filtered
wrapper is now left alone.

Progress had no indeterminate state.  Semantic's `indicating` animated a stripe
when the size was unknown; without it an upload that has not reported yet sits
at 0% and reads as stalled -- and `aria-valuenow="0"` tells a screen reader the
same wrong thing.  It now animates, reports no value, and falls back to a
static fill under prefers-reduced-motion.

Also: night and amber now override an inline `--label-color`.  A call site may
legitimately need many distinguishable colours -- the Flasher's chip badges use
a twelve-colour palette no four tokens can express -- but on a night-adapted
screen a green or blue chip badge defeats the entire mode.  The labels lose
their distinctness there, which is the right trade.
2026-08-08 18:04:02 -06:00
Roland
06f1ce46bc
ui: migrate Archive.js off Semantic UI React
Replaces every semantic-ui-react and ./Theme import with the token-driven
./ui library: Segment->Panel, Tab/Tab.Pane->Tabs (Tabs.List/Tabs.Tab/
Tabs.Panel), Loader active inline='centered'->Loading, Message/Message.Header
->Message kind/title, Statistic/StatisticValue/StatisticLabel->Statistic
value/label, Card/CardContent/CardHeader/CardMeta/CardDescription->Card
media/title/meta/children, Input label={...}->ActionInput action={...},
Form.Dropdown->Select, Form/Form.Field/TextArea->plain markup + Textarea.

Converts all three Semantic grid spellings (Grid.Row/Grid.Column, bare
GridRow/GridColumn) to flexbox for the two-up rows in ArchivePage and
DomainsPage, and to plain "row"/"column" divs (CollectionEditForm's existing
CSS-class convention, shared with still-unmigrated Channels.js/Playlists.js)
for the fields passed into CollectionEditForm in DomainEditPage.

ArchiveFileNameForm no longer goes through hooks/useForm's InputForm helper,
which still renders Semantic's Form.Input internally (hooks/useForm.js is
off-limits for this migration) -- it now builds its field directly against
form.getInputProps, mirroring the pattern Download.js already established.

Drops ThemeContext's obsolete i/s/t/inverted spreads throughout, and the
mimetype-color accent on Card (the new Card has no color prop; Videos.js's
already-migrated VideoCard dropped the same accent for the same reason).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-08 18:04:02 -06:00
Roland
0fc02377fd
ui: FileBrowser.js and FilePreview.js off Semantic
Migrated both files onto the token-driven ./ui component library: Table,
Modal, Button/IconButton, Checkbox, Header, Panel, Progress, Placeholder,
Skeleton, ActionInput/TextInput, IconStack, Menu/ButtonGroup for the slicer
split-button, and native <img>/<form> in place of Semantic's Image/Form.

Notable adaptations:
- Semantic's per-row `disabled` styling (no visual equivalent in the new
  Table) is reproduced with inline opacity/pointer-events instead.
- DirectorySearch's autoFocus now passes straight through as a prop instead
  of a nested `input={{autoFocus}}` object, matching SearchBox's API.
- The responsive icon+label footer buttons keep a single Button and toggle
  children instead of swapping component type.
- FileBrowser.test.js: two upload-button assertions keyed off a literal
  Semantic `.green` class; broadened to also match on the icon, mirroring
  the sibling assertion already using that fallback.

Preserved as-is (reported, not fixed): getVideoPreviewModal's
`<Header as='h5'>{path['path']}</Header>` indexes a string with `['path']`,
which was already dead/undefined before this migration.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-08 18:04:02 -06:00
Roland
a37f30a703
ui: migrate Downloads, Upload, and BulkTagModal off Semantic
Ports admin/Downloads.js, Upload.js, and BulkTagModal.js to the token-driven
ui library: Table/Modal/Progress/Message/Button/IconButton replace their
Semantic and Theme.tsx equivalents. Failed download rows take the dashed
danger border via Table.Row's `failed` prop; retry/restart actions use
role='retry' and delete/stop actions use role='danger' instead of hardcoded
colors. RecurringDownloadsTable keeps its export name and props for
Archive.js. Updates BulkTagModal.test.js to render through the real
Mantine-backed components instead of mocking the retired ./Theme module, and
drops the three files from semantic-baseline.json now that they no longer
import Semantic UI.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-08 18:04:02 -06:00
Roland
016a47ad64
ui: migrate Docs.js and Zim.js off Semantic UI React
Replace semantic-ui-react and ./Theme imports with ./ui across both
files: Grid -> Group/flexbox (two-up rows), Loader -> Loading, Segment
-> Panel, Statistic/StatisticLabel/StatisticValue -> Statistic +
StatisticGroup, Tab -> Tabs, Accordion -> Mantine Accordion.Item with
controlled string value, Dropdown -> Select, Message/Label/Placeholder
-> ui equivalents. Buttons take role (primary/save/retry/danger/cancel)
instead of Semantic color names where a role fits.

Zim unsubscribe now goes through a destructive Confirm (via APIButton's
confirmContent) instead of firing immediately, matching the
Confirm+destructive rule for irreversible actions; the outdated-Zims
delete flow keeps its rich custom Modal (dynamic file lists) but its
trigger and confirm buttons are styled destructive and labeled "Delete".

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-08 18:04:01 -06:00
Roland
a9dbee6f24
ui: migrate VideoPlayer.js off Semantic UI React
Replaces semantic-ui-react and ./Theme imports with the token-driven ./ui
library: Segment->Panel, Tab->Tabs (Tabs.List/Tab/Panel, keepMounted=false
to preserve per-tab lazy render/fetch), Loader active->Loading, Input->
TextInput, Grid/Grid.Column->Mantine Grid/Grid.Col, Container->plain div,
color=/APIButton->role=. Comment/CommentGroup and Transition have no ui
equivalent, so the comment thread and audio-poster fade are rebuilt from
plain elements/tokens. The audio poster wrapper (custom play/pause/volume
glyphs drawn over the poster image) gets .media so night mode tints the
composite as one unit; the video/audio elements and poster img stay
untagged since they are already filtered leaves. Added ARIA button
semantics to the poster click target since it has no native href/button.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-08 18:04:01 -06:00
Roland
91324c6372
ui: Channels.js and Playlists.js off Semantic
Migrates both files onto the token-driven component library: Grid ->
Mantine Grid/Grid.Col, Segment -> Panel, Message/Header/Modal/Statistic
compounds -> ui equivalents, Semantic Form/Input -> plain form +
TextInput, and button colors -> role. Confirm/delete flows keep their
guards and now report through Button role='danger'/APIButton role.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-08 18:04:01 -06:00
Roland
00408d515d
ui: fix the cypress failures, and two real defects behind them
All 43 cypress specs pass now (was 36 of 43).  Five of the seven failures were
stale selectors, two were real.

Real, and both mine:

  The search modal stopped focusing its input.  Mantine mounts modal content on
  a later tick, so the effect that focused it ran while the ref was still null;
  Semantic's modal mounted synchronously, which is why focusing there had been
  enough.  Fixed declaratively -- the field asks for focus itself, and SearchBox
  also sets `data-autofocus`, which is what Mantine's focus trap honours when it
  takes focus away from a plain autoFocus.

  Escape closed the suggestion list and the enclosing modal at once, so a user
  correcting a mistyped query lost the whole modal.  Escape now dismisses the
  suggestions and stops there; a second Escape, with the list closed, still
  closes the modal.

Stale selectors, all in the specs: Semantic's icon font (`i.search.icon`) is
SVG now, its `.ui.modal`/`.ui.dimmer` are the library's modal and overlay, and
one test asserted a button carried the classes `ui button secondary`.  That last
one is rewritten to check what a user can actually perceive -- that Edit is a
real link pointing at the edit route -- because framework class names say
nothing and break on every library change.

Two accessibility gaps the tests walked into:

  The nav's search control was an anchor with an onClick, no name and no
  keyboard path.  It now has a name, a role and Enter/Space activation, which is
  also what let the spec stop matching on icon-font classes.

  The search dialog had no accessible name at all; it has one now.

One `{force: true}` remains, with its reason recorded: Cypress judges that
field covered because it sits in a fixed modal under a sticky header, and I
verified by hand at Cypress's own viewport that it is the topmost element at
its centre, unobscured, and focused on open.

Also closed a hole in the ratchet that this uncovered.  A file could drop its
`semantic-ui-react` imports, leave the baseline, and still render Semantic
through Theme.tsx's wrappers -- which is exactly how a Semantic modal survived
in the search shortcut long after that file looked migrated.  A test now
forbids it, and the three files doing it (Calculators, KeyboardShortcutsProvider,
and Common.js reaching for theme names) import from the library or from
themes/names instead.
2026-08-08 18:04:01 -06:00
Roland
f8bef9fc23
ui: keep the unmigrated pages' notifications working
Greptile P1, and a real one: App.js migrated and took `<SemanticToastContainer/>`
with it, but seven pages -- Archive, Channels, Docs, FilePreview, Playlists,
Zim, ControllerPage -- still raised their toasts through the Semantic library.
With no container mounted those notifications went nowhere.  No error, no
warning; a user deletes a document and is simply never told whether it worked.

Fixed by pointing those seven at the library's `toast`, which takes the same
options, rather than by mounting a second toast system.  Nothing calls the
Semantic toast library now, so a test asserts that at zero: a file that goes
back to it would raise notifications that silently never appear, and that is
not something a reviewer would spot.

Also reverted Archive.js to its last good state.  An interrupted agent had
migrated part of it -- Panel, Tabs, and Mantine's Grid.Col -- while the rest of
the file still used Semantic's Segment, Tab and Grid.Row, so a single `Grid`
identifier had to be two different components at once.  A half-migrated
1400-line file is worse than an unmigrated one; it goes back on the list and
gets done in one piece.

That break was masked: react-scripts caches ESLint results per file, so the
earlier build passed while Archive.js sat on a stale pass.  Editing its import
invalidated the entry and the errors appeared.  Verified with the cache cleared
this time, which is the only way that check means anything after a wide change.
2026-08-08 18:04:01 -06:00
Roland
e8dd804010
ui: thirty-five files off Semantic, leaving fifteen
Phase 2, part 4.  Baseline 50 -> 15.

Migrated: all eight calculators, the rest of inventory and collections, the
Map/Zim/Docs cluster, Videos, Files, Search, Archive, DashboardPage,
DatesSelector, HelpModal, Events, Tags, Flasher's neighbours, admin Settings
and Status, Download, and App.

Also the three shared modules everything imports: api.js and customHooks.js
swap the Semantic toast library for the library's shim (same call shape), and
useForm.js renders the library's Message.  useForm accepts both the old
`{content, header, positive|negative}` shape and the new `{kind, title}`,
because the pages building those objects migrate separately.

Two library gaps closed, both found by call sites needing them:

  ActionInput is a forwardRef now.  Its ref reaches the input rather than the
  wrapper, because callers use it to focus or select the field -- a ref to the
  surrounding div would do neither, which is why one call site had resorted to
  hand-copying the component's markup.

  Header takes a `color` token name.  Three separate call sites had reached for
  an inline style to colour a heading that carries meaning ("3 items to add"
  against "2 to remove"), which is how a hex ends up in markup.

Two files needed repair after being interrupted mid-edit: MapViewer had a
Segment left in its error branch, and VideoPlayer had lost the useContext
import its own code still used.  Neither jest nor tsc noticed; only
`npm run build` did, which is the whole reason that check exists.

Fifteen files remain: Archive, BulkTagModal, Channels, Docs, FileBrowser,
FilePreview, Flasher, Nav, Playlists, Theme.tsx, Upload, VideoPlayer, Zim,
ControllerPage, admin/Downloads.  Theme.tsx is deliberately last -- the
unmigrated files still import its Semantic-backed wrappers, so it can only go
once they have.
2026-08-08 18:04:01 -06:00
Roland
87c9bff8ed
ui: fix search suggestion selection, rendering, and clipping
Three findings from review and CI on the Common.js migration, all real.

Selecting a suggestion navigated nowhere.  Semantic handed its callers
`{result}` and every caller destructures that shape, so passing the bare result
left `result.location` undefined -- the search suggestions and the Ctrl+K modal
both stopped navigating.  The shape stays until the call sites can change
together.  (Greptile P1.)

Suggestions lost their custom markup.  Callers pass `resultRenderer` to draw a
tag as a chip rather than a line of text, and the migrated wrapper forwarded it
into a div's props instead of to SearchBox, so every suggestion fell back to the
generic title and description.  SearchBox takes it now.  (Greptile P2.)

The suggestion list clipped its last group.  A cypress spec caught it: with
directories, channels and domains all present, the content runs past the 320px
cap and the final row was cut off.  Eight grouped suggestions is an ordinary
result set, so the cap was simply too low; it is viewport-relative now and
still bounded.

Both regressions are pinned by tests -- one asserting the callback shape a
caller actually destructures, one asserting the custom renderer replaces the
default markup rather than wrapping it.
2026-08-08 18:04:01 -06:00
Roland
2629891ebc
ui: Common.js and thirteen more files off Semantic
Phase 2, part 3.  Baseline 63 -> 50.

Common.js is the one that mattered: 51 files import it, so its Semantic usage
was propping up the rest of the migration.  Everything in it now comes from the
library -- Paginator, the two search inputs, DirectorySearch, TabLinks, the
toggles, the messages, APIButton, Breadcrumbs, SimpleAccordion, IframeViewer,
CardPoster, FileIcon, LoadStatistic, the theme menu, and the hotspot icons.

Also migrated: Placeholder, Apps, ArchivePreview, CbzViewer, AddToPlaylist,
TaggedDeleteConfirmModal, the collection edit/tag forms, and four inventory
components.

New in the library, all in the gallery and tested:

  SearchBox     Semantic's `<Search category>` with its grouped results shape
                kept, because four call sites already build it.  A real
                combobox rather than a div that listens for clicks.
  Pagination    Semantic's prop names; onPageChange gets the page number.
  TabBar        the strip only -- routing stays with the caller.
  IconStack     Semantic's IconGroup, named once for assistive technology.
  Table.HeaderCell sorted/onSort -- a real button carrying aria-sort.
  Button/IconButton translate Semantic's size names.

That last one is defensive: unmigrated callers still pass size='tiny', and
Mantine drops a size it does not recognise without a word, so the button
quietly renders at the wrong size.  Mapping here keeps the mixed state honest.

Three real bugs found and fixed on the way, none of them cosmetic:

  * `fileSuffixIconName` returned fourteen icon names the map did not have, so
    opening a folder with a PDF, a Word document or an archive in it logged an
    error and drew a fallback glyph.  All fourteen mapped, with a test that
    walks the mapper's own return values, and another asserting every mapped
    name resolves to a Tabler component that exists.
  * `Statistic` relied on Semantic's `Statistic.Group` for its spacing, so on
    the dashboard the load averages ran together into "1 MIN. LOAD5 MIN.
    LOAD15 MIN. LOAD".  It carries its own spacing now.
  * Semantic's inverted stylesheet colors every descendant of an inverted
    segment, so Toggle labels inherited #dddddd -- white pixels in night mode,
    on the Settings page, which is exactly where someone adjusts their WROLPi
    in the dark.  Restated from tokens.

The baseline ratchet gained a third assertion: a migrated file may still lean
on Semantic's CSS classes without importing the library, which the import check
cannot see and which would collapse silently when Phase 4 deletes the
stylesheet.  There is one such bridge today and it is now declared.
2026-08-08 18:04:00 -06:00
Roland
3454e042f9
ui: nine leaf files off Semantic, and the components they needed
Phase 2, part 2.  Baseline 72 -> 63.

Migrated: Tables, SortableTable, DonatePage, Share, Headline, ErrorBoundary,
and the QR code, VIN decoder, and temperature calculators.

New in the library, all in the gallery and tested:

  SearchBox     Semantic's `<Search category>`, keeping its grouped `results`
                shape because four call sites already build it.  A real
                combobox: arrows move, Enter takes the highlighted suggestion
                or submits the raw text, Escape closes without clearing.
  Pagination    Semantic's prop names, but onPageChange gets the page number
                rather than an (event, data) pair.
  TabBar        the strip and its house style only; routing stays at the call
                site, which is what decides what is active.
  IconStack     two glyphs composed into one symbol, named once for assistive
                technology (Semantic's IconGroup).
  Table.HeaderCell gains `sorted` and `onSort`.

That last one is a fix, not just a port.  Semantic's `sorted` prop set
`aria-sort`; migrating a table by hand dropped it, leaving a clickable <th>
that no keyboard could reach and no screen reader could describe.  Putting it
in the library means the next table gets it right without anyone remembering.

Night mode holes closed along the way: the QR codes on the donate, share, and
calculator pages are white by necessity — a scanner needs the contrast — and
they render as SVG, which the media filter does not target.  In night mode that
was a lit panel.  They now carry `.media`, so the filter remaps the block as a
unit and the contrast survives the mapping.

Also: `paypal` joins the icon map rather than being imported ad hoc in a page,
and FileBrowser stops passing Semantic-only table props into a DOM element.
2026-08-08 18:04:00 -06:00
Roland
57985cea94
ui: media filtering is a per-theme setting, not a night-mode hardcode
Filtering media was keyed on `data-theme="night"`, which made it a property of
the theme that no user could turn off.  Some people want their media tinted to
match the interface; some want to see the actual image.

A theme now declares a filter and whether it starts on, and the user decides:

  night   night-red    on by default.  An unfiltered thumbnail undoes dark
                       adaptation, which is the whole point of the mode.
  amber   amber-mono   off by default.  Amber is a look, not a night-vision
                       aid, so media keeps its real colors until asked for.
  light   -            no filter offered, so no toggle is shown.
  dark    -

The choice is stored per theme, so wanting amber's tint says nothing about
wanting night's.  The toggle lives in the theme picker, which puts it on the
Settings page and in the gallery at once.

Mechanically the rules now key on a `data-media-filter` attribute rather than
`data-theme`: a rule keyed on the theme could not be turned off.  index.html
stamps that attribute before first paint alongside data-theme -- without it,
night mode paints a screen of unfiltered thumbnails while the bundle arrives
over a hotspot, costing the user the adaptation the theme exists to protect.
Tests cover both the attribute rule and the fact that the inline script's
duplicated ids and defaults still agree with names.ts.

The amber matrix is the same luminance collapse tinted to #ff9500 (red full,
green 0.584, blue zero) with a gentler gamma than night's: amber carries far
more luminance and clips to a flat wash otherwise.

The gallery gains a Media filtering section showing the WROLPi logo filtered,
beside an unfiltered copy that opts out with `night-unfiltered`.  That copy is
behind a click on purpose -- an unfiltered image is a bright patch, and night
mode should not spring one on someone reading in the dark.

ui-design.md gains the media filtering contract it never got in Phase 0,
including the note that a Phase 3 custom theme names an existing filter id and
cannot ship its own matrix, since that would be code rather than data.
2026-08-08 18:04:00 -06:00
Roland
00c52872fa
ui: migrate the extension pages and Configs; five components they needed
Phase 2, part 1.  Three files leave Semantic UI (baseline 75 -> 72), and the
components they needed join the library and the gallery:

  Header       Semantic's shape (as/icon/subheader/dividing).  47 files use
               <Header>, and the level now picks the size, so the type scale
               stays a scale instead of each call site choosing a font size.
  Message      gains an icon and a dismiss button.
  ActionInput  input and button sharing one outline, for <Input action={...}/>.
  Loading      centered loader with a caption, replacing the
               <Loader active inline='centered'> + wrapper div in 19 places.
  Modal        becomes compound - Modal.Header/.Content/.Actions, plus
               Semantic's open, closeIcon, and size names - because 34 files
               are written against that shape.

test-utils mounts MantineProvider with ThemeProvider's exact configuration:
migrated components render Mantine internals and every one of their test files
would otherwise need its own provider.

One Semantic bleed found and fixed: semantic.min.css sets
`h1,h2,h3,h4,h5 { font-family: Lato }` and loads after Mantine's element rule,
so headings ignored the theme's body face.  Only visible in amber, where the
body is mono and the headings were not.  A class-level restatement outranks it;
the rule comes out when Semantic does.

Verified: 925 jest tests, clean production build, and a night-mode audit of
every painted pixel in the gallery (red-only, one known false positive).  The
Configs backup and restore flow was exercised against the real API.
2026-08-08 18:04:00 -06:00
Roland
6ffc5b57c8
ui: night mode actually filters media, and fix three picker icons
Addresses the automated review on #583; all three were real.

The night filter only applied to elements tagged `.media`, and nothing in the
app carried that class - so selecting Night filtered the interface but left
every video, thumbnail, PDF and map full-colour, which is precisely the thing
the mode exists to prevent. The Settings copy promised otherwise, so it was
also wrong.

It now targets the element types instead of an opt-in class, because night mode
has to fail safe: a library of thousands of files reaches the screen through
many components, and one that forgot to opt in would undo half an hour of dark
adaptation. `.media` still works for wrappers that must be filtered as a unit,
and `.night-unfiltered` opts out. The filter is restated on `:fullscreen`, which
a filter on an ancestor stops reaching. Verified on the videos page: all 25
media elements filtered.

Three of the five theme choices rendered a fallback glyph and logged errors,
because the icon map was built by grepping literal name='...' strings and the
navigation picker passes its icon through a variable. Adds sun, moon and
lightbulb, plus a test asserting every theme choice resolves to a real icon.

The pre-paint script wrapped the `matchMedia` fallback in the same try as the
storage read, so a device with storage blocked painted light even when it
prefers dark - recreating the flash the script exists to prevent.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-08 18:04:00 -06:00
Roland
7d88fece2f
ui: theme picker in Settings, and ship the component gallery
The gallery at /theme-sample is now a normal route rather than a development
one, and Settings links to it, so a user can see what a theme actually looks
like before committing to it. Every new component belongs in the gallery: it is
how the design gets reviewed, and how a component that only looks wrong in one
of the four themes gets caught.

Settings gains a Theme section with the new ThemePicker, which explains what
each theme is for - notably that night mode filters videos, documents and maps
to red as well, not just the interface.

Theme names and picker options move to themes/names.ts so the component library
can read them without importing components/Theme.tsx, which still pulls in
Semantic UI. Theme.tsx re-exports them, so existing call sites are unchanged.
The navigation bar's compact picker now reads the same list, so the two cannot
drift apart.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-08 18:04:00 -06:00
Roland
32b5ab391b
ui: token-driven component library on Mantine
Adds src/components/ui: buttons, messages, labels, progress, status, tables,
form controls, panels, cards, statistics, modals, tooltips, tabs, and icons.
Everything resolves its colors through theme tokens, so no component branches
on the current theme, and Semantic UI keeps rendering the app meanwhile.

Mantine is bridged rather than restyled: its own CSS variables are pointed at
our tokens. Four details were not obvious, and each caused a visible defect
in night mode before being fixed:

  - Structural colors must go in the resolver's light/dark blocks. Mantine
    emits its values under [data-mantine-color-scheme], which outranks the
    :root block that `variables` becomes, so its grays were winning.
  - The dark and gray palettes are mapped shade by shade to tokens. Mantine
    reads them directly for table borders, zebra stripes and input chrome;
    they can be neither flattened nor left alone.
  - Mantine writes --button-* as inline styles, which no stylesheet can
    outrank, so the dashed danger treatment sets real properties instead.
  - Switch colors are written onto inner elements, so they need scoped CSS
    rather than a variable on the root. Adds a --knob token for control
    handles, because Mantine's default thumb follows theme.white, which is
    near-black in the dark-based themes.

An audit of every painted pixel in the /theme-sample gallery now finds no
non-red channel in night mode.

Icons come from @tabler/icons-react with the 102 Semantic names the app uses
mapped to components; they stroke with currentColor, so they follow the theme
without any rule of their own. Toasts keep the react-semantic-toasts-2
signature so those call sites migrate by changing an import.

TypeScript 5 is required: Mantine's declarations use `const` type parameters,
which 4.9 cannot parse (Mantine 7 is the same, so downgrading is no escape).
That needs two npm overrides to keep clean installs working, both verified
against a Docker build: react-scripts declares peerOptional typescript ^3||^4,
and fork-ts-checker 6.5.2 assigns typescript.performance.mark, which TS 5
made getter-only - crashing the dev server's type checker on every rebuild.

semantic-baseline.test.js ratchets the removal: it fails if a file not already
listed imports Semantic UI. An ESLint rule would have printed 75 warnings per
rebuild, and CI unsets CI= for its build step, so it would guard nothing there.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-08 18:04:00 -06:00
Roland
806727023d
ui: theme tokens and a four-theme runtime
Every color now resolves through CSS custom properties on
html[data-theme=...], so a theme is a table of values rather than code.
Four themes ship: light, dark, night (red hues only, to preserve dark
adaptation off-grid) and amber (monochrome amber terminal).

Fonts are exposed as two roles, --font-body and --font-mono, which a theme
assigns a bundled face to; amber uses the mono face. Faces must be
self-hosted, never fetched from a CDN, so the app keeps working offline.

Night mode also filters media that cannot consume tokens - video, images,
PDFs, embedded pages, canvases - through an SVG color matrix that maps
luminance onto the red channel. Tag such an element with `.media`; a filter
on an app-level ancestor would create a containing block and break fixed
positioning.

ThemeProvider persists the choice and index.html stamps data-theme from
localStorage before the bundle loads, so the first paint already matches the
saved theme on a slow connection. The OS preference is followed only while
"system" is selected; night and amber are explicit choices. The nav toggle
becomes a five-item picker.

Semantic components still render themselves, so only the shell is
token-driven for now; the inverted props stay in place until each component
is migrated.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-08 18:03:00 -06:00
lrnselfreliance
616424b223
fix: repair CI after the dependency bumps, and align it with the Trixie build (#651)
* fix(app): depend on @testing-library/dom directly

jest-dom 7 moved @testing-library/dom from a bundled dependency to a peer
dependency. Nothing else pulls the top-level copy in — @testing-library/react
13 carries its own nested v8 — so it was present only as a peer, and CI
installs with --legacy-peer-deps, which skips peers. Every suite then failed
to load setupTests.js with "Cannot find module '@testing-library/dom'".

Declare it as a devDependency so it installs under either resolution mode.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(app): import mount from cypress/react

Cypress 14 removed the cypress/react18 subpath; cypress/react mounts React
18 itself. The e2e support file loads commands.js, so the stale import broke
bundling for every spec, not just component tests:

  Module not found: Package path ./react18 is not exported from package cypress

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* ci: install and cache Cypress the way the bump requires

The Cypress binary lives in ~/.cache/Cypress, outside node_modules, but only
node_modules was cached. A restored cache satisfied npm, so npm skipped the
postinstall download and the 15.x binary was never fetched:

  The cypress npm package is installed, but the Cypress binary is missing.

Cache both paths and install the binary explicitly.

Also align the JS toolchain with what actually ships. Debian 13 devices run
nodejs 20.19.2 and install with a plain `npm install`; the Docker app image
does the same. CI pinned older Node and passed --legacy-peer-deps, which
skips peer dependencies -- that is why jest-dom 7's peer requirement passed
review and broke CI. Match the devices instead.

Pin caddy to 2.11.4, the version Debian 13 devices run, rather than latest.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* ci: test on Python 3.13, the version that ships

Debian 13 devices run Python 3.13.5 and the api/controller/help images are
python:3.13-trixie, but CI ran 3.11 (and one job on 3.12), so the runtime
under test was two minors behind the runtime in production.

Verified on 3.13 before switching: backend 1712 passed, 5 skipped; controller
883 passed. The only new warning is SQLAlchemy 1.3.22 reaching Python's
deprecated datetime.utcfromtimestamp(), which is noise rather than a break.

Drop api-tests-3-12: no workflow referenced it, it duplicated api-tests, and
its "Sanic does not yet support 3.12" note is stale now that devices run 3.13.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-08 18:01:52 -06:00
dependabot[bot]
40391165e2
build(deps): bump cypress from 13.17.0 to 15.20.0 in /app (#601)
Bumps [cypress](https://github.com/cypress-io/cypress) from 13.17.0 to 15.20.0.
- [Release notes](https://github.com/cypress-io/cypress/releases)
- [Changelog](https://github.com/cypress-io/cypress/blob/develop/CHANGELOG.md)
- [Commits](https://github.com/cypress-io/cypress/compare/v13.17.0...v15.20.0)

---
updated-dependencies:
- dependency-name: cypress
  dependency-version: 15.19.0
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-08 16:33:22 -06:00
dependabot[bot]
753cc31a81
build(deps): bump aiohttp from 3.14.1 to 3.14.3 (#633)
Bumps [aiohttp](https://github.com/aio-libs/aiohttp) from 3.14.1 to 3.14.3.
- [Changelog](https://github.com/aio-libs/aiohttp/blob/master/CHANGES.rst)
- [Commits](https://github.com/aio-libs/aiohttp/compare/v3.14.1...v3.14.3)

---
updated-dependencies:
- dependency-name: aiohttp
  dependency-version: 3.14.3
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-08 16:33:19 -06:00
dependabot[bot]
eabeee59c4
build(deps): update uvicorn requirement in /controller (#605)
Updates the requirements on [uvicorn](https://github.com/Kludex/uvicorn) to permit the latest version.
- [Release notes](https://github.com/Kludex/uvicorn/releases)
- [Changelog](https://github.com/Kludex/uvicorn/blob/main/docs/release-notes.md)
- [Commits](https://github.com/Kludex/uvicorn/compare/0.49.0...0.52.1)

---
updated-dependencies:
- dependency-name: uvicorn
  dependency-version: 0.52.0
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-08 16:33:16 -06:00
dependabot[bot]
887a3cd655
Bump pillow from 12.2.0 to 12.3.0 (#564)
Bumps [pillow](https://github.com/python-pillow/Pillow) from 12.2.0 to 12.3.0.
- [Release notes](https://github.com/python-pillow/Pillow/releases)
- [Changelog](https://github.com/python-pillow/Pillow/blob/main/CHANGES.rst)
- [Commits](https://github.com/python-pillow/Pillow/compare/12.2.0...12.3.0)

---
updated-dependencies:
- dependency-name: pillow
  dependency-version: 12.3.0
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-08 16:31:53 -06:00
dependabot[bot]
f35c152d85
build(deps-dev): bump @testing-library/jest-dom in /app (#599)
Bumps [@testing-library/jest-dom](https://github.com/testing-library/jest-dom) from 6.9.1 to 7.0.0.
- [Release notes](https://github.com/testing-library/jest-dom/releases)
- [Changelog](https://github.com/testing-library/jest-dom/blob/main/CHANGELOG.md)
- [Commits](https://github.com/testing-library/jest-dom/compare/v6.9.1...v7.0.0)

---
updated-dependencies:
- dependency-name: "@testing-library/jest-dom"
  dependency-version: 7.0.0
  dependency-type: direct:development
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-08 16:30:39 -06:00
dependabot[bot]
62320b6c36
build(deps): bump fast-uri from 3.1.2 to 3.1.5 in /app (#637)
Bumps [fast-uri](https://github.com/fastify/fast-uri) from 3.1.2 to 3.1.5.
- [Release notes](https://github.com/fastify/fast-uri/releases)
- [Commits](https://github.com/fastify/fast-uri/compare/v3.1.2...v3.1.5)

---
updated-dependencies:
- dependency-name: fast-uri
  dependency-version: 3.1.5
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-08 16:30:36 -06:00
dependabot[bot]
60f99942b5
build(deps): bump brace-expansion in /app (#636)
Bumps  and [brace-expansion](https://github.com/juliangruber/brace-expansion). These dependencies needed to be updated together.

Updates `brace-expansion` from 1.1.13 to 1.1.18
- [Release notes](https://github.com/juliangruber/brace-expansion/releases)
- [Commits](https://github.com/juliangruber/brace-expansion/compare/v1.1.13...v1.1.18)

Updates `brace-expansion` from 2.1.2 to 2.1.4
- [Release notes](https://github.com/juliangruber/brace-expansion/releases)
- [Commits](https://github.com/juliangruber/brace-expansion/compare/v1.1.13...v1.1.18)

---
updated-dependencies:
- dependency-name: brace-expansion
  dependency-version: 1.1.18
  dependency-type: indirect
- dependency-name: brace-expansion
  dependency-version: 2.1.4
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-08 16:30:33 -06:00
dependabot[bot]
890efcaaf1
build(deps): bump react-router from 7.18.1 to 7.18.2 in /app (#597)
Bumps [react-router](https://github.com/remix-run/react-router/tree/HEAD/packages/react-router) from 7.18.1 to 7.18.2.
- [Release notes](https://github.com/remix-run/react-router/releases)
- [Changelog](https://github.com/remix-run/react-router/blob/react-router@7.18.2/packages/react-router/CHANGELOG.md)
- [Commits](https://github.com/remix-run/react-router/commits/react-router@7.18.2/packages/react-router)

---
updated-dependencies:
- dependency-name: react-router
  dependency-version: 7.18.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-08 16:30:30 -06:00
dependabot[bot]
268a3a4f06
build(deps-dev): bump @types/node in /app in the npm-dependencies group (#596)
Bumps the npm-dependencies group in /app with 1 update: [@types/node](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/node).


Updates `@types/node` from 26.0.1 to 26.1.2
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/node)

---
updated-dependencies:
- dependency-name: "@types/node"
  dependency-version: 26.1.2
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: npm-dependencies
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-08 16:30:27 -06:00
dependabot[bot]
acb052b580
build(deps): bump cryptography from 48.0.1 to 50.0.0 (#635)
Bumps [cryptography](https://github.com/pyca/cryptography) from 48.0.1 to 50.0.0.
- [Changelog](https://github.com/pyca/cryptography/blob/main/CHANGELOG.rst)
- [Commits](https://github.com/pyca/cryptography/compare/48.0.1...50.0.0)

---
updated-dependencies:
- dependency-name: cryptography
  dependency-version: 50.0.0
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-08 16:30:24 -06:00
dependabot[bot]
2be657a788
build(deps): update fastapi requirement in /controller (#604)
Updates the requirements on [fastapi](https://github.com/fastapi/fastapi) to permit the latest version.
- [Release notes](https://github.com/fastapi/fastapi/releases)
- [Commits](https://github.com/fastapi/fastapi/compare/0.138.2...0.140.13)

---
updated-dependencies:
- dependency-name: fastapi
  dependency-version: 0.140.13
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-08 16:30:02 -06:00