Commit graph

34 commits

Author SHA1 Message Date
Roland
0d1a988fa6
repos: add the Repos UI
* Repos table (CollectionTable), New Repo and Edit pages (frequency,
  mode, branch, description, tag, delete with an optional "also delete
  its files").
* A GitHub-like repo page: upstream, branch, latest commit, a status
  message when not yet downloaded or the last update failed, the file
  tree (directories through ?path=, files in the file preview), and each
  directory's README.
* Global search: a Repos section under Other with escaped README
  headlines, Repos in the suggestions, and the search text sent to the
  Other estimate.
* Settings: the Repos Directory field.
* CollectionTable columns may `render` their cell.
* Theme gallery: the repo summary, tree, and status icons.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 16:56:09 -06:00
lrnselfreliance
91f92683ba
Speed Test calculator: ping, download, and upload against the API (#708)
* Speed Test calculator: ping, download, and upload against the API

Users asking whether the hotspot is fast enough, or how many people can
stream video at once, had no objective tool that works offline.  This
adds one under Calculators > Tools & Reference.

Backend (wrolpi/speedtest.py, /api/speedtest):
* ping: tiny no-store JSON the browser times for round trip latency.
* download: streams slices of a pre-built random buffer for a clamped
  number of seconds (0.1-15).  Random so no proxy or compression can
  flatter the result; slices so no bytes are generated per request.
* upload: streaming-body handler that reads and discards the body and
  reports bytes and elapsed time.  Requests over 8 MiB are refused.
* info: the client address Caddy forwarded plus how many tests are in
  flight.  Sanic now trusts one proxy hop (PROXIES_COUNT = 1) so
  request.remote_addr is the browser, not Caddy.  Nothing else read
  that field before.

There is deliberately no one-test-at-a-time lock.  Several people
testing at once is how a user learns how the link shares between
streamers, so the server only counts in-flight tests (a shared_ctx
Value created pre-fork so every worker sees the same number) and the
UI reports how many others ran alongside.

Both Caddyfiles route /api/speedtest/* unbuffered, as the SSE chat
route already is, so throughput is measured live rather than in bursts.

Frontend:
* speedtest.js holds the logic with no React in it (bucketed
  throughput, steady-state average that drops the first second, ping
  stats, stream-capacity tiers, hotspot address check) so Jest drives
  it with fake fetch and streams.
* SpeedTestCalculator runs 20 pings, a 10 s download, and a 10 s upload
  with a live figure and rolling trace, then shows Mbps and MB/s, ping
  verdict, the client address with a hotspot badge, and a table of how
  many 480p..4K streams the download speed supports.  Upload is
  sequential POSTs of one 4 MiB Blob because Safari cannot stream a
  request body.  Nothing is saved.
* Sparkline is a new inline-SVG ui component (no chart dependency),
  added to /theme-sample and checked in all four themes.  A missing
  sample is a gap, not a plunge to zero.

The page states that it measures the network path, not the drive that
actually serves video files.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* speedtest: bound uploads in time, close the Start race, address review

Review on #708 found three bugs and some polish:

* Start could be clicked again while the first fetchInfo() was still
  pending, because the phase only left `idle` after that await.  A
  second click made a second AbortController and a second concurrent
  run, and Cancel only reached the later one.  start() now refuses when
  a controller already exists and enters a `starting` phase
  synchronously, so the button is Cancel before the first await.

* The upload phase could overrun the advertised 10 s by however long
  the in-flight 4 MiB POST took: on a slow hotspot, the very link this
  tool exists for, that is tens of seconds.  runUpload() now aborts the
  in-flight request at the phase deadline (counting only completed
  requests) and uses 1 MiB bodies so a few-Mbps link still completes
  several inside the phase.

* The server upload sink was capped by size only, so a slow or paused
  body could hold a worker until 8 MiB eventually arrived.  It now also
  enforces MAX_UPLOAD_SECONDS (15 s, matching download) and answers
  408 when exceeded.  Both caps together are what "cannot hold a
  worker" actually requires.

Also: a NaN `seconds` now gets a NaN message instead of "must not be
negative"; the module, Sparkline, and gallery docstrings are trimmed
to purpose; and the upload counter is covered for the mid-stream 413,
the 408, and a broken body.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-13 18:47:18 -06:00
lrnselfreliance
9278103554
e2e: exercise the FileBrowser footer buttons against real files (#670)
Some checks are pending
CodeQL / Analyze (push) Waiting to run
Controller Tests / controller-tests (push) Waiting to run
Jest Tests / jest-tests (push) Waiting to run
* e2e: exercise the FileBrowser footer buttons against real files

Sixteen Cypress tests covering Rename, Move, Delete (including the
tagged-file force-delete flow), Ignore/Unignore, Upload, and scoped
Refresh, with no API mocking: fb:* tasks seed a cypress-fb/ sandbox
inside the media directory, the UI mutates it through the real API, and
assertions check the disk as well as the table.  Skipped in CI, which
runs only the React dev server.

Background jobs (move, refresh) only queue work and worker_status reads
idle while jobs wait, so cy.refreshFiles / cy.waitForEvent poll the
events feed for completion events newer than the server's clock, and
fail loudly when one never arrives.  Every suite cleans up
unconditionally: sandbox files, DB rows via a final refresh, test tags
and their tags/ directories, and any leaked ignored-directory config.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* e2e: act on review — drain stragglers, teardown that cannot be skipped

Events carry no job id, so waitForEvent cannot prove WHICH job finished.
The rename tests now drain the parent-directory refresh their rename
queues before ending, so the next test's refresh wait cannot be
satisfied by a straggler while the DB still reflects the old tree; move
waits match the destination in the completion message.

Teardown must always reach the disk wipe: a failed command aborts the
rest of its hook, so the tag cleanup and the DB-purge refresh are now
best-effort (failOnStatusCode: false, no assertions), and the unignore
runs after every test instead of once per spec.  History and
forward-looking product notes trimmed from comments.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-19 20:27:29 -06:00
Roland
2c6b5ae10e
test: the component suite's twelve failures, none of them about the component
The cypress component suite has been failing 12 of 311 for long enough to read
as background noise.  Every one of them was the test's own fault, and four
distinct causes account for all twelve.

Dead selectors.  `div.ui.label.large`, `div.divider.text`, `.item` and
`have.attr 'error'` all named markup the old library rendered.  None of them
started failing when the library was replaced -- they went on matching nothing,
or looking for an attribute no control carries, which is the failure mode that
makes a test look broken rather than the code.  A tag chip is `.wrolpi-tag`, a
Select is a real input whose value can be read, an option carries the ARIA role,
and an invalid field says `aria-invalid`.  Both directions of that last one are
asserted by value: a valid field says `aria-invalid="false"` rather than
dropping the attribute, so `not.have.attr` would have passed on absence.

Missing context.  `useFileWorkerStatus` throws rather than defaulting when its
provider is absent, so the channel edit page died on a modal buried three levels
down while every other page mounted fine.  App.js wraps the whole app in that
provider; the mount helper now does too, with the poll slowed to a minute.

Unstubbed fetches.  The inventory page loads the food catalog from its own
endpoint, and the RSS edit form fetched downloaders before its intercept was
declared.  Both rejected with "Failed to fetch" out of an effect, which Cypress
counts as an uncaught application error and fails the running test on -- so
three inventory tests and one download test died for a reason unrelated to
anything they assert.

Assertions reading the wrong thing.  `cy.contains('Food Storage')` found the
hidden `.inventory-print` copy, since contains takes the deepest match and the
print sheet is mounted at all times.  And the PUT body arrives as a JSON string,
so `req.body.items` was undefined both in the reply the interceptor built and in
the assertion waiting on it.  The RSS submit test deep-equalled one exact JSON
string and broke the moment the video downloader gained eight settings the form
fills in by default; the envelope is still matched exactly and the settings are
checked for the values the fixture set.  It also never waited on its own alias,
so an interceptor that never ran would have passed in silence.

Separately, the navbar's shrink test read its link count in a bare `.then()`
right after the corner finished growing -- before the recalculation that growth
triggers had run, so `shrunken` was sometimes already the full count and the
assertion came out "expected 10 to be above 10" under load.  It now waits for
the bar to settle to one row, which is the same signal the test above it
asserts, and requires the "More" trigger to exist so it cannot pass having
measured a bar that never overflowed.

New: two tests for the file browser filter's width, desktop and phone, which is
what caught the dead selector behind it.

311 passing, 0 failing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-08 18:05:54 -06:00
Roland
e00f0439da
ui: the last of Semantic UI, in name and in dead CSS
The library has been uninstalled for some time, but the codebase still read as
though it were there: identifiers named after it, rules written against its
class names, and about 180 comments explaining a rule in terms of a thing that
is no longer installed.

Renamed, keeping the vocabulary the app actually uses: semanticUIColorMap ->
navColorHexMap, semanticColorNames -> paletteColorNames, semanticNames ->
iconNameAliases, semanticSizes -> sizeAliases, and SemanticUIColors ->
NavColors in wrolpi/schema.py, which is what validates nav_color on the way in.
ColorToSemanticHexColor had no callers at all and is gone.

The baseline ratchet has done its job -- the list reached zero, so the JSON is
deleted and the guard keeps only the checks with teeth: no package, no import,
no `className="ui "`, no toast library.  Its Theme.tsx check went with the
wrappers it was watching for.  It also asserts its own walk found files, so a
clean result cannot mean it scanned nothing.

Deleted from App.css: every rule requiring `.ui`, `.inverted`, `.segment` or
`.modal`, none of which any component has set since the stylesheet stopped
loading, plus the form skeletons, ui-alerts and progress-text rules that
nothing renders.  Two of them were doing harm rather than nothing: the drag
tints had a dark-theme variant under `.inverted` that stopped applying, leaving
15% of a fixed blue over a dark row, and now mix a token instead.

Two selectors naming markup that no longer exists were live bugs.  The file
browser's filter had `.ui.input.file-browser-filter`, so it rendered at the
browser default width on a bar laid out to give it 34em -- now qualified by its
container, because `.wrolpi-action-input` sets `width: 100%` at the same
specificity and is loaded later.  And SearchBox takes named props rather than a
rest spread, so the `id` DestinationForm hands it was dropped in silence and
the label it renders pointed at nothing; the download forms' Destination field
has had no associated label since the migration.

"Semantic roles" survives in four places, where it means what it says.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-08 18:05:54 -06:00
Roland
eba9bbf7de
review: close the mixed-unit gaps the scale left behind
Seven review comments, all valid.  The common thread is the one the guard was supposed to
catch and did not: geometry converted to rem beside geometry that stayed px.

The guard was narrower than its name.  It missed:

- `transform`, `flex` and `flex-basis`, so `translateX(26px)` and `flex: 1 1 200px` were
  invisible on properties nobody thought to list;
- every NEGATIVE length, because the lookbehind treated the minus sign as part of the
  preceding token -- `left: -7px` and `margin-top: -2.5px` position the eyelet on a tag whose
  diamond HAD been converted, and `right: -3px` / `bottom: -3px` place the corner badge on a
  converted icon.  Each is a piece of geometry that stopped tracking the piece it belongs to.

With both fixed it found nine values, including one no comment mentioned (`flex: 1 1 200px`).
All nine converted, except the three in a dead `.toggle` / `.slider` block: nothing has
referenced those classes since Toggle became a Mantine Switch, and it was ALREADY broken --
track and knob in rem, travel still `translateX(26px)`, so the thumb stopped ~4px short of a
54px track.  Dead CSS that is also wrong is a trap for whoever revives it, so it is deleted.

The tag eyelet is centred with `translateY(-50%)` rather than a negative margin of half its
own height, so the two pieces cannot drift again whatever the scale.

Also from the review:

- A runtime check, which is what the source guards were missing.  Three cases in
  ui-layout.cy.js measure the real thing: the root font-size is the declared multiple of 16,
  our text AND a Mantine control both grow by the same factor when the scale changes, and a
  hairline does not.  The second is the one that catches `theme.scale` being set and silently
  cancelling the root.
- `max-height: max(..., 56.25cqw)` now follows a plain rem declaration.  An engine without
  container query units throws out the whole declaration, which would have left the poster
  with NO cap -- a portrait cover standing as tall as the card is wide.  Older engines clamp;
  newer ones take the second.
- `pxToRem` in components/ui/scale.ts, replacing the `/ 16` written out in CardGroup and
  Placeholder.
- The map overlays: 20 values across the layer panel, add-pin dialog, search panel and
  context menu, whose type had grown 10% while their padding had not.  Left in px on purpose:
  the context menu's `left`/`top` (a real click coordinate), maplibre's own `ScaleControl`
  maxWidth, and the `calc(100% - 100px)` reserve for maplibre's px-sized controls.  The
  `100vh - 120px` reserve DID become rem, since the chrome it reserves for is ours.
- The keyboard-shortcuts spec no longer deletes `.mantine-Notifications-root` from the DOM.
  The reviewer was right that it is brittle; the endpoints are mocked instead, now that the
  probe named them: `/api/tag`, `/api/events/feed`, `/api/files/search` and
  `/api/files/worker_status`.  Two of those were already "mocked" as `/api/tags` and
  `/api/events`, which nothing calls -- the wrong paths were the reason a toast appeared at
  all.  Verified under CI's condition (`forceNetworkError` on everything unmocked): 12
  passing with no DOM surgery.

What is NOT done, deliberately: about 176 numeric geometry values remain in 23 feature files.
The map overlays were the visible ones.  Sweeping the rest is a change to 23 files nobody has
reviewed, so instead the guard's name now says "in our stylesheets" rather than implying the
whole app, and a new ratchet keeps numeric geometry out of the component library so the
eleventh inline `gap: 10` cannot be added there.

Both new guards planted against.  Verified: 1142 jest / 65 suites, 251 ui-layout + 10
modal-stack component tests, all 50 e2e specs, clean build, map overlays measured to track the
scale (panel minWidth 200 -> 220, search 320 -> 352) and screenshotted.
2026-08-08 18:05:51 -06:00
Roland
6be7f4b99e
test: stop the search-modal test depending on an unrelated error toast
The other half of CI's cypress-e2e failure: `clicking search icon opens modal` failed with
`expected '<input.wrolpi-searchbox-input>' to be 'visible'`, covered by a danger toast.

Toasts paint above modals on purpose -- z-index 400 against the modal stack's 200 -- so an
error raised while a modal is open stays visible.  The search modal is tall, so it sits near
the top of the window, and a top-right toast covers the right-hand 192px of its 365px input.
Cypress occlusion-checks a `position: fixed` element, so the assertion failed.

Measured at both scales, because the question was whether the interface scale caused it:
130px of a 330px input before, 192px of 365px after.  The overlap is PRE-EXISTING; the scale
widened it from 39% of the input to 53%, which is what pushed CI over the line.  Roland's
call was to accept the overlap -- an error toast belongs on top -- and fix the test.

It passed locally and failed in CI because CI serves the React app with no API behind it.
The endpoints this spec leaves unmocked are `/api/tag`, `/api/events/feed`,
`/api/files/search` and `/api/files/worker_status` -- note that the spec's existing mocks say
`/api/tags` and `/api/events`, neither of which the app calls -- so they fail and api.js
raises "Unexpected server response".

The toast container is removed before the assertion rather than clicked away, because a click
races the next failing call.  A blanket `/api/**` 200 stub was tried first and does not work:
a 200 with the wrong shape fails the same way, which is what "Unexpected server response"
means.

Verified by reproducing CI's condition locally (`forceNetworkError` on every unmocked
endpoint): 12 passing with the removal, 11 passing and 1 failing without it.  All 50 e2e
specs pass under normal conditions.
2026-08-08 18:05:51 -06:00
Roland
99f0bcc6bc
style: spell it "color" everywhere
283 occurrences across 23 files, all of them mine and all of them in the
theme/UI work: comments, test names, and a handful of local identifiers
(`colours`, `roleColours`, `awaitColoured`) plus one test fixture tag named
'Uncoloured'.

Nothing was required to keep the other spelling.  Checked before replacing: no
CSS custom property or class name contains it, no library prop or API key does,
and every string holding it is a test name or our own copy.  The renamed
identifiers were checked for collisions with an existing `color`/`colors` in the
same file first -- there were none.

Verified as a pure rename: 262 lines changed, and normalising the spelling on
every removed line reproduces the added line exactly, for all 262.  So the diff
contains no other edit hiding in it.

1134 jest / 64 suites, 271 cypress component tests with the same 12 pre-existing
Semantic-era failures in Tags/Channels/Download/Inventory, clean npm run build.
2026-08-08 18:05:50 -06:00
Roland
bbe3cac9a8
test: act on review -- the sprite gap, and the branch no e2e can reach
Both comments were right, and the second named a real blind spot.

The e2e iterated a hardcoded theme list while the unit suite iterated
`themeNames`, so a fifth theme would have got mapping coverage for free
and no cold start.  It imports `themeNames` and `themeSessionKey` now.

The sprite was invisible to it.  A wrong sprite name 404s the icons and
MapLibre carries on, so the canvas exists and the suite passes -- exactly
the case the review described.  The spec now intercepts
`/map-assets/sprites/**` and asserts the requested set is `mapSprite()`.
Planted it by collapsing the sprite back into the flavor: night and amber
get PAST the canvas assertion and fail on the sprite, which is the point.

The rebuild branch cannot be reached from a browser at all.  MapViewer
builds a style twice -- at init, and in an effect keyed on
`[theme, mapReady, scaleUnit]` -- but the theme picker lives on Settings
and in the gallery, never in the nav, so there is no way to change the
theme with /map open; navigating to Settings unmounts the viewer.  A
Cypress case that switches themes would have nothing to click.

So that branch is guarded in the source instead: both `buildStyle` calls
must pass `mapFlavor(theme)` and `mapSprite(theme)`, and none may pass a
bare `theme`.  Crude, and the honest alternative to a browser test that
cannot reach the branch it claims to cover.  Planted by breaking ONLY the
rebuild call: the source guards go red and the e2e stays green, which is
the whole argument for keeping it.

The call-site scan counts parens rather than matching a regex.  The
arguments are themselves calls, so anything non-greedy stops at
`mapFlavor(theme)` and reports half an argument list as the whole of it --
which is how the first version of this test failed while the code was
correct.

1098 jest / 61 suites, 4 e2e themes, clean build.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-08 18:05:48 -06:00
Roland
83359c834e
fix: the map viewer was blank in night and amber
MapViewer handed protomaps-themes-base the WROLPi theme name as its
basemap "flavor".  That worked while the only themes were `light` and
`dark`, and broke the day night and amber arrived: protomaps looks the
flavor up in a record and dereferences the result without checking, so an
unknown name is not a fallback but

    TypeError: Cannot read properties of undefined (reading 'background')
        at nolabels_layers (base_layers.ts:18)

thrown before the first tile.  MapLibre never constructed, so /map
rendered nothing in two of the four themes.

`mapFlavor()` in themes/names.ts maps a theme to one of the two flavors we
can actually serve.  Only `light` and `dark`: the flavor is interpolated
into `/map-assets/sprites/<flavor>` too, and those are the two sprite sets
we ship, so protomaps' `black` and `grayscale` would load a style and then
find no icons.  Night and amber take `dark` and let the media filter tint
the canvas -- a filter shifts hue, so handing them `light` would give a
red or amber slab, which is the opposite of the point.

Two layers of test, because they cover different halves:

  - map-flavor.test.js checks every theme against the REAL protomaps
    (calling layers() and requiring it not to throw) and against the
    sprites directory on disk, rather than a list of valid names copied
    into the test, which is what would have passed here in the first
    place.  It fails when a fifth theme is added without a mapping.
  - map-themes.cy.js drives the actual page in all four themes and waits
    for the MapLibre canvas.  The unit test cannot see whether MapViewer
    USES the mapping, which is the half that was broken.  Verified against
    the planted regression: night and amber fail with no canvas, light and
    dark still pass.

Not a bug: the 404 on /blobs/map-overview.pmtiles in the same trace.  That
is a HEAD probe for an optional planet overview, gated on `resp.ok`, and
the blob is absent in a dev checkout.  The console line is unavoidable.

1093 jest / 61 suites, clean build.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-08 18:05:48 -06:00
Roland
eab13e4803
ui: give tables a real header, drop the column grid, quiet the row rules
Three things the file browser made obvious.

The header was not a surface.  Mantine gives thead no background at all, so
the header painted whatever was behind the table -- which is exactly what an
unstriped body row paints.  Against a striped body it read as one more stripe,
and bold type was the only thing distinguishing it.  It now carries `--head`,
the token that already means "recessed surface", with a full-strength 2px rule
under it: the header/body division is the one line in a table that carries
meaning, so it is the one allowed to be loud.

Column borders are gone.  A line around every cell is a lot of ink to repeat
what the column headings already say, and it was half of why the header
disappeared -- a bordered header cell looks like a cell in a grid.

Row rules were `--border`, the same weight as the outline around the table, so
a long table read as a stack of boxes.  They now use `--table-line`, a new
token per theme sitting between the surface and `--border`.  The table outline
is not a row rule and keeps `--border`; Mantine draws both from
`--table-border-color`, so that had to be restated.

In the file browser an icon and a filename are inline siblings in one cell.  An
inline SVG sits on the text baseline, so a 24px glyph beside 13px text hung
above it -- the icon looked centred in the row and the name looked like it was
resting on the icon's floor.  `vertical-align: middle` centres it, and the
margin is a gap that was never there at all: the folder icon touched the first
letter of its own name.

Cypress now loads App.css, as the app does.  Without it a spec covering the
file browser rows measures a component the app never renders.

One jest test and eleven browser tests, each checked red against a planted
regression.  Two notes for the next person:

  - `html .mantine-Table-table` is specificity, not decoration.  Mantine
    declares --table-border-color on its own hashed class, which ties with
    `.mantine-Table-table` and wins on source order.
  - the "quieter than the outline" test measures both lines against `--panel`.
    Nothing paints <html> under a mounted component, so measuring against the
    root background compares them to black, which ranks the *paler* line as the
    louder one -- the assertion passed backwards until it was pointed at the
    surface the rules actually sit on.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-08 18:05:45 -06:00
Roland
664c7d2219
test: prove the toast contrast fix in a browser, and fix the harness that hid it
The title contrast was verified by hand and by nothing else.  jsdom cannot
check it: it resolves no `var()`, so the title's colour there is the literal
string `var(--mantine-color-white)` and every contrast assertion passes.

Six browser tests now cover it -- title and description at WCAG AA in each
of the four themes, the title staying heavier than the message where night
and amber drop its muting, and the toast taking `--panel` for its surface.
The three that can fail were checked against the fix removed: 1.13:1 in
dark, night and amber.  Light passes either way, correctly, because
Mantine's light-scheme title uses `--mantine-color-black`, which the bridge
maps to `--text`.

Getting there took three corrections, all of them mine:

* `cy.mountUI` never set `forceColorScheme`, so every theme rendered
  against Mantine's LIGHT scheme.  Mantine keys its own rules on
  data-mantine-color-scheme, and ThemeProvider sets it from the theme --
  night and amber ride on dark.  Any per-theme test could therefore pass on
  a page that was broken in the app.  This is the harness the tag contrast
  tests use too.

* The support file never imported `@mantine/notifications/styles.css`,
  which App.js does, so a Notification rendered unstyled and had no
  surface or title colour of Mantine's to be wrong about.

* The spec's own helper mounted a second time to raise the toast, which
  reset the theme to the default.  All four per-theme cases ran in light.

For the record, the chain the fix breaks:
`.m_3feedf16` (the title) in dark scheme is `color: var(--mantine-color-white)`,
the bridge aliases that to `--btn-text`, and `--btn-text` is the text drawn
on a filled button -- near-black in dark, night and amber.  Mantine writes
it through `:where(...)`, which has zero specificity, so a single-class
override plus `html` outranks it.
2026-08-08 18:05:45 -06:00
Roland
ecf611931b
ui: tags look like tags, and stay readable in every theme
Four things, three cosmetic and one a real legibility bug.

* Tags were square chips.  They now have a pointed left edge and an
  eyelet, like a tag tied to a garment -- the shape Semantic gave them,
  and the reason a tag reads as a tag rather than as a small button.  The
  point is a square rotated behind the left edge, sized so its diagonal
  equals the body height and its two edges meet the body at the corners.

  Only where something is actually a tag.  `Label` carries the count
  badges in the Tags table too -- file, zim, channel and domain counts --
  and a point on a number reads as an arrow, so the shape is behind a
  `tag` prop and `NameToTagLabel` opts in.

* They were smaller than what they replaced: 11.5px text with 3px of
  padding, against Semantic's 12px and ~7px.  Now 12.5px and 5px, which
  also keeps them a reasonable thing to tap.

* Wrapped rows of tags sat on the row above, so they carry a bottom
  margin, plus a left margin for the point to occupy.

* The text colour was unreadable in night and wrong in amber.

Tags.js calculates black-or-light against the colour the user chose for
each tag, and wrote the answer to `color` inline -- a declaration no
stylesheet rule can outrank.  Night turns a tag into an outline over a
near-black page, so a near-white tag kept the black text calculated for
its fill and was left legible only by its border: 1.1:1, measured.  Amber
replaces every fill with its own bright orange and was painting `#dddddd`
on `#ff9500`, about 1.9:1, and a neutral grey in a theme whose whole
point is that no other hue reaches the eye.

The fix is to stop hard-coding `color` so the theme can decide: the
calculated colour goes into `--label-text`, and night and amber override
it alongside the fill they already override.  There is no second contrast
function to write, which is what this looked like it needed -- once a
theme has discarded the user's colour there is nothing left to calculate
against, and the answer is just the theme's own token.

`.wrolpi-label-white` and `-black` moved to the same variable.  Setting
`color` there outranked the base rule, so amber was painting `--white` on
`--text`: light orange on orange.

`contrastingColor` was also picking the worse of its two options.  It
compared a brightness figure against a threshold, and the figure weighted
the gamma-encoded bytes without linearising them, so Semantic's blue
`#2185d0` got light text at 2.9:1 where dark text gives 5.3:1.  Every
mid-tone blue, teal and purple a user might pick sat in that band.  It
now measures the WCAG contrast ratio of both candidates and returns the
better one, which needs no threshold at all.

Tests
-----
`Tags.test.js` is new, and covers the chip nothing covered: every other
spec takes `NameToTagLabel` from `tagsContextFixture`, which stubs it as
a function returning the name.  That is how a tag could be unreadable
without a single test noticing.  Two of these were verified red against
the inline `color`.

`cy.contrastRatio()` measures text against the surface *actually* behind
it, walking up to the first ancestor that paints something -- a
transparent tag has no background of its own, so a naive check reads
rgba(0,0,0,0) and concludes anything is legible.  Twelve cases, three
tag colours across four themes, at WCAG AA.  Plus a test asserting the
old inline-`color` version still measures below 1.5:1, so the check is
provably able to fail.

The gallery had label swatches in theme colours and no user-coloured tag,
which is why none of this was visible in it.  It has eleven now, spanning
the range where the black-or-light decision flips.
2026-08-08 18:05:44 -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
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
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
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
Roland
8e5654a661
Removing extremely flaky test. 2026-06-11 19:46:34 -06:00
Roland
5497e1eb61
Fixing flaky domain search test. 2026-05-31 13:09:20 -06:00
Roland
dc531a1f42
Fixing flaky e2e tests.
Some checks are pending
CodeQL / Analyze (push) Waiting to run
Controller Tests / controller-tests (push) Waiting to run
Jest Tests / jest-tests (push) Waiting to run
2026-03-21 17:01:50 -06:00
Roland
282b0f63e8
Adding links next to video captions to allow scrubbing of video by the captions, adding filter to make finding quick. 2026-03-21 16:50:27 -06:00
Roland
2bae99c6ef
Updating cypress tests for new file group routes. 2026-03-21 16:26:08 -06:00
Roland
07f601e77f
Add controller onboarding flow for primary drive setup
Introduce an onboarding system in the Controller that guides users
through selecting and mounting their primary media drive. This is
the fallback UI flow used when the main React app is unavailable.

Key changes:
- Add onboarding API endpoints and schemas (controller/api/onboarding.py)
- Add onboarding logic to detect drives, handle already-mounted drives
  (needed for RPi which auto-mounts), and save primary drive selection
  to the controller config when no mounts are configured
- Extend the Controller's index.html template with the onboarding UI
- Skip root cert creation until onboarding is complete
- Update first_startup.sh to support the onboarding/repair flow
- Add comprehensive tests for onboarding logic and API
2026-03-14 16:08:40 -06:00
Roland
35994bd363
Adding download window which can prevent downloads from running at unwanted times.
Some checks are pending
CodeQL / Analyze (push) Waiting to run
Controller Tests / controller-tests (push) Waiting to run
Jest Tests / jest-tests (push) Waiting to run
2026-03-12 20:36:57 -06:00
Roland
1e37f0d4dd Skipping Caddy tests on CICD.
Some checks failed
CodeQL / Analyze (push) Has been cancelled
Controller Tests / controller-tests (push) Has been cancelled
Jest Tests / jest-tests (push) Has been cancelled
2026-03-10 20:30:46 -06:00
Roland
da8a9bb923
Caddy must serve files inline on /media and download on /download. 2026-03-08 09:54:00 -06:00
Roland
3f411679bd
Adding keyboard shortcuts to UI.
Some checks failed
CodeQL / Analyze (push) Has been cancelled
Controller Tests / controller-tests (push) Has been cancelled
Jest Tests / jest-tests (push) Has been cancelled
2025-12-26 18:59:20 -07:00
Roland
7baba10203
Using bulk SQL statements to speed up bulk file tagging.
Some checks are pending
CodeQL / Analyze (push) Waiting to run
Jest Tests / jest-tests (push) Waiting to run
2025-12-09 15:28:45 -07:00
Roland
b339c21515 Moving form fields. 2025-12-06 15:11:37 -07:00
Roland
a1e0fb4e92 Creating a new Collection model for use in organizing FileGroups.
Replacing Channels and Domains with Collection.

Moving downloading relationships from Channel to Collection.

Created domains.yaml config to manage tagged domain collections.

Adding turn-key upgrade solution in the UI.
2025-12-06 15:11:37 -07:00
Roland
ed14dd5976
Ignoring ChunkLoadError because this occurs only on CircleCI. 2024-11-28 10:05:08 -07:00
Roland
5621d47309
Video downloads have many more options. Downloads are migrated.
Storing video settings in session storage to help user start downloads consistently.

Applying channel tag name to new channels when download videos or channels.

Initializing map tiles when importing and resetting map.
2024-11-28 09:48:57 -07:00