Acting on the review of #621. No bugs were found; these are the four
suggestions and two nits.
The real one: measurement accepted only hex. `relativeLuminance` returned 0 for
anything else, which is black's luminance and indistinguishable from it -- so a
token written `rgb(92 79 168)` measured as pure black, the "best" foreground was
chosen against a colour the theme does not contain, and the wrong hex was then
frozen into an inline style where CSS could no longer correct it. Silent, and
worse than not measuring. `parseColor` now reads `rgb()`/`rgba()` in both the
comma and space forms, percentages, and `#rgba`/`#rrggbbaa`; `isMeasurable` lets
a caller tell "unreadable" from "black"; and navColorsFrom falls back to the
tokens when the background or every candidate is unreadable. Unreadable
candidates are filtered rather than the whole measurement abandoned, so one
oddly written token does not cost the other two. This matters because the
header claimed custom YAML themes get correct foregrounds for free, and a
hand-written palette is exactly where a non-hex value will first appear.
The Cypress inverse hardcoded night's `--btn-text` as `#0a0000`. The unit tests
parse tokens.css precisely so a palette edit cannot leave a stale claim behind,
and that literal was the one place the rule was broken. It reads the token from
the theme now, asserts it resolved, and additionally pins that red still takes
`--btn-text` while brown no longer does.
Documented rather than changed: the warning indicators still pick their colour
from the `conflictingColors` name list. It is the same defect as the one fixed
here and measurably worse -- `--danger` and `--warning` are 1.0-1.8:1 against
the bar in every theme, light and dark included, because a red icon on a red bar
is a red icon on a red bar. The fix is not a better colour: severity has to
stay distinguishable from the bar's text as well as from itself, and a
monochrome theme has no third brightness, so it has to stop being a hue. That
is a redesign, and the note now sits beside the residue in navColors.ts rather
than only in a PR description. The gallery draws the indicators as the bar
draws them when nothing is wrong; it does not stage a fake overheating Pi to
exhibit a fault nobody has agreed how to fix.
Also documented: re-measurement is triggered by `data-theme`, so a future
custom-theme editor patching custom properties in place would leave the bar on
the values it last measured. There is no event for "a custom property was
assigned" to observe instead, so the note says what such an editor must do.
Nits: "Eleven" in a comment where the assertion locks ten (and a stale 2.91:1
beside it, now 3.27:1), and a missing space in `const wrolpiIcon =<img`.
Six new unit cases, planted: dropping the fallback reddens two.
1121 jest / 62 suites, 196 in ui-layout.cy.js, clean npm run build.
Four faults, each measured in the running app before and after.
The search glyph carried Semantic's `item` class, which has had no rules at all
since Semantic was removed: no pointer cursor, no hover, and an 18.6px hit area
against a 54.4px bar. It is the same kind of thing as Help and Admin and now
takes the same class. The share glyph did compute `cursor: pointer` -- it is an
`<a href>` -- but its target was the 24px box the icon occupies, so the pointer
appeared only if you found it exactly. Corner anchors now stretch and pad like
a link.
The More button rendered with NO class at all: a bare grey UA button while the
rest of the bar took the user's colour. Menu.Target clones its child and hands
it a className of its own, and the trigger spread those cloned props after its
own `className` attribute, so the clone replaced ours. It is merged now. This
was mine, from extracting NavDropdownTrigger last commit -- and it also made the
previous commit's "measured and drawn are the same component" claim only half
true, because the hidden placeholder is not cloned and so kept its class.
The bar still wrapped, for a reason the padding fix could not reach: the corner
does not keep a constant width. The Cmd-K hint renders only once
useKeyboardDetected has seen a keypress, so it appears at an arbitrary later
moment and widens the corner by ~30px; the load, memory, temperature and drive
warnings come and go with the machine's state. None of that resizes the
container, which was the only thing observed -- so nothing recalculated. That
is the other half of "at certain widths": the same viewport wrapped or not
depending on whether the user had touched a key. The corner and the home link
are observed too now.
The search modal showed an input and the first line of the first group. The
suggestion list is absolutely positioned, which is right everywhere else -- a
list that reflows the page while you type is unusable -- but in a modal it fails
twice: an absolute list adds no height, so the panel sized itself to the search
box alone at 114px, and the panel's own `overflow: auto` then clipped the list
at that edge. In the modal the list sits in flow; the inverse is asserted so
the rule cannot leak to the pages where overlaying is correct.
Two of the new tests were wrong first and reverting the fix left them green:
they asserted hand-written markup carrying the classes I expected the components
to have, rather than the real SearchIconButton and ShareButton. They use the
real components now. Every fix was then planted against: 5 red for the More
class, 2 for the search class, 2 for the corner hit area, 1 for the observer,
2 for the modal.
1115 jest / 62 suites, 196 in ui-layout.cy.js, clean npm run build.
Confirmed live: share and search both pointer at 54.4px, More white on the bar
colour with a transparent face, and the modal's list in flow and unclipped.
Three faults in the bar, all reproduced by measurement before being fixed.
The indicators sat low. The slot carried `marginTop: 0.8em`, a nudge left over
from Semantic's menu; the corner centres its children, and a top margin is part
of the margin box being centred, so it applied as a ~6px downward shift. It
read as an error on the hotspot and the theme picker but not on share or search,
which is why it survived: an IconButton is a square ActionIcon that centres its
glyph exactly, while a bare-icon anchor sits high inside a line box that
reserves descender space for text that is not there. The nudge cancelled that
out on one shape and was pure error on the other. Both are laid out now --
flex, centred, no nudge -- and the anchors get `display: flex` so they hug their
glyph instead of a text line.
Help and Admin highlighted only a box around their text while Videos and
Statistics highlighted the full height. Same class, same rules, different
parent: the bar stretches its children, but the corner is a nested flex
container that centres its own, so a link inside it was sized to its content.
The hover background is painted on the link box, so that box IS the affordance.
The bar broke onto a second line at certain widths. Two measurement errors that
only overlap in a narrow band, which is why it appeared while resizing rather
than always. useOverflowNav measured the WRAPPER's offsetWidth and subtracted
only the home link and the corner, missing the bar's own `padding: 0 0.25em` --
about 8px it believed it had. And the hidden "More" placeholder it measured was
a plain button with no dropdown caret, about 21px narrower than the button that
eventually rendered. The placeholder is now the same component as the real
trigger, so the two cannot drift again.
Tested in ui-layout.cy.js against the real <DesktopNav/>, which takes its
colours, home link and indicators as props and so needs none of NavBar's polling
hooks. Indicator centres are measured against the bar's centre rather than each
other, because three things can be equally and consistently wrong and were. The
wrap case sweeps 1180px down to 640px in 6px steps -- a spot check steps over a
band that narrow. All three failed first: share 3.4px high, Help 24.8px against
a 54.4px bar, and two rows at 1156px.
Confirmed on the running app: every indicator at 0.0px from the bar's centre,
Help and Admin at the full 54.4px, and no wrap anywhere from 1500px to 780px.
1115 jest / 62 suites, 182 in ui-layout.cy.js, clean npm run build.
The bar's background is the one surface a user picks -- twelve colour names,
which each theme resolves to twelve of its own colours. Forty-eight
backgrounds, and the links and status icons were drawn on all of them in a
single token, `--btn-text`, which is near-black in three of the four themes.
Night's `--brown` is #451212. A near-black glyph on it is 1.33:1. Nine of
night's twelve colours and seven of amber's sat below 4.5:1, and twelve of the
forty-eight below 3:1 -- icons the same colour as the bar behind them.
So the foreground is measured against the background the theme actually
resolved, choosing between that theme's own `--btn-text`, `--black` and
`--white`. Its own, not literal ones: a white pixel in night mode undoes the
dark adaptation the theme exists to protect, so night's "white" is #ff8a8a.
Measuring at runtime rather than shipping an `--on-red` token per colour also
means the custom YAML themes still ahead get this for free.
Nothing is below 3:1 any more and the worst case is 3.27:1, up from 1.33:1.
No combination is worse than it was.
Ten of the forty-eight are still under 4.5:1 and no foreground will lift them:
a monochrome theme resolves all twelve names onto one ramp, and a mid-tone of a
hue cannot carry text of that same hue. That shortfall is in the palette, so
/theme-sample now draws all twelve bars with the measured ratio printed beside
each one, calling out the ten -- it is a decision to make while looking at them.
The WCAG primitives move to themes/contrast so theme code can measure a colour
without importing Common.js, which pulls in half the component library.
Common.js forwards `contrastRatio` with a delegating function rather than a bare
re-export: six specs mock it with `{...jest.requireActual('./Common')}`, the
spread invokes every getter, and those spreads run re-entrantly while the module
is still evaluating -- a re-export getter fires before its import binding exists
and takes the whole suite down.
Tested: navColors.test.js measures all forty-eight against the palette parsed
out of tokens.css, and holds Nav.js to the call by source since neither bar is
reachable from a test that could measure it. ui-layout.cy.js measures the
painted result in a real browser in all four themes and holds it against the
number the component reports. Reverting to the fixed foreground reddens four
jest cases and three cypress ones.
1115 jest / 62 suites, 177 in ui-layout.cy.js, clean npm run build.
Closes the tail of the library: `Loader`, `Loading`, `Placeholder` and
`Checkbox` had no test of their own, and `IconStack`, `MediaFilterToggle`,
`MultiSelect` and `NumberInput` were not in the gallery, which the project
rule says every component must be.
Putting them in the gallery is what found the rest of this.
An IconStack's corner glyph paints a disc of the surface behind itself so
the two sets of strokes do not cross, and that surface was hardcoded to the
page background. It is right in exactly one of the three places a stack
appears. The nav bar showed a white disc on the red bar, and the file
browser's "New Folder" button showed one punched through the blue fill. It
is now `--icon-stack-bg`, defaulting to the page: the file browser passes
the button's fill, and the nav bar sets it on the <nav> beside the colour it
already writes there, so it inherits -- the bar's colour is a user setting
and cannot be named by a token.
A MultiSelect's chosen options are Mantine Pills, and Pill is the one
component that ignores the radius theme: it writes `--pill-radius: rem(1000)`
onto the element, which zeroing `--mantine-radius-xl` cannot reach. They
were lozenges on a page of square boxes.
Twelve jest tests and sixteen browser tests. The browser half covers what
jsdom cannot judge: that a Skeleton is a different colour from the panel it
covers in all four themes (its greys are remapped to our tokens, and neither
`--border` nor `--bg` was picked with a panel behind it), that the loader
colour resolves through the token, and that the corner disc matches its
surface. Every one was checked red against a planted regression first.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
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.
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*).
Browser-based ESP32/ESP8266 flasher (esptool-js + Web Serial); nothing is
uploaded to WROLPi, the flash is written straight to the device over USB.
Firmware sources (themed, tabbed): a saved set, the WROLPi media directory,
or a local file. Local files can be dropped anywhere on the page, which jumps
to the "Add from computer" dropzone. The media picker searches .bin files by
path and can detect the connected chip to filter firmware to matching images.
- Indexed file_group.suffix + path filter for fast .bin search (alembic
migration backfills via split_path_stem_and_suffix)
- modules/flasher: chip detection from ESP image headers, /search endpoint,
and flasher.yaml saved configurations (/configs CRUD)
- Flash progress modal with ETA, connect timeout, boot-mode hint, auto-
scrolling logs
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* Add Playlists: ordered, mixed-type collections
A playlist (Collection kind='playlist') is a manually-curated, ordered list that
mixes item types. Squash of the playlists feature branch (24 commits).
Data model & API:
- CollectionItem is polymorphic via item_kind: 'file' (FileGroup), 'zim'
(zim_id + zim_entry), or 'url' (link to anything the WROLPi browser opens,
e.g. /map?lat=&lon=&z=). CHECK constraint enforces exactly one kind; per-kind
unique constraints; migration a7b1c2d3e4f5.
- Endpoints: create playlist; add/remove/reorder items (idempotent adds: 201 new,
200 existing); GET returns the ordered items. All mutations wrol_mode_check'd.
Config (source of truth):
- playlists.yaml (PlaylistsConfig) stores each playlist's metadata AND ordered
items (file by relative path, zim by path+entry, url inline); round-trips a
DB wipe. Supports backup import (DB_CONFIG_FILE_NAMES).
- The redundant generic CollectionsConfig (collections.yaml) is deleted:
channels/domains own their configs, authors/subjects are re-derived.
On-disk sync (sync_playlists_directory switch):
- Each playlist materializes into the configurable Playlists Directory
(playlists_destination, Settings UI input, excluded from indexing) with
position-prefixed entries: NNNN_<file> hard links (copy fallback across
filesystems) and NNNN_<title>.html stubs for zim/url items.
- Tagged playlists are namespaced like channels: <playlists>/<tag>/<name>;
a tag change moves the directory and prunes the empty old tag dir.
- Deletion safety: managed files are only removed when they are our marked
stubs or still have another hard link (st_nlink > 1) -- never a last copy.
Stray loose files at the playlists root are removed; stub URLs are
scheme-allowlisted (no javascript:/data:).
Frontend:
- /playlists list page (CollectionTable, name filter, New Playlist modal, Tag
column) between Files and Zim in the nav.
- View-only detail page (Name link) and edit page (Edit button): themed table
with model icons/colors + poster thumbnails, items link to their model page
(video/archive/doc) or preview, up/down reorder, remove, add-URL form,
single-tag selector; all editing disabled in WROL Mode.
- Reusable green "Add to Playlist" button on Video/Archive/Doc detail pages,
the FilePreview toolbar, Zim search results, and Map pins (added as a /map
URL item with a map-marker icon).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* Playlist edit page parity: rename, custom directory, tag modal move suggestion
Match the ChannelEditPage/DomainEditPage UX on the playlist edit page:
Back/View buttons, an edit form (name, description, directory), and the
shared CollectionTagModal with the violet "Tag" button. The tag modal now
suggests moving the playlist to its managed tag location
(playlists/<tag>/<name>); accepting the suggestion stores directory=None so
the sync keeps auto-managing the location. Tag buttons on the Channel and
Domain edit pages are now violet to match.
Backend: update_collection learns rename (with per-kind validation and
duplicate checks) and playlist custom directories (cleanup of the abandoned
directory, config round-trip, commit-before-switch to avoid the background
sync racing pre-commit state). tag_collection's duplicated untag/tag
branches are merged, and the per-kind config-save dispatch is consolidated
into _activate_config_save().
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* Deduplicate Collection error handling and Tag creation
Delete the duplicate UnknownCollection in wrolpi/collections/errors.py and
re-export the canonical one from wrolpi/errors.py, which carries
status_code=NOT_FOUND (the local copy inherited 500). Replace the repeated
query/raise pattern in reorganize.py with Collection.find_by_id.
Add Tag.get_or_create_tag and use it for collection tagging; flush the new
Tag before returning so callers can read .id immediately -- previously
tagging a collection with a brand-new tag name set tag_id=None. Regression
test included.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
A failing SMART self-assessment predicts imminent drive failure, so it
deserves a prominent warning. useDriveHealth() reads the assessment
already present in smart_stats (pySMART reports PASS / FAIL / null) and
flags any drive whose assessment is FAIL. Nav.js renders a red warning
icon in its own always-on slot — like the power warning, and unlike the
single-slot priority chain where a transient high temp could hide it.
Frontend-only: the assessment field is already in the cached payload.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* Warn in the navbar when a hard drive runs hot.
The navbar already warns about high CPU temperature; add the equivalent
for drive temperature, since an overheating disk risks data loss and a
user running an off-grid library won't be watching for it.
- controller/lib/smart.py: build_smart_stats() returns the per-drive list
plus warning thresholds (55C warn / 65C critical) so the frontend stays
dumb. Reading SMART wakes a spun-down drive, so drives in standby are
detected with `smartctl -n standby` (which works through the USB-SATA
bridges where `hdparm -C` only reports "unknown") and NOT read; their
last-known reading is reused instead.
- controller/lib/status_worker.py: collect SMART on a slow 60s cadence
(cached on app.state) rather than the 5s status loop, so the poll does
not keep USB drives awake. The result flows into the same cached payload
via a new collect_all_status(smart_stats=...) kwarg.
- Frontend: useDriveTemperature() reports the hottest drive; Nav.js shows
a yellow/red `hdd` icon with a popup, mirroring the CPU warning.
Verified on hardware: smart_stats reports real temps and SMART runs once
a minute, not every 5s.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* Address Greptile review on HDD temperature warning.
- _drive_is_asleep also treats SLEEP mode as asleep, not just STANDBY.
smartctl -n standby exits "Device is in SLEEP mode, exit(2)" for the
deeper power mode; matching only "STANDBY" meant a SLEEP drive was read
and woken — the exact failure the check prevents. (TDD: added a failing
SLEEP test, then fixed.)
- useDriveTemperature uses ?? instead of || for the temperature fallback
to express "fall back only on null/undefined".
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Create a new Docs module that consolidates PDF and eBook handling into a
unified system with subjects and authors support. This replaces the
separate PDF/eBook modelers with a single Doc model backed by an
alembic migration (rename ebook to doc).
Key changes:
- New modules/docs package with models, API, extractors, and lib
- Subject and author metadata extraction with case-insensitive
normalization for cleaner PDF metadata
- Frontend Docs pages with AuthorsPage, SubjectsPage using
CollectionTable, and doc card deletion
- Global search integration for subjects and authors
- Generic collection item count support
- Frontend file card metadata adjusted to match collection sort
- Bump yt-dlp version
* Batch reorganization is supported for both Domain and Channel Collections.
* Batch reorganization happens when any Collection does not match the respective file format. For example, a Channel Collection file format that does not match the `yt_dlp_options.file_name_format` will show `needs_reorganization`.
* Individual Collections can always be reorganized. The reorganization is a little more aggressive. Useful for when some files may have been missed in the batch reorganization.
Introduces FileWorker for background file operations with dual-queue
architecture (public/private). Handles refresh, move, and rename tasks
with progress tracking, job completion monitoring, and batch processing.
Compares DB FileGroups to filesystem state using FileGroupDiff to detect
new/modified/deleted files. Designed for memory efficiency on Raspberry Pi.
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.
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.