Commit graph

44 commits

Author SHA1 Message Date
Roland
ef5a3a1dbc
ui: a progress bar's track is its own edge, not a border
Take the 1px outline off the bar without changing its size.  `box-sizing:
border-box` is global and the declared 1.375rem / 7.5rem were always the outer
box, so nothing moves.

What the border was quietly doing was giving the empty half of the track an
edge, so the track now has to carry that itself: `--head` rather than `--bg`.
A bar sits on a Panel as often as on the page -- Status and Downloads put them
in Panels and table rows, uploads in modals -- and `--bg` is invisible against
one of those two by definition, which is also why light mode needed its own
`--panel` override.  `--head` is off both surfaces in all four themes, and
keeps the label's contrast against the empty track above 4.5:1 everywhere,
which a lighter track (`--border`, 3.4:1 in night) would not.

Asserted per theme in the component spec: no border on any edge, the height
and min-width measured against `rem` rather than px, and the track distinct
from the Panel beneath it.  Putting the border back fails all four.
2026-08-08 18:05:53 -06:00
Roland
7741ee1f12
ui: a row of buttons is spaced by the row
The archive page's six actions shared edges, and Update sat lower than the
rest.  Both come from the same thing: Semantic's Button carried
`margin: 0 .25em .25em 0`, so a page could list buttons as bare siblings and
they arrived spaced -- and separated from the line below when the row wrapped.
Nothing replaced that margin.

Two of those six answered with `marginTop: 0.5em`, which is the wrong axis: a
Button is `display: inline-flex; vertical-align: middle`, and `vertical-align`
is measured on the MARGIN box, so half of any top margin shows up as the
visible button dropping.  It was covering the wrapped-row case in the only axis
a margin can, and paying for it in the other one.

`.wrolpi-button-row` is a flex row with a gap, so both axes are covered at once
and no button needs a margin of its own -- the same rule as `.wrolpi-stack`,
along the other axis.  Applied to the five rows in the app that had no gap: the
archive page, the shared collection edit form (which three pages hand action
buttons to, and where nine buttons plus Cancel carried `margin-top: 1em` each
while Save carried none), the doc page's two responsive branches, and the
downloads table's edit/restart pair.

`.action-button-spacing` goes with them; it was that same margin, on Cancel.

Guarded four ways.  ui-layout.cy.js measures that the row really spaces what it
holds, wraps rather than overflows, centres its buttons against a taller item
beside them, and counts a confirmed APIButton as one item rather than two --
that one returns a fragment holding a `<Confirm/>`, which would open a hole in
the row if it did not portal.  Common.test.js scans for the two ways this comes
back: a button in a row stamping a vertical margin, and a row of buttons that is
not a row.  Neither throws, which is how the app got here.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-08 18:05:52 -06:00
Roland
fea03a0b78
ui: pages own the space between their blocks
Panels shared edges: two 1px borders meeting, which reads as one tall
surface with a rule across it rather than a seam between two.

Done as `margin-bottom` on the panel first, which is the anti-pattern, and
it failed in both of the ways that rule is known to fail before the reading
was done.  It compounded with the gap of every Stack that already held
panels -- four of them, spacing those lists at 35.2px against the app's
17.6px -- and the `:last-child` reset meant to keep trailing space off the
bottom of a container silently did nothing, because Media wrapping makes a
panel the only child of its own wrapper.  On Status that separated four
pairs out of five and left the first touching.

So the space belongs to whatever stacks the blocks.  `.wrolpi-stack` is on
PageContainer, which every page uses, and on the three wrappers that stack
blocks somewhere the page's own stack cannot reach: Settings' panel wrapper,
the block below the video player, and the Zim and Map route wrappers, which
hold their tab bar outside PageContainer.

A gap rather than `> * + * { margin-top }`, which was tried and is quietly
broken here: Media injects `.fresnel-container { margin: 0; padding: 0 }` at
runtime, same specificity, later source order, so every wrapped block threw
the margin away.  A child cannot zero its parent's gap.  It also means
Media's hidden half contributes nothing, where the owl selector spaced
around something invisible.

With the page owning the spacing, the blocks that owned some of it had to
give it up, or their margin was added to the gap: the tab bar (33px against
17.6 everywhere else), the shared search control bar and paginator on
Videos/Archives/Docs/Files, the card grid, the file browser's filter row and
the inventory search.  Two margins deliberately stay -- the file browser's
4em, which reserves room for the sticky buttons (62645831), and heading
margins, which bind a heading to the content below it.

Tests plant each of the three failures: the wrapped block, a child that
zeroes its own margin, and a hidden Media half.  Verified in the browser on
sixteen routes and in all four themes -- every stacked pair 17.6px, the
map's 85vh box and the video player unchanged, no horizontal overflow.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-08 18:05:52 -06:00
Roland
14e32db961
ui: larger tags, tighter rows
A tag's word was 78% of the size of the body text beside it, which is right
for a label -- an annotation on something else, like the count next to a
tab's name -- and wrong for a tag, which is the whole content of its chip
and the thing a wall of them is scanned for.  On the dashboard, where the
tags are the page, it read as a footnote.  Now 87.5%.

The point grew with it, because it has to: its diagonal is the body's
height, or its two edges meet the body short of the corners and it reads as
a notch stuck on the side.  21px a side for the 30.2px body.  That is also
the one length here that went UP -- the margin reserving room for the point
was a third of a pixel short of the larger one, which is enough to clip
against the tag before it.

Padding is unchanged.  The gaps are two defects rather than a design:

  - The link wrapper carried 0.3em either side, from when these were
    Semantic labels with no margin of their own.  A tag already reserves
    the room its point needs, so that was 0.3em twice on top of it: two
    tags 34.7px apart where the point needs 18.4px.  Now 25.3px, leaving
    about 7px of visible space.

  - TagsLinkGroup spread its props down to every chip, so the dashboard's
    marginTop -- meant to space the row below its heading -- landed on all
    34 tags.  That is not a margin above the row; it is 7.7px added between
    every wrapped row of it.  Row gaps 18.5px -> 12px.

Tests first: both margins fail on the real values (0.3em on the link,
0.5em on the chip), and the size is asserted as a ratio to body text so it
cannot be satisfied by the interface scale, which raised tag and prose
together and left the tag exactly as small next to it.

Verified in all four themes, including night, where the point is two
strokes rather than a solid diamond and so shows a corner mismatch plainly.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-08 18:05:52 -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
02aed6d7f5
ui: raise the suggestion list's cap, which the scale outgrew
CI's cypress-e2e job went red on the interface scale: `directory-search.cy.js` failed
`expected '<div.wrolpi-searchbox-result-title>' to be 'visible'`.

The suggestion list is capped at `min(60vh, 30rem)`.  The rem ceiling scales with the
interface but a fraction of the VIEWPORT does not, so when the type grew 10% the rows grew
and the cap did not: the full set of grouped suggestions now needs about 420px, and 60vh of
a 660px-tall window -- Cypress's default -- is 396px.  The last group was clipped again,
which is the exact defect the comment on that rule already describes.

70vh is 462px there, and the 30rem ceiling still bounds it on a tall window, so the list
cannot cover the page either way.

Measured rather than guessed: the spec fails at 660px of viewport height and passes from
700px up, so the shortfall was about 24px.

Verified: all 50 e2e specs pass locally, 248 ui-layout component tests, 1141 jest tests.
2026-08-08 18:05:51 -06:00
Roland
7d602f7ea8
ui: let a landscape poster fill a one-column card, and fix three scale-blind tests
`--card-poster-max-height` is a fixed height, so it stops scaling with the card: held to
220px, a 16:9 thumbnail is 391px wide, and any card wider than that centred it with empty
panel on both sides.  That is every video card at one column -- a phone, or any window
under about 456px of content width.

The cap is now `max(var(--card-poster-max-height), 56.25cqw)` with the poster band as the
query container.  56.25cqw is nine sixteenths of the card: exactly the height of a 16:9
image that fills the width, and the same number states the rule for every other ratio --
a poster may be as tall as a 16:9 slice of this card and no taller.  So a portrait book
cover still reads as a band rather than a column, and now grows with the card instead of
stopping at 220px.  `max()` rather than `min()` so the floor wins on a narrow card.

The gap pre-dated the interface scale, below about 414px, but the scale widened the range
it shows in: the track minimum went from 200px to 220px, so one column now starts at 456px
of content width instead of 414.

Every existing poster case mounted a 233px card, which is why the wide card's own failure
went unseen for the whole migration.  Two cases added on a 451px card: a landscape poster
reaches the edges, and a portrait cover stays a band.

Three tests carried a hardcoded px expectation, which is a hidden assertion that the
interface scale is 1.  All three failed at 1.1, in names that said nothing about size --
and I did not catch them because I ran jest and the build for the scale change but not
cypress, which AGENTS.md asks for on frontend work:

- `cardPosterCap()` read `--card-poster-max-height` with parseFloat.  The cap is rem now,
  so it returned 12.5 and failed a within(80, 240) bound -- taking five poster tests with
  it.  It resolves through the root font-size and checks the unit.
- `inNarrowColumn` was a fixed 340px, chosen to hold two 150px statistic tracks.  At 165px
  tracks it became one column, so a test about the gap BETWEEN columns had no second
  column.  It is now 340px times the scale, because "narrow" means narrow relative to the
  type.
- The APIButton size test asserted 36px for `small`.  It now measures against a real `sm`
  button, which is the claim it was making -- that `small` resolves to `sm` -- and holds at
  any scale.

A `uiScale()` helper reads the token so no further test has to name a pixel count.

Verified: ui-layout 248 passing, 0 failing; 1141 jest tests / 65 suites; clean build;
browser-checked at 400/451/500/700/1200px of grid width, no side gap at any of them.  The
12 remaining component failures are the pre-existing Semantic-era selector failures in
Tags, Channels, Download and Inventory.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-08 18:05:51 -06:00
Roland
dbc9f823a4
ui: scale the whole interface by 10%, off the root font-size
The interface reads about 10% smaller than the pre-migration Semantic build, and the
drift is not one number.  Measured on the QA Pi at 1280px, Semantic's headings were
h1 28 / h2 24 / h3 18 / h4 15 / h5 14 against our 26 / 21 / 17 / 14 / 13, and its video
card a fixed 290px with an 18px title against our 238px with a 13px one -- while our
BODY text and chrome went the other way, a 16px base against Semantic's 14 and 42px
buttons against 36.  No per-component nudge reproduces what a user comparing the two
sees, which is the whole page at once.

Ten percent is not a fudge factor: our heading scale times 1.1 is 28.6 / 23.1 / 18.7 /
15.4 / 14.3, which is Semantic's scale to within a pixel at every step.  Verified in
the browser -- those are the numbers it now renders.

`--ui-scale: 1.1` and `html { font-size: calc(100% * var(--ui-scale)) }`, with
everything that should grow written in rem:

- 102 lengths in ui.css, App.css and tokens.css converted, restricted by property:
  font-size, padding, margin, gap, width/height, border-radius, offsets.
- Borders, outlines and shadows deliberately NOT converted.  A hairline is a hairline
  at any text size.
- 46 inline `fontSize` px values across 8 files.  `em` values were left alone: they
  already resolve against the parent.
- Ten inline-styled blocks in the library (Card's text block, Loading, Placeholder,
  ColoredInput, Confirm's action row, CardGroup's gap) moved into ui.css, because an
  inline px size cannot be reached by a scale or seen by a test.
- CardGroup converts its `minWidth` prop to rem, which is what returns a phone to one
  card per row: as px the track ignored the scale, so a 430px screen fitted two 200px
  cards where the old build fitted one 290px card.

Mantine needs nothing: its 644 size declarations are `calc(Xrem * var(--mantine-scale))`
and scale with the root.  `theme.scale` must stay at 1 -- it exists to CANCEL a
customised root font-size, so setting it to 1.1 as well would leave Mantine's half of
the interface at the old size beside our larger text.  A test pins that.

`zoom: 1.1` on the root was tried first and rejected.  It works and it is one line, but
it scales at paint: every 1px hairline becomes 1.1px and softens on a 1x display, and
viewport units do not shrink to compensate, so `100vh` paints 110% of the screen -- a
95vw modal overflowed sideways and the CBZ viewer's fullscreen overflowed down.  Rem
scales real lengths, keeps borders crisp, leaves viewport units alone, and preserves a
user's own browser font-size preference instead of overriding it.

A percentage rather than a px root size, for that last reason.

Five guards in themes/ui-scale.test.js, each planted against: the scale itself, that
every scalable declaration is rem, that borders are NOT, that no inline px font-size
survives anywhere including the gallery, and that Mantine's scale stays 1.

Also removes `.ui.modal.themed-confirm`, a dead Semantic-era rule and the source of the
one `95vw` that would have overflowed: `.ui.modal` no longer exists and nothing sets
that class.

Verified: 1141 tests / 65 suites; clean build; browser-checked at 1280px and at a 430px
phone; navbar single-row from 780 to 1800px; hairlines still 1px and no horizontal
overflow in all four themes.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
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
b0a7c9da1e
ui: the map pin buttons were three sizes for three reasons
Reported as "all different sizes", and the pin row's three actions measured
46x30, 30x30 and 54x36.  Three causes.

APIButton dropped `size`.  `useAPIButton` declared `size = 'medium'` as a
parameter and then never put it into the props it hands to Button, so every
APIButton in the app rendered at Mantine's default regardless of what its call
site asked for -- seventeen call sites pass one, from `xs` on the map pins to
`big` and `huge` on Settings.  The delete pin was 36px tall beside two 30px
siblings because of it.  Passing the parameter default through would have fixed
the bug by breaking everything else -- every APIButton naming no size would have
grown from `sm` to `md` -- so the default is undefined now and Button's own
default stands.  Eight buttons on three other pages do grow as a result, which is
their call sites finally being honoured: VideoPlayer's `large`, one of Channels'
`big`, and Settings' two `big` and two `huge`.

An icon-only Button was not square.  A Button is as wide as its content plus its
horizontal padding; an ActionIcon is square by construction; and the pin row
mixes the two, because AddToPlaylistButton renders an IconButton when it has no
label.  Their heights already agreed -- ActionIcon's scale was remapped onto
Button's some commits ago -- and this is the other axis of the same alignment,
keyed off Mantine's own `--button-height` so the two stay in step through every
size.

They were also misaligned and touching, which is a third thing: bare inline-block
siblings align on text BASELINES, and a Button's baseline is not an ActionIcon's,
so the middle control sat 4.3px above the other two.  A Group, as every other row
of buttons in the app uses.

Written test-first at Roland's request.  Seven cypress cases went red on the
sizes -- widths 46 against 30, 54 against 36, 62 against 42, 70 against 50, and
"xs is shorter than lg: expected 36 to be below 32" for the dropped size -- with
both inverses green throughout: a labelled button stays as wide as its label, and
an APIButton naming no size keeps the height it had.

The alignment claim is measured in ui-layout.cy.js, including the premise that
bare siblings really are misaligned, since three controls of identical height not
lining up is not believable otherwise.  The call sites are held by a source
guard, because MapPins fetches from the API and builds its rows through
SortableTable.

That guard earned itself twice over.  Written for the pin rows, it found the
map-files subscribe cell putting a preview button beside a subscribe button with
the same defect.  Then a first attempt at a general "any cell with two controls"
rule had to be abandoned: a cell whose buttons are the arms of a ternary renders
only one and needs no Group, and excluding ternaries to allow for that also
excluded the subscribe cell, whose `role={isSubscribed ? ... : ...}` says nothing
about how many controls it holds.  Named cells with a stated scope beat a general
rule that quietly stops covering things.

Two slips of mine in that file, both caught by running it: `expect(value, message)`
is chai and throws in jest, and `onSave(pin.id, ...)` was not unique to the cell
it named -- the edit row's TextInput calls it from onKeyDown in a different cell,
so the check passed against the wrong one.

1132 jest / 64 suites, 243 in ui-layout.cy.js, clean npm run build.  Confirmed
live: three 30x30 controls, vertical spread 0, 10px gaps.
2026-08-08 18:05:50 -06:00
Roland
5f422939c6
review: one inset, and two tests that could not fail
Acting on the review of #625.  No bugs; four suggestions and three nits, of
which two were already handled and one needed no code.

The weld used three separate 8px literals -- the control's padding, its flex gap,
and the clear button's negative margins -- which had to stay in lockstep or the
button would drift past the border or back inside it.  They are one
`--searchbox-inset` now.

Cancelling the gap on the clear button's LEFT was a real side effect: when a
field is both `loading` and `clearable`, which SearchResultsInput allows, the DOM
order is input, spinner, clear, and the spinner ended up welded to the divider.
Only the right-hand padding is cancelled now; the 8px before the button stays,
and a case covers the pair.

`min-height` was still coming from `--ai-size`, so the rule only half-owned the
height it claimed to set -- true today by 0.15px, the field being 36.15 against a
36 minimum.  Unset now.

Two nits needed no code and are recorded as findings rather than changes.  The
clear button's corners are already square: the theme zeroes every
`--mantine-radius-*`, so there are no wedges to show against the field, and a
case now depends on that explicitly.  And a label-less button with BOTH `icon`
and `iconAfter` would stay mis-centred, but no call site writes one, so the limit
is documented instead of designed around.

Blank children now count as no label.  `toArray` drops null and booleans but
keeps `''` and whitespace, which would have left the glyph off-centre; no call
site does it today and the predicate should not depend on that.

Two corrections to my own work, both caught by planting:

The fragment case.  I wrote that `toArray` flattens fragments away to nothing and
tested `<></>` as unlabelled.  It does not -- it returns the fragment as one
child, so `<></>` is indistinguishable from real content.  The test now records
what actually happens, with `<>Delete</>` as the shape that occurs in practice.

The min-height assertion compared `--ai-size` -- which reads back as
`calc(2.25rem * var(--mantine-scale))` -- against a computed `36px`.  Two strings
that can never match, so it passed with the rule removed.  It compares resolved
lengths now, paired with an ordinary IconButton that DOES carry the minimum, so
its absence is attributable to this rule rather than to a Mantine default.  An
earlier attempt fabricated a shorter field by overriding the input's font size;
that was deleted, because a test that invents a layout the app never renders is
not evidence about this app.

1128 jest / 63 suites, 231 in ui-layout.cy.js, clean npm run build.  Confirmed
live: inset 8px, clear button 1px from the field edge, 8px from the input, at the
field's full inner height with no minimum of its own.
2026-08-08 18:05:50 -06:00
Roland
add67942e8
ui: three button-layout defects, each with a different cause
Reported together as "button groups", but they turned out to be three unrelated
things.

The file browser's footer buttons touched while the Channel Edit page's had
gaps.  Channel Edit lays its buttons out with Mantine's `Group`, which is a flex
row with a gap; the footer is hand-rolled and was a plain block, so its
inline-block buttons sat flush -- JSX drops whitespace-only lines, so there was
not even a space character between them.  The footer is now the flex row every
other button group already is, wrapping because it carries eight controls.

The Settings config table's Import and Save buttons had off-centre icons while
Restore in the same row looked right.  Restore is an IconButton -- an ActionIcon,
which centres a lone glyph by construction.  Import and Save are icon-only
Buttons, and an icon on a Button goes into Mantine's `leftSection`, which carries
a `margin-inline-end` to hold it off the label.  With no label that margin is
pure offset: measured at 8px left of centre.  An icon with no label is now
rendered as the button's content instead, so it centres.  `Children.toArray`
decides that, because it drops nulls and the file browser passes
`{label('Delete')}` -- null whenever the bar is too narrow for words, which is
exactly where the offset showed most.

The search field's clear button sat inside the field rather than attached to it:
the control is a bordered box with `padding: 0 8px`, leaving the button 9px short
of the edge and 2px short of the height.  Semantic welded it to the field --
measured on the QA Pi at zero gap and full height -- so it now stretches and
negative margins cancel the padding and the flex gap.  Negative margins rather
than dropping the control's padding, because that padding is what holds the
search icon and the text off the border; only this one child should escape it.

The `html` prefix on that rule is load-bearing: Mantine sets ActionIcon's height
from `--ai-size` on its own single class, and at equal specificity the winner is
whichever stylesheet loaded last.

Fourteen new cases.  One correction worth recording: the height assertion first
compared against the control's border box and failed on correct behaviour -- the
button fills the CONTENT box, and the 1px above and below it is the field's own
border, which it should sit inside rather than paint over.  Each fix planted
separately; the icon change reddens three, the footer one, the clear button one.
Every case is paired with an inverse -- a labelled button must keep its icon on
the left, and the field must still be padded around its other children.

1128 jest / 63 suites, 226 in ui-layout.cy.js, clean npm run build.  Confirmed
live: footer gaps 8px on one row, config glyphs at 0.0px from centre, clear
button 1px from the field edge at the field's full inner height.
2026-08-08 18:05:50 -06:00
Roland
5e15f89594
review: a busy button is not a disabled one, and night's danger is not red
Acting on the review of #624.  Two bugs, both real, both confirmed in the
browser against the live stylesheet before fixing.

Mantine renders a loading button as `disabled={disabled || loading}`, so every
busy control in the app is `:disabled` -- an APIButton mid-request, the refresh
button, the flasher's connect.  Mantine's own grey repaint is careful to exclude
`[data-loading]` for exactly that reason; the new rule was not, so "working..."
faded to 0.45 and read as unavailable rather than active.  Measured: a loading
button came back at opacity 0.45.  Both roots now mirror Mantine's exclusion.

Night encodes danger as a dashed border on a transparent fill, because it has no
second hue to spend.  The general disabled rule repaints from `--button-bg` --
the filled red Mantine leaves in the variable -- and at (0,2,1) it tied with
`html[data-theme="night"] .wrolpi-button-danger` and won on source order.
Measured: background rgb(255,87,87), border solid transparent.  So the file
browser's Delete, which is role='danger' and disabled until a selection exists,
became a solid red chip in the one theme where that is not what danger means.
Restated at (0,3,1) so it no longer depends on where it sits in the file.

The specificity note in the comment was also wrong, and wrong in a way that
would mislead the next person: Mantine's disabled paint is
`:where(:disabled:not([data-loading]), ...)`, and `:where()` contributes
nothing, so it scores (0,1,0).  The `html` prefix is not needed to beat that --
it is needed to stay level with the app's own `html ...` overrides, and the
night danger collision is the demonstration.

The fade is now `--disabled-opacity`, read by the tests instead of restated, and
asserted for equality rather than against a band -- a band tolerates an
accidental different fade or an opacity leaking in from elsewhere and still
reports success.

Five new cases from the review: loading versus disabled (paired, so it cannot
pass by nothing fading at all), night's dashed danger with a light-theme inverse
proving the fill is not stripped everywhere, cross-colour distinctness in all
four themes rather than only light -- night and amber are where one hue makes
collapse most likely -- and an outline pair checking text and border, which are
the only things a fill-less variant has to lose.  Each planted: dropping the
loading exclusion reddens one, dropping the night restatement reddens one.

1128 jest / 63 suites, 216 in ui-layout.cy.js, clean npm run build.
2026-08-08 18:05:50 -06:00
Roland
1f890668b2
ui: a disabled button keeps its own colour
The file browser's footer is eight buttons, six of them disabled until something
is selected, so the toolbar a user meets on arriving at /files was an
undifferentiated grey row: Delete, Rename, Move, Ignore and Tag rendered as five
identical pixels.  Colour is how those are told apart at a glance.

Mantine repaints a disabled button flat grey -- background, text and border all
replaced with `--mantine-color-disabled`.  Semantic kept the colour and dropped
the whole control to `opacity: 0.45`; measured on the QA Pi, a disabled Delete
is still red at 0.45, Rename still yellow, Tag still violet.  That is the better
signal: it says "not available" without also saying "no longer the delete
button".

Mantine leaves the variables alone and overrides only the painted properties, so
the colour the call site asked for is still sitting in `--button-bg` and can
simply be reinstated.  Applies to every disabled Button and ActionIcon, not only
the file browser's -- it is one convention, and having two would be worse than
either.

A pre-existing test earned its keep here: the IconStack corner disc was pinned
to `--mantine-color-disabled` so it would follow a disabled button's grey, and
it reddened the moment the button stopped being grey.  That override is gone;
`--button-bg` is now correct in both states, so the general rule covers them.

Ten new cases.  Fill equality is checked against each button's own enabled twin
in all four themes, and separately across the five colours at once -- comparing
each against its twin would still pass if every pair were grey, which is the
actual bug.  Opacity is asserted from both sides, because keeping the colour
must not cost the affordance.  Removing the rule reddens eleven.

1128 jest / 63 suites, 209 in ui-layout.cy.js, clean npm run build.
2026-08-08 18:05:49 -06:00
Roland
3f1e0b386f
ui: the corner triggers, the More button, and the search modal
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.
2026-08-08 18:05:49 -06:00
Roland
3204fb51ea
ui: centre the pagination strip
It sat hard against the left edge of its container despite three call
sites wrapping it in `<center>` or `text-align: center`.

Mantine builds a Pagination as a BLOCK root -- full width, so those
wrappers had nothing to centre -- holding a flex Group that carries the
controls and lays them out at `flex-start`.  So `justify-content` on the
root does nothing at all; it has to reach the Group.

Setting `--group-justify` on the root does not work either, which cost a
detour: Group defaults that prop and writes the variable INLINE on its own
element, where an inherited value cannot reach it.  The property itself is
fair game, since Mantine only ever reads the variable from a hashed class,
so `.wrolpi-pagination .mantine-Group-root { justify-content: center }` at
(0,2,0) is what lands.

The three wrappers are now plain divs keeping their margin.  `<center>` is
a deprecated element that was doing nothing here.

Two tests: the gap either side is equal -- measured, not read off
`justify-content` -- with an assertion that the container really is wider
than the strip, since otherwise the equality holds trivially at 0 and 0.
And an inverse: at 240px nothing hangs off the LEFT edge.  That one is the
risk centring introduces -- overflow in a flex-start row spills right,
where it can be scrolled to, but centred it spills both ways and the first
page becomes unreachable.  It passed before this change and must keep
passing after.

Verified red first (0px left against 230px right), and the wrong-element
fix -- centring the root rather than the Group -- reddens it again.

1080 jest, 160 in ui-layout.cy.js, clean build.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-08 18:05:47 -06:00
Roland
455f42faae
ui: act on review -- corner chrome, a swallowed className, and the poster frame
Three real defects from the PR review, each reproduced before it was fixed.

The tag mark and a video's duration were pinned to the poster BAND, which
is the full width of the card, rather than to the painted image.  Once the
height cap landed the two stopped being the same box: measured, the mark
sat 39px clear of a book cover, 56px clear of a small poster and 60px
clear of a vertical video, and the duration 55px clear.  It went unnoticed
because on a 16:9 poster the image fills the band and the two pin to the
same place -- so the tests cover the height-bound ratios, and assert the
letterbox gap is really there before measuring into it.

CardPoster now centres a frame that shrink-wraps the image, and the frame
is what corner chrome is positioned against.  VideoCard passes its
duration through a new `overlay` prop rather than wrapping CardPoster in a
box of its own.

The mark also went back INSIDE the link.  Splitting it out made the
tagged corner a dead spot on an otherwise clickable poster.

CardLink and ExternalCardLink wrote `className='...' {...props}`, so a
caller's className replaced the pair instead of adding to it.  Every
archive title passes `card-title-ellipsis`, so every archive title lost
`card-link` -- the one card type this PR migrates for layout was the one
missing the title treatment.  Both compose now.

On the fourth comment, the breadth of the ActionIcon remap: it is
deliberate and the numbers are small.  59 IconButton call sites, 52 of
them at the default size, 7 at `xs` (one in Flasher, three in the
inventory schema editor).  The one library control that stands beside an
input rather than a button is SearchBox's clear, and the remap is exactly
what that scale is for -- it now matches its field to the pixel, where
before it was 28px against a 36px input.  Guarded by a test that fails at
42 vs 36 if the default is reverted.

1078 jest, 158 in ui-layout.cy.js, clean build.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-08 18:05:47 -06:00
Roland
bfb2e71d02
ui: an icon button is the same height as the button beside it
The Details/Open pair on every archive card came out 36px beside 28px.

Mantine gives ActionIcon a size scale unrelated to Button's, and defaults
the two differently:

    size        xs    sm    md    lg    xl   default
    Button      30    36    42    50    60   sm (36)
    ActionIcon  18    22    28    34    44   md (28)

So the two never matched, at any size, and every toolbar mixing them was
ragged -- the archive card is just where it shows most.

Mantine already ships the aligned scale as `--ai-size-input-*`, for an
ActionIcon standing beside an Input; those five values are identical to
`--button-height-*`.  ui.css points one at the other, which fixes every
size at once including ones no call site uses yet, and IconButton now
defaults to `sm` like Button.

Both halves are needed and both are proven so: dropping just the `sm`
remap reddens the default and `sm` cases while xs/md/lg/xl stay green;
reverting just the default reddens the default case alone, at 42 vs 36 --
the mismatch inverted rather than fixed.

A further test pins the direction.  The pair could also be squared up by
cutting the labelled button down to 28px, which would be the wrong fix.

Gallery gains a row of Button/IconButton pairs at all five sizes.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-08 18:05:47 -06:00
Roland
687b41bf20
ui: cards get legible titles, contained posters, and meta beside the title
Four defects on /videos, /docs and /archives.

Titles were invisible outside light.  `.card-link` carried a literal
`color: black` from before the themes existed -- about 1.1:1 on the dark,
night and amber panels.  It applies to every card title and to the
channel/author/domain line beside it.  The date was fine, since it takes
`--muted` from the card body, which is what made this look like a card
bug rather than a link bug.  Titles now take `--text`: they are links,
but they are the card's text, not a wall of blue.

Posters were cropped, not mis-proportioned.  The image carried a fixed
290x163 box; a card in the default grid is about 233px wide and Mantine's
Card sets `overflow: hidden`, so the browser painted the middle 233px of
a 290px image.  The ratio was right in the layout tree and wrong on
screen.  Replaced with caps on both axes -- the card's width and
`--card-poster-max-height: 200px` -- with `width`/`height` left `auto` so
the pair behaves as "contain".  Bounding the width alone was tried first
and is not enough: a square thumbnail then stands as tall as the card is
wide, and a portrait book cover taller still.

The meta line was foot-anchored, so on /docs a PDF with no author had its
date shoved up to 55px below the title, at the bottom of a card that was
tall only because of its neighbour in the row.  It now sits directly
under the title.

Archive cards read buttons-then-domain, because the buttons were children
and the meta was what got pushed to the foot.  Card gains an `actions`
slot, rendered last and anchored to the foot, so a row lines its buttons
up however many lines each title took.

Gallery gains a poster row covering all five ratios and a row showing
meta/actions against unequal titles.

1076 jest tests, 145 in ui-layout.cy.js, clean build.  Every new guard was
checked against a planted regression: dropping the height cap reddens the
square and portrait cases and leaves 16:9 green; `width: 100%` reddens
containment; restoring `color: black` reddens dark/night/amber and leaves
light green; re-anchoring the meta and swapping the slot order redden both
the jest order test and the cypress geometry tests.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-08 18:05:47 -06:00
Roland
1ef3b8552e
test: act on review — a test that could not pass with Reduce Motion on
The indeterminate test read the band's position out of a running animation and
asserted it had left the box.  Two problems, and the second is the serious one:
it samples a moving target, and under `prefers-reduced-motion` the slide is
replaced by `width: 100%` with no negative margin, so the band never leaves the
box and the assertion is permanently false.  Anyone with Reduce Motion turned
on -- or a CI host with it set -- gets a hard failure on a correct build.

Reproduced it rather than taking it on faith: forcing the reduced-motion branch
for the whole run fails the old form and passes the new one.

The band is pinned now, to where the animation's first keyframe puts it, and the
assertion is on the clip plus that escape.  Deterministic, and true on any
machine.  Added the other branch too: with motion suppressed the bar fills
instead of sliding, and a band that never moves must never leave the box.

`text-overflow: ellipsis` on the label.  `nowrap` alone stopped it wrapping and
being bisected by the clip, but left a hard mid-glyph cut, and the call sites
that feed this are exactly the ones with long strings -- Upload.js and
FileBrowser.js label with a filename, Files.js with the error string.

The one-line test was close to circular: with `nowrap` applied, one line box is
almost a restatement of the rule.  It now also asserts the computed `nowrap` and
`ellipsis`, and that the line is genuinely WIDER than the space it has -- without
that, a label that simply fits would satisfy it.

Dropped a dead ternary from the air-around-label test; it built a rect nothing
used and made it look as though the measurement came from somewhere it did not.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-08 18:05:47 -06:00
Roland
5df38916f1
ui: progress bars get room for their label, and keep their band inside
Three things, plus an answer to why two sets of bars on the same page were
spaced differently.

The bar was 16px tall with an 11px bold label, leaving about half a pixel of air
above and below.  On the Status page that is 44 of them.  Now 22px with a 12px
label -- these labels carry device names and units, not just a number.

The indeterminate band escaped the bar.  It slides on `margin-left` from -35% to
100%, and nothing clipped it, so the animation ran from outside the left edge to
outside the right one.  `overflow: hidden` on the bar.

The CPU and RAM bars were touching while the drive-bandwidth bars on the same
page were not, and the difference was not the bars: CPU and RAM are bare
siblings inside a Panel, while each bandwidth bar sits alone in a Grid column
and takes its spacing from the Grid.  `* + .wrolpi-progress` distinguishes those
two by itself -- it fires only when a bar actually follows something, so an
only-child bar in a column is untouched.  A blanket margin would have doubled
the Grid's gutter, and that is asserted too.

The gallery gains a Progress section: six percentages, custom labels, no label,
indeterminate, and the four roles.  Its own demo gaps were removed so what it
shows is the spacing the component really has.

MEASURED while building the gallery, and NOT fixed: the label reads 5.49:1
against the fill in light -- the brightness filter is what buys that -- but 2.35
in dark, 2.48 in night and 2.74 in amber.  Against the empty track it is 5.2 to
15:1 everywhere.  Reaching AA over the fill needs the label drawn twice and
clipped at the fill boundary so each half takes its own colour, and in night not
even that suffices: `--text` is only about 4:1 against pure black and a fill is
lighter than black by definition.  Recorded in ui.css and in the test, which
asserts the track half only rather than picking a threshold low enough to pass.

One test was unfalsifiable and is rewritten.  "Keeps a long label from widening
the bar" cannot fail: the label is `position: absolute; inset: 0` and takes no
part in its parent's width.  What a long label actually does is wrap to a second
line inside a 22px bar and get cut in half by the new clip, so the test measures
line boxes now.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-08 18:05:47 -06:00
Roland
fc0958369b
test: act on review — and one of the seven turned out to be undemonstrable
Six real fixes and one honest retraction.

The control is now a SIBLING of the heading inside a flex row, not a child of
it.  Inside the `<h3>` it joined the heading's accessible name, so screen reader
heading navigation announced "Root CA Certificate Help".  The row is what keeps
them on one line, and it is only rendered when there is something to sit beside,
so the markup for the other 46 call sites is unchanged.

`style` now lands on the wrapper.  It used to reach the outer div; going through
Header put it on the inner heading, where a margin sets space INSIDE the wrapper
and only worked at all through margin collapse -- which stops the moment the
wrapper gains padding or a border.  Status's "Drive Bandwidth" is the live case:
verified 16px on the block, 0 on the h2.  Text properties still arrive, because
colour and text-align inherit; the `color` PROP stays on the heading.

A `<label>` only when `for_` names a field.  Several call sites pass an
InfoHeader as the `label` prop of InputForm/ToggleForm, which wraps it in a
label of its own -- an unconditional one nested labels, which is invalid and
makes clicking the text ambiguous.  Verified: zero `label label` on
/videos/settings, where "Video Resolutions" keeps its label and "Video File
Format" no longer has one.

The help controls carried `iconStyle: {margin: '0.5em'}`, which separated a
free-floating sibling and, inside the row, added to the gap on every side.
Zeroed; the row's gap is the spacing.

The browser test mounted a plain Button in an `after` slot rather than a real
HelpHeader, so the default icon chrome above was exactly what it could not see.
It mounts the real wrapper now.  Two of my assertions were also too weak to
catch what they claimed: the gap was measured to `.wrolpi-header-after`, a flex
container whose own rect does not move when a child gains a margin, so it had to
measure the button; and the squeeze case used a title of ordinary words, which
simply wraps and never puts the row under pressure.

Renamed the suites -- "keeps the icon on its own line" read as the opposite of
the fix.

The retraction: `flex-shrink: 0` is declared but has NO test. Three attempts
could not find a width where removing it changes what is painted -- the row's
only other item is a heading, and its automatic minimum size absorbs the
pressure; the help control is an ActionIcon with a min-width it will not go
below either.  The declaration stays as a statement of intent, with a comment
saying it is unproven, rather than a green test that does not discriminate.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-08 18:05:46 -06:00
Roland
c18fe84783
ui: headings open their section, and keep their help icon on one line
Two things, and the second was a regression this migration caused.

A heading had `margin: 0 0 10px` -- no top margin at all -- so it sat flush
against whatever came before and read as part of it rather than as the start of
the next thing.  On /more/statistics "Tags" touched the panel above it with a
0px gap.  Now 22px above and 10px below, because a heading belongs to what
follows it.

`* + .wrolpi-header` rather than a blanket margin, for the same reason
StatisticGroup needed it: a heading that opens a Panel or a page is already
where it should be, and pushing it down just dents the top of its own container.

The help icon on every HelpHeader and InfoHeader had dropped to its own line --
"Root CA Certificate" in Settings is the clearest one.  `.inline-header` set
`h1..h5 {display: inline-block}` so the heading and a sibling `<span>` shared a
row, which worked while these were a bare `<h2>` and stopped the moment they
gained a block wrapper: the `<label>`'s block child forced a break.

Fixed in the library rather than patched in App.css.  `Header` takes an `after`
slot rendered inside the heading element, where the existing flex row already
handles alignment and spacing.  Both wrappers pass their icon through it, the
`<label>` moves inside the heading so `for_` keeps associating the text with its
field (three call sites rely on it), and the `.inline-header` rules are deleted.
Fifteen call sites are fixed by fixing the two wrappers.

Tests: containment in jest, and the part jsdom cannot judge -- that the two
boxes actually share a line, and that the heading stays one row tall -- in a
browser.  Planted both original shapes: the margin removed, a blanket margin
denting its container, and the control moved back outside the heading element.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-08 18:05:46 -06:00
Roland
b7ef6af8bd
test: act on review — and correct an overstated claim about the toast bug
I said all four toast kinds were "the same pixel" in night.  They were not.
Planting a reversion to hue names left the new distinctness test GREEN, because
in night `--blue`, `--green`, `--yellow` and `--red` are four different values
(2.29, 3.14, 4.52, 5.64 against a panel).  What they are is cramped and badly
ranked, with info at 2.29:1 -- below the 3:1 floor, present and invisible.

The byte-identical pairs are real but elsewhere: `--orange` is `--text` and
`--yellow` is `--amber`, which bites a call site that reaches for those names,
not the toast mapping.  The PR body has been corrected too.

So the distinctness assertion is not the one with teeth; the legibility floor
is.  The monochrome toast test now measures every kind against the toast's own
surface, and the hue reversion fails it naming the number: 2.29:1.

Also from review:

`.wrolpi-message-error` still forced `border: 1.5px dashed var(--red)` while its
accent and icon had moved to roles -- nearly the same pixel in night, so no
colour test could see it.  It follows `--message-color` now.  Six more library
rules were naming a hue for a thing that means danger: the night danger button,
invalid form fields, invalid paths, path error text, danger panels and failed
table rows.  All roles now, with a source guard that fails any rule whose
SELECTOR means severity and whose body names a hue.  Focus rings and active-tab
accents deliberately keep `--blue`: those are the primary accent, not severity.

The tokens.css note claimed the brightness ramp held "in every theme" when the
tests deliberately limit it to monochrome.  Narrowed, with the reason spelled
out, because the next person to read it would otherwise have "fixed" light to
match the prose -- which would mean reordering light's hues to satisfy a test.

Added the end-of-chain cases the review asked for: four toasts and four Messages
in night and amber, measured rather than inferred from variable names.

Colour reads now go through a strict `toRgb` that refuses anything not already
resolved.  The reviewer's concern that `getPropertyValue('--info')` returns an
unresolved `var(--blue)` does not hold -- custom properties are substituted at
computed-value time and it returns `#20629c` -- but feeding an unresolved value
to a luminance calculation yields NaN, and `expect(NaN).to.not.equal(x)` passes.
Worth refusing loudly.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-08 18:05:46 -06:00
Roland
4341350c40
ui: semantic roles, so night and amber can tell severity apart at all
A component now asks for what a colour MEANS -- `danger` -- and the theme
decides how that looks.  Five roles: neutral, info, success, warning, danger.

The names are the ones already in the tree.  `--primary/--success/--warning/
--danger` were declared in tokens.css with ZERO consumers, and `MessageKind`
was already `info|success|warning|error`; this is one vocabulary instead of
three.  `error` stays a valid kind because 26 call sites write it, and `danger`
is the same thing under the token's name for new code.

The token set is the easy half.  Light and dark spend a hue per role.  Night is
red and nothing else and amber is amber and nothing else, and there a role has
to be a step on a BRIGHTNESS ramp -- which is why this was worth doing:

  night  --orange is byte-identical to --text
         --yellow is byte-identical to --amber
  amber  the same collisions, one hue over

A call site picking between hue names in those themes is picking between
synonyms.  That is why all four toast kinds were the same pixel in night, and
why an error and an informational message were indistinguishable.

Both ramps are built rather than copied: every role clears 3:1 on the panel
(neutral is allowed lower -- for pending and disabled, dim IS the signal),
severity is strictly increasing in contrast, and danger reaches that theme's
own --text brightness so the loudest role is never quieter than prose.  Amber
needed its own scale, not night's: its --text is 8.6:1 where night's is 5.0:1,
so night's ramp would have left every role dimmer than ordinary text.

Fixes in passing: night's info was 2.29:1, under the 3:1 floor.  And Status
loses its three `html[data-theme="night"]` overrides entirely -- it was the one
component already encoding severity by brightness by hand, and the roles are
that generalised.

Roles are registered as Mantine colors too, so `color='danger'` works anywhere
`color='red'` does.

`isMonochromeTheme` is a new predicate in themes/names.  It is the same set as
`explicitOnlyThemes` today and deliberately separate: one is about how a theme
is chosen, the other about what a theme has to work with.

Scope: tokens plus the library.  The ~24 app-level `color='...'` call sites are
a follow-up -- they need a judgement per site, and two of them already disagree
(Downloads.js colours the same "Download error" label `orange` on one line and
`red` on another).  The 22 `violet` uses are not semantic at all and stay hues.

Tests: the ramp claims are asserted per theme in a browser, and the ordering
and danger-vs-text claims run ONLY for the monochrome themes -- in light, blue
is darker than green on white, so a brightness ramp there would fail a design
that is perfectly fine.  Every guard checked red against a planted regression,
including the original bug (warning and danger collapsed to one value in night)
and night's info back at its old 2.29:1.

The roles were folded into each theme's own block rather than kept in a
standalone one, so the existing "same tokens in every theme" and "only red hues
in night" guards cover them for free.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-08 18:05:46 -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
b15a9408f1
test: act on review — the skeleton guard was reading the wrong layer
The "separates the skeleton from the panel" cases could not fail.  A Skeleton
is three layers: the element paints nothing, ::before paints
--mantine-color-body (`--bg`) at z-index 10, and ::after paints the bar the
user actually sees (`--head` in light, `--border` in dark) at z-index 11, on
top.  `paintedBackground` walked outward from the element and returned the
first paint, which is ::before -- so all four cases compared the page
background to the panel.  Those differ by design in every theme, so the bars
could have collapsed into the panel entirely and every assertion stayed green.

My planted regression did not catch it because the plant was broader than the
claim: it forced all three layers to the panel colour, so the test went red
for a reason the fix did not depend on.  The plant now sets only ::after, and
a second plant sets only ::before and must stay GREEN.

The layer order is now the caller's argument, since "the background" is
ambiguous once pseudo-elements are involved and guessing makes a test vacuous
rather than wrong.

Also from review:

The disc on a filled button was pinned to `--blue` at the call site, but WROL
mode disables that button and Mantine repaints it `--mantine-color-disabled`
-- a blue chip on a grey button, the original hole inverted.  The surface now
comes from the cascade (`--button-bg`, and the disabled colour for any
variant), so the call site says nothing at all and cannot be wrong about a
state it does not control.  Keying on `[data-variant="filled"]` alone matched
none of our buttons: Mantine omits the attribute when a call site names a
colour but no variant, which is how every filled button here is written.

The Checkbox behaviour test asserted only that a handler fired, against a box
held at `checked={false}` that could never change.  It is now uncontrolled and
asserts the box is checked, plus a second test that the handler sees the new
state -- read inside the handler, because `currentTarget` is null by the time
a mock is inspected.

The empty-caption test counted every div in the container; it counts the
loader's siblings now.  Two comments corrected: Pill emits no `--pill-radius`
at all and its own stylesheet falls back to `1000rem`, and the nav bar no
longer relies on the default surface.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-08 18:05:45 -06:00
Roland
affe5869f5
ui: test the last four components, and put the last four in the gallery
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>
2026-08-08 18:05:45 -06:00
Roland
213b940a1b
ui: toasts move to the top right, get readable titles, and get tests
The title was unreadable in dark, night and amber.

Mantine paints a dark-scheme notification title with `--mantine-color-white`,
and the bridge aliases that to `--btn-text` -- deliberately, because it is
the text drawn on a filled button, near-black in those three themes.  So
the title came out rgb(28, 31, 33) on a rgb(38, 41, 43) surface: about
1.1:1, present and invisible.  Fixing the alias is not an option, it is
correct for the buttons it exists for, so notifications now take their
surface, border and text from our tokens instead.  Measured after: 14.9,
11.9, 5.0 and 8.6 to one.

The description follows the same route, and night and amber drop its
muting.  `--muted` on a panel is 2.0:1 in night and 3.1:1 in amber -- the
description is the message itself, so those two let the title's weight
carry the hierarchy instead.  (`--muted` being that dim in night is not
only a toast problem; statistic labels and inactive tabs use it too.  Left
alone here.)

Position moves from bottom right to top right.  Bottom right put toasts
over the file browser's sticky footer and a video's player controls, which
are the two places a background task is most likely to report while the
user is doing something else.

Gallery gets a Toasts section: one button per type, one that stacks a
numbered toast per press, one that fills the container to its limit of
five, one that never expires, one with no title, one with text long enough
to wrap, and clear-all.  A toast is the one component that cannot be seen
by loading a page, so it needs buttons rather than a sample.

Tests
-----
Thirteen, where there were none for thirteen call sites -- and `toast` has
already failed once app-wide, when App.js dropped Semantic's container
while seven pages still imported the old helper and every notification
silently went nowhere.

Six were verified red against planted defects (a shared `id`, which makes
Mantine replace instead of stack; one colour for every type; `autoClose`
taking the 0 literally).

The duration tests use real timers with short durations, which matters.
With fake timers the element is never removed from the DOM -- hiding a
notification runs an exit transition, and advancing the clock, even
awaited inside `act`, does not complete it.  Both the assertion and its
control then read the same whatever the code does.  The test for `time: 0`
passed against a planted `autoClose: 0` twice before this was rewritten:
once asserting synchronously, once with a control that could not fail
either.  It now waits for a normally-expiring toast to disappear first, so
dismissal is demonstrably working before the survivor is asserted.
2026-08-08 18:05:44 -06:00
Roland
92059f8342
ui: sit the tag's point on the visible edge, not a pixel inside it
`right: 100%` positions against the containing block's *padding* box, and
the tag body carries a 1px border, so a square centred there sits a pixel
inside the visible left edge.  The point read as set into the tag rather
than growing out of it.  Nudged left by exactly that pixel, with the
margin that reserves room for it following.

Two pixels was too far: the point's edges then meet the body's left edge
short of its corners, and the difference shows as a gap on the inner side.

The geometry test that was supposed to cover this could not tell the
difference.  It asserted the tip lands far to the left of the body, which
is true either way -- the un-nudged tip is only 2px further right and
still well clear.  The claim is about where the point is *centred*, so it
measures that now, and was checked against the un-nudged version.

Its neighbour, which checks the left margin covers the protrusion, is
deliberately still geometry-agnostic: the margin-to-protrusion
relationship is the invariant, and the exact offset is a matter of eye.
It also now derives the protrusion from half the square's diagonal rather
than half its side, which had made it loose enough to pass against a
margin 5px short.
2026-08-08 18:05:44 -06:00
Roland
fe92455ee6
ui: an inactive tab was a near-white block in dark, night and amber
Found while checking tags in the gallery, and unrelated to them.

A tab is a NavLink in the app's nav bars, but a <button> on the Inventory
page and in the theme gallery.  A button carries the UA's own `buttonface`
background -- rgb(239, 239, 239) in Chrome -- and only `.wrolpi-tab-active`
set a background, so every inactive tab painted that near-white block.
Invisible in light, and a bright patch in the other three; in night it is
the one thing the theme exists to prevent.

One line, and a test in all four themes.
2026-08-08 18:05:44 -06:00
Roland
d697265267
ui: statistics carry no chrome, only spacing
The group had a frame, a surface and a hairline between every cell.  It
now has none of those: a plain responsive grid, transparent, so the
statistics sit on whatever they were dropped onto and take its colour --
the page on the gallery, a Panel on Apps and Status.

That also retires the problem the previous commit went two rounds on.
Ruling the cells needs a technique that survives wrapping, because
`auto-fit` settles the number of rows at paint time: a border per cell
rules columns but not rows, and a border-coloured background behind a 1px
gap rules both but paints every track no cell landed in.  With no rules to
draw, neither case exists.  The gap is wider across than down, since two
statistics side by side have only air between them while stacked ones
already have the label's line.

The margin above the group is conditional on having a sibling before it.
A plain `margin-top` reads correctly under the Header that precedes most
groups, but Apps opens each of its Panels with one, and there it pushed
the values down off the panel's top padding while the bottom stayed put,
leaving every panel lopsided.

Five browser tests described hairlines that no longer exist.  They are
replaced by the claims that now hold -- real space between wrapped rows,
a wider column gap than row gap, transparency and no frame anywhere, and
the conditional margin -- each verified red first: against flush rows for
the spacing, against a background-carried hairline for the transparency,
and against an unconditional margin for the panel case.

Per-theme, the pair to check is no longer the hairline and the surface but
the value and the label: with nothing framing a statistic, the contrast
between those two is the only thing giving it structure.
2026-08-08 18:05:44 -06:00
Roland
a2aa1352fb
ui: Statistic and StatisticGroup, and the four defects they had
These three exports had no test of their own.  Statistic had already been
caught out once -- it relied on Semantic's `Statistic.Group` for its
spacing, so it broke silently when that was uninstalled -- and they are
on the Status, Dashboard, Apps, Docs, Videos, Archive and Channels pages.

Four defects, each with a test that goes red without the fix:

* StatisticGroup ruled between columns only, with a `border-right` on
  every cell but the last.  `auto-fit` wraps, so the seven statistics on
  the Docs page became four rows on a phone with no rule between them,
  and the cell ending each earlier row drew its border straight onto the
  group's outer one, making that edge 2px on the right and 1px on the
  left.  The hairlines are now a 1px grid gap filled by an outline on
  each cell, which rules both axes however the tracks fall.

  Ruling the gaps with a border-coloured background behind the grid --
  which is where this went first -- has the same reach but paints every
  track no cell landed in: an odd count left the last slot a solid grey
  block.  An outline is drawn only where a cell is.  Both of these are
  guarded, because the second was introduced while fixing the first.

* A statistic that renders nothing left a padded cell and a stray
  hairline mid-row.  FanRPMStatistic returns null on a device with no fan
  connector, which is most of them, so this was the Status page's normal
  appearance.  The group cannot render a child to find out, so the cell
  is still emitted and `:empty` takes it out of the grid -- which is why
  the cells carry no inline style for that rule to have to outrank.

* Statistic destructured five props and dropped the rest.  LoadStatistic
  and the four Status wrappers all end in `{...props}`, so anything
  passed through a wrapper vanished with no error anywhere.

* VideosStatistics passed `style={{margin: '2em'}}` to every statistic,
  Semantic-era spacing that the grid now provides.  It beat the group's
  own rule, being inline, and made that one page's statistics sit
  differently from the six others.

CardGroup was the only component in the library with no class of its own,
which left nothing to assert against -- `container.firstChild` in jsdom
is Mantine's injected <style> element.  It has one now, and takes
className/style/rest like its siblings.

The gallery grows a seventh statistic, so the group wraps at a normal
window width where the row hairlines can be seen, and a second group of
coloured readings as Status renders them.

Geometry, hairlines and per-theme token resolution are in ui-layout.cy.js
rather than jest: jsdom returns zeros from getBoundingClientRect and
never resolves a var(), so it cannot judge any of it.
2026-08-08 18:05:44 -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
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
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
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
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
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