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>
The calculators page had seven "?" glyphs in a row -- `cogs`, `tint`, `food`,
`signal`, `thermometer`, `car`, `th large` -- and `arrow left` was unmapped
while `arrow down`, `right` and `up` were all there. Also `tags`, `comments`,
`chevron down`, and `closed captioning outline`.
An unmapped name is not subtle: it renders IconHelpCircle and logs a console
error. It shipped anyway, because nothing failed on it.
Three tests already guarded this and all three missed it. They cover the
theme picker's choices, the names the mimetype helpers can return, and the
map's own integrity -- none of them looks at what call sites write. The new
guard walks src/ and checks every name it finds, in each of the four
spellings, each of which was hiding at least one of these:
<Icon name='x'/> and icon='x' the plain JSX prop
{icon: 'x'} a data table; how the calculators declare theirs
icon={active ? 'tags' : 'tag'} a ternary, where `tags` was unmapped
name={s === 'descending' ? ...} a comparison operand, which is NOT a name
The last one is why the extraction strips comparison operands first: the sort
arrow in DataTable tests a direction, and reading 'descending' as an icon name
would be a false positive that trains everyone to ignore this test.
Verified by planting each spelling in the file it was found in, and by
reverting the map additions -- the guard reports them. Also swept thirteen
routes in a real browser with console.error hooked: no icon errors left.
Drops `taggedImageLabel`, a Semantic Label prop bag with no consumers, whose
`corner: 'left'` was never an icon name at all.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
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>
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>
All five points were right.
* The gallery's Toasts copy still read "Bottom right" after the container
moved. Anyone using that page as a visual checklist would have looked in
the wrong corner and concluded the change had not landed.
* Nothing observed ThemeProvider's own wiring. Both suites mount their own
`<Notifications position='top-right' limit={5}/>`, so moving the container
back, or dropping it, would have failed neither -- while the comments
claimed "container missing while call sites still fire" as the failure
already shipped once. Three tests in ThemeProvider.test.js now raise a
real toast through the provider and assert it appears, and where.
Asserted through a toast rather than by querying for a container: Mantine
renders an empty container for every position it supports, so the obvious
selector returns `top-center` no matter how the provider is configured.
That is what the first version of this test did, and it failed for that
reason rather than the intended one.
The five-at-once test is honest about what it covers: Mantine's own
default limit is also 5, so it does not prove the prop is there. It
catches the limit being raised.
* The default duration was untested. Sleeping 150ms and finding the toast
still up rules out "vanishes next tick" and nothing else -- it passes for
a 200ms default, for `false`, and for Mantine's own 4000ms. Read the
deadline back off Mantine's store instead: 5000 when unset, the caller's
number when given, `false` for 0.
* Space activates a followable toast in `toast.ts` and only Enter was
covered, so dropping that branch stayed green.
* Colour assertions used `toContain('red')`, which any declaration
containing those three letters satisfies. They match the whole custom
property now.
Planted four regressions to check the new tests: position, limit, the
5000ms default, and the Space branch. Three went red; the limit one did
not, for the reason recorded above.
The title contrast was verified by hand and by nothing else. jsdom cannot
check it: it resolves no `var()`, so the title's colour there is the literal
string `var(--mantine-color-white)` and every contrast assertion passes.
Six browser tests now cover it -- title and description at WCAG AA in each
of the four themes, the title staying heavier than the message where night
and amber drop its muting, and the toast taking `--panel` for its surface.
The three that can fail were checked against the fix removed: 1.13:1 in
dark, night and amber. Light passes either way, correctly, because
Mantine's light-scheme title uses `--mantine-color-black`, which the bridge
maps to `--text`.
Getting there took three corrections, all of them mine:
* `cy.mountUI` never set `forceColorScheme`, so every theme rendered
against Mantine's LIGHT scheme. Mantine keys its own rules on
data-mantine-color-scheme, and ThemeProvider sets it from the theme --
night and amber ride on dark. Any per-theme test could therefore pass on
a page that was broken in the app. This is the harness the tag contrast
tests use too.
* The support file never imported `@mantine/notifications/styles.css`,
which App.js does, so a Notification rendered unstyled and had no
surface or title colour of Mantine's to be wrong about.
* The spec's own helper mounted a second time to raise the toast, which
reset the theme to the default. All four per-theme cases ran in light.
For the record, the chain the fix breaks:
`.m_3feedf16` (the title) in dark scheme is `color: var(--mantine-color-white)`,
the bridge aliases that to `--btn-text`, and `--btn-text` is the text drawn
on a filled button -- near-black in dark, night and amber. Mantine writes
it through `:where(...)`, which has zero specificity, so a single-class
override plus `html` outranks it.
No, it did not still work. Semantic's toast took an `onClick` in its
options and made the whole toast activate it. The migrated helper
destructured four keys and dropped the rest, silently, so every toast that
carried one became a dead toast.
`Events.js` still passes one -- it did before the migration and was never
changed -- for the three events that arrive with a URL:
* `user_notify_message`, a page shared from the browser extension
* `upload_archive`
* `screenshot_generated`
The first renders the description "Click here to view the shared page",
which it was not. Nothing threw and nothing looked wrong; the toast
simply stopped being a way to reach the thing it was announcing.
`onClick` is passed through again, and a toast carrying one gets a pointer
cursor so it looks like what it is. Clicks on the dismiss button do not
follow it -- the button is inside the toast, so its click reaches the root,
and closing a notification is not the same as following it.
It is also reachable from the keyboard now, which it never was: WROLPi runs
on devices with a keyboard and no mouse, and a toast only a pointer can
follow is a feature half the deployments cannot use. No `role`, because a
`button` or `link` role may not contain interactive content and the dismiss
button is in there.
Mantine also leaves that dismiss button unnamed, so a screen reader read it
as "button". It has a label now, which is how the test for the
dismiss-does-not-follow case can find it.
Five tests, all red first. The gallery gets a Clickable button; following
the toast raises a second one, so it is obvious whether the handler ran.
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.
Both from review, and both correct.
* The tag edit preview and the Flasher's chip badges still wrote the
calculated text colour to `color`. That is the same defect the last
commit fixed on the tag chip itself: an inline declaration no stylesheet
rule can outrank, so night keeps the colour calculated for the fill it
has just discarded and a bright fill leaves black text on near-black.
The preview is the worse of the two -- it is the preview of the colour
being chosen, so it was misrepresenting what the tag would look like at
the moment the user picks it. It now shows the tag shape as well.
Fixing the first site did not fix these, and the stylesheet comment
explaining the night and amber override already named the Flasher. So
there is a source guard now: no element carrying `wrolpi-label` may set
`color` in its style object. Verified against a planted violation,
which it reports by file.
* `hexToRGBArray` rejected the shorthand `#fff`, which left the luminance
at zero -- a near-white tag was read as black and handed light text.
Reachable because tag colours are not only picked in the colour picker:
they are stored in a config, and a config is a file a user edits by
hand. Three tests were red first, including one asserting the shorthand
and the six-digit form of the same colour agree.
`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.
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.
Four things, three cosmetic and one a real legibility bug.
* Tags were square chips. They now have a pointed left edge and an
eyelet, like a tag tied to a garment -- the shape Semantic gave them,
and the reason a tag reads as a tag rather than as a small button. The
point is a square rotated behind the left edge, sized so its diagonal
equals the body height and its two edges meet the body at the corners.
Only where something is actually a tag. `Label` carries the count
badges in the Tags table too -- file, zim, channel and domain counts --
and a point on a number reads as an arrow, so the shape is behind a
`tag` prop and `NameToTagLabel` opts in.
* They were smaller than what they replaced: 11.5px text with 3px of
padding, against Semantic's 12px and ~7px. Now 12.5px and 5px, which
also keeps them a reasonable thing to tap.
* Wrapped rows of tags sat on the row above, so they carry a bottom
margin, plus a left margin for the point to occupy.
* The text colour was unreadable in night and wrong in amber.
Tags.js calculates black-or-light against the colour the user chose for
each tag, and wrote the answer to `color` inline -- a declaration no
stylesheet rule can outrank. Night turns a tag into an outline over a
near-black page, so a near-white tag kept the black text calculated for
its fill and was left legible only by its border: 1.1:1, measured. Amber
replaces every fill with its own bright orange and was painting `#dddddd`
on `#ff9500`, about 1.9:1, and a neutral grey in a theme whose whole
point is that no other hue reaches the eye.
The fix is to stop hard-coding `color` so the theme can decide: the
calculated colour goes into `--label-text`, and night and amber override
it alongside the fill they already override. There is no second contrast
function to write, which is what this looked like it needed -- once a
theme has discarded the user's colour there is nothing left to calculate
against, and the answer is just the theme's own token.
`.wrolpi-label-white` and `-black` moved to the same variable. Setting
`color` there outranked the base rule, so amber was painting `--white` on
`--text`: light orange on orange.
`contrastingColor` was also picking the worse of its two options. It
compared a brightness figure against a threshold, and the figure weighted
the gamma-encoded bytes without linearising them, so Semantic's blue
`#2185d0` got light text at 2.9:1 where dark text gives 5.3:1. Every
mid-tone blue, teal and purple a user might pick sat in that band. It
now measures the WCAG contrast ratio of both candidates and returns the
better one, which needs no threshold at all.
Tests
-----
`Tags.test.js` is new, and covers the chip nothing covered: every other
spec takes `NameToTagLabel` from `tagsContextFixture`, which stubs it as
a function returning the name. That is how a tag could be unreadable
without a single test noticing. Two of these were verified red against
the inline `color`.
`cy.contrastRatio()` measures text against the surface *actually* behind
it, walking up to the first ancestor that paints something -- a
transparent tag has no background of its own, so a naive check reads
rgba(0,0,0,0) and concludes anything is legible. Twelve cases, three
tag colours across four themes, at WCAG AA. Plus a test asserting the
old inline-`color` version still measures below 1.5:1, so the check is
provably able to fail.
The gallery had label swatches in theme colours and no user-coloured tag,
which is why none of this was visible in it. It has eleven now, spanning
the range where the black-or-light decision flips.
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.
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.
Both of Greptile's findings on #591 were real, and the first was a plain miss:
mockModule shipped with no caller, so its documented usage had never once been
executed and could not have worked. It called jest.requireActual on a path
passed in by the spec, which resolves relative to test-utils.js rather than to
the caller -- for any spec in a subdirectory the documented
'../hooks/customHooks' pointed outside src altogether. It now takes the module
the spec has already resolved, and throws on a string rather than quietly
resolving to the wrong one, which was the original failure.
It also has a caller now. FileBrowser.test.js listed six hooks in a hand-written
factory and returned only those, so every other export of customHooks was
undefined for anything it rendered -- the exact landmine the helper exists to
remove.
The second: `inverted` drove Mantine's colour scheme on its own, so asking for
night or amber through the new theme option produced a tree that reported a dark
theme while Mantine rendered its light one. Production cannot reach that
combination, and a harness that invents states the app never has is worse than
one that is merely incomplete -- what a test proves about such a tree is true of
nothing. The scheme is now derived from the theme name via isDarkTheme, and
data-theme is stamped on <html>, without which a component test cannot exercise
any token table or the night-mode treatment at all. isDark remains overridable
so a spec can still make the two disagree deliberately.
test-utils.test.js covers the harness itself, which had none. Eight of its
twelve assertions were red against the code above.
986 jest, 46 e2e, clean cache-busted build, tsc clean.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Specs had no shared vocabulary for the app's data or its contexts, so each one
rebuilt both. A settings object was written out by hand wherever one was needed,
twenty-odd keys at a time, and nothing held those copies to each other or to the
API. Contexts were worse: to render a consumer a spec mocked the whole context
module, which replaces every export including the providers, so each spec
reinvented the pieces it still needed. One supplies a `Media` that always
reports desktop -- quietly leaving that component's mobile branch untested -- and
one hand-writes a context as a bare `_currentValue` with no provider, working only
because useContext reads that React internal.
test-fixtures.js is now the single definition of both, shared by jest, the
Cypress component specs, and e2e intercept bodies, so the three cannot disagree
about what the API returns. Every fixture is a function taking overrides; a
shared mutable object lets one test change what the next one sees.
The drift this prevents was already real. The theme fixture supplied `i`, `s`
and `t` holding hardcoded Semantic greys for months after ThemeProvider stopped
supplying them, and ThemeContext still declared all four. So the fixture agreed
with the tests, and with nothing else: FileBrowser was reading `inverted` off the
context and pasting the resulting `undefined` into a className, which is why the
file browser's footer -- near-white #f9fafb, with a `.inverted` dark variant that
nothing could select any more -- stayed light in dark, night and amber. A white
bar pinned to the bottom of the screen in night mode costs the user exactly the
dark adaptation the theme exists to protect. It takes tokens now.
test-fixtures.test.js holds each fixture to its context's declared shape in both
directions, so a property added to one and forgotten in the other fails a test.
Two failed attempts are recorded in test-utils.js because both were instructive.
Importing Tags.js and FileWorkerStatusContext.js so the harness could supply
those providers broke eight suites: it put both into the module graph of every
spec ahead of that spec's own mocks, and components bound to a second instance of
a mocked module -- a different jest.fn() than the test had configured, so a hook
mocked to return a value returned undefined, in a file no test mentions.
Requiring them lazily fixed that and not its mirror image: useContext read a
different TagsContext than the harness had required, so the provider was ignored
and the context default won. A harness must not change what a spec imports, so
it owns only contexts/contexts.js and a spec passes any other context itself.
DomainTagging is converted off the `_currentValue` fake as proof, and now asserts
the tag name a user sees rather than a test id its own mock invented. The five
remaining specs are listed in a ratchet so the count can only fall.
974 jest, 15 browser component, 46 e2e, clean cache-busted build, tsc clean.
Fixtures are confirmed absent from the shipped bundle.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
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>
The PathInput overlap could not have been caught by jest -- jsdom computes no
layout -- so the only guard was a grep for one spelling of one mistake. This
adds the layer that can actually see it, and it found a real bug immediately.
ui-layout.cy.js mounts library components in a real browser through a new
cy.mountUI(), which supplies Mantine's provider and a stamped data-theme and
nothing else. That spartan setup is the point: these components read no
ambient state, so a spec can assert what jsdom cannot compute -- real geometry,
resolved custom properties, applied filters -- across all four themes.
It reproduces the leftSectionWidth='auto' overlap on purpose and asserts it
still overlaps, because a geometry check that has never gone red is
indistinguishable from one that cannot fail.
The bug it found: the rule resetting `filter` on leaf media inside a `.media`
wrapper scored (0,2,2) against the (0,3,1) rule it was correcting, so it lost
every time and never applied to anything. Every image inside a filtered
wrapper has been through the luminance matrix twice since that rule landed --
markedly darker than its surroundings, which is exactly what it was written to
prevent. tokens.test.js asserted only that the rule's text was present, and so
stayed green throughout; it now compares the two selectors' specificity, which
is computable without a browser and goes red on the old selector.
Three guards in ui.test.js keep the library mock-free by construction rather
than by habit: no jest.mock in its own tests, no fetch in any library file, and
no useContext outside the theme controls, whose job is to read the theme. All
three were verified red against planted violations.
959 jest tests, 14 new browser tests, clean cache-busted build.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
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.
`semantic-ui-react`, `react-semantic-toasts-2` and `semantic-ui-offline` are
gone from package.json, the lockfile and node_modules -- 19 MB of dependencies
that nothing had imported since the migration finished.
Four things still referenced them and would have broken on uninstall. None was
caught by a build or by jest:
Four test files still ran `jest.mock('react-semantic-toasts-2', ...)` for
components that no longer use it. A jest.mock of a module that does not exist
throws, so the packages could not have come out without this.
`src/components/Download.cy.js` and `cypress/support/component.js` both
imported semantic.min.css. The support file now loads what the app loads --
index.css, the font and token stylesheets, and Mantine's.
The ratchet changes job rather than being deleted. An import of a missing module
fails loudly now, but three quieter mistakes would not: a `className="ui button"`
that styles nothing, a file reaching back through Theme.tsx, and someone
reinstalling a package to "just use one Semantic component". It asserts all
three, plus that package.json names nothing matching /semantic/i.
Two pre-existing problems in the cypress component harness surfaced while
checking the uninstall had not broken it, both fixed because that file had to be
edited anyway:
It imported `react-router-dom`, which is not installed -- this app is on
react-router v7. Dates to 5621d473, so the component suite has been
un-runnable since well before this migration.
Its mount helper supplied no MantineProvider, so every library component threw
-- the same gap test-utils.js had for jest.
The component specs now load and four pass; eleven assertions are still written
against markup from before they stopped running. Repairing those is its own
piece of work, deliberately not bundled in here. The e2e suite is unaffected:
43/43.
Videos.js has its own VideoCard, separate from the file cards in Files.js, so
restoring the accent there missed it -- archives and documents showed the edge
and videos did not.
Audited every remaining `<Card>` rather than fixing the one that was reported.
The only two without an accent are the map's place card and the loading
placeholder, and neither has a mimetype to colour, so both are correct.
Videos show blue and audio-only entries show violet, which is right rather than
inconsistent: both come from `mimetypeColor`, the same helper that colours the
file icons, so a card and its icon can never disagree.
Roland's call, and the better one: along the top the accent sat immediately
against the poster's own top border, so the two edges competed. On the bottom it
closes the card and reads as a property of the whole thing rather than a header
rule.
Two agents independently dropped it, each because our Card had no `color` prop
and each citing the other's precedent -- which is how a deliberate feature
quietly becomes a convention. Roland wanted it back.
Card takes a token colour name and draws it as a 3px edge along the top rather
than tinting the surface: surfaces stay flat per the design rules, and an edge
survives night mode without becoming a bright patch. Because it is a token, the
accent remaps per theme -- four hues in light and dark, a single one in night and
amber, which is the point of those themes.
Restored on every card that shows a file: the video, image and generic file cards
in Files.js, and the archive card. All of them take it from
`mimetypeColor(mimetype, path)`, the same helper that colours the file icons, so
a card and its icon agree.
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.
Replaces semantic-ui-react and Theme.tsx imports with components from
components/ui (Panel, Table, Modal, Confirm, Status, Select, TextInput,
Checkbox, IconButton, Message). Preserves every confirmation, disabled
state, and polling interval; service status now maps onto Status'
complete/active/pending/failed kinds instead of a raw color. Repairs
ControllerPage.test.js to render inside MantineProvider and to find the
unmount Confirm button by dialog role instead of DOM-order position,
since Mantine portals the modal ahead of the page content. Drops
ControllerPage.js from semantic-baseline.json now that it's migrated.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The navigation bar was the last visibly-Semantic surface, so this finishes the
visual migration. Its two fragile features survive intact: the navbar colour
still comes from the user's `nav_color` setting, now as `var(--<name>)` since
our token tables define all fourteen Semantic colour names, with text taking
`--btn-text` so every theme stays legible; and the warning-icon contrast swap
is unchanged, including the case where `lowWarningColor` is null and must mean
"no override" rather than `var(--null)`.
Fixed a pre-existing bug the migration exposed: the memory indicator passed its
colour to `Link`, which ignores it, so that icon never changed colour -- 81% and
95% RAM looked identical. Every sibling indicator colours the Icon; now this
one does too.
The agent that migrated this file also gave names to the warning indicators,
which were unlabelled icons inside links, so a screen reader now announces what
each one is. The three data-testid hooks are unchanged.
Baseline 15 -> 2. Only ControllerPage and Theme.tsx remain; Theme.tsx is last
by design, since it can only go once nothing imports its Semantic wrappers.
Replaces semantic-ui-react's Menu/Dropdown/Icon/Popup with plain flexbox
elements plus the Mantine-based Menu (for the mobile hamburger and desktop
"More" overflow), Tooltip, and Icon from ./ui. Nav color and the
warning-icon contrast swap keep their exact logic, now resolved through
CSS custom properties (var(--{navColor}), var(--btn-text)) instead of
Semantic color props. Adds accessible labels to previously-unlabelled
warning icons while preserving their data-testid hooks and threshold/
priority logic. New nav CSS lives in App.css (wrolpi-navbar*).
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.
Replace semantic-ui-react/Theme.js imports with the token-driven ui/
library: Tab -> Tabs, Segment -> Panel, Message/Label/Progress/Loader ->
their ui equivalents, Table.* compound API, Confirm-free flows preserved
exactly. Remove obsolete ThemeContext i/s/t spreads. Drop Flasher.js from
semantic-baseline.json now that it no longer imports Semantic UI.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Replaces every semantic-ui-react and ./Theme import with the token-driven
./ui library: Segment->Panel, Tab/Tab.Pane->Tabs (Tabs.List/Tabs.Tab/
Tabs.Panel), Loader active inline='centered'->Loading, Message/Message.Header
->Message kind/title, Statistic/StatisticValue/StatisticLabel->Statistic
value/label, Card/CardContent/CardHeader/CardMeta/CardDescription->Card
media/title/meta/children, Input label={...}->ActionInput action={...},
Form.Dropdown->Select, Form/Form.Field/TextArea->plain markup + Textarea.
Converts all three Semantic grid spellings (Grid.Row/Grid.Column, bare
GridRow/GridColumn) to flexbox for the two-up rows in ArchivePage and
DomainsPage, and to plain "row"/"column" divs (CollectionEditForm's existing
CSS-class convention, shared with still-unmigrated Channels.js/Playlists.js)
for the fields passed into CollectionEditForm in DomainEditPage.
ArchiveFileNameForm no longer goes through hooks/useForm's InputForm helper,
which still renders Semantic's Form.Input internally (hooks/useForm.js is
off-limits for this migration) -- it now builds its field directly against
form.getInputProps, mirroring the pattern Download.js already established.
Drops ThemeContext's obsolete i/s/t/inverted spreads throughout, and the
mimetype-color accent on Card (the new Card has no color prop; Videos.js's
already-migrated VideoCard dropped the same accent for the same reason).
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Migrated both files onto the token-driven ./ui component library: Table,
Modal, Button/IconButton, Checkbox, Header, Panel, Progress, Placeholder,
Skeleton, ActionInput/TextInput, IconStack, Menu/ButtonGroup for the slicer
split-button, and native <img>/<form> in place of Semantic's Image/Form.
Notable adaptations:
- Semantic's per-row `disabled` styling (no visual equivalent in the new
Table) is reproduced with inline opacity/pointer-events instead.
- DirectorySearch's autoFocus now passes straight through as a prop instead
of a nested `input={{autoFocus}}` object, matching SearchBox's API.
- The responsive icon+label footer buttons keep a single Button and toggle
children instead of swapping component type.
- FileBrowser.test.js: two upload-button assertions keyed off a literal
Semantic `.green` class; broadened to also match on the icon, mirroring
the sibling assertion already using that fallback.
Preserved as-is (reported, not fixed): getVideoPreviewModal's
`<Header as='h5'>{path['path']}</Header>` indexes a string with `['path']`,
which was already dead/undefined before this migration.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Ports admin/Downloads.js, Upload.js, and BulkTagModal.js to the token-driven
ui library: Table/Modal/Progress/Message/Button/IconButton replace their
Semantic and Theme.tsx equivalents. Failed download rows take the dashed
danger border via Table.Row's `failed` prop; retry/restart actions use
role='retry' and delete/stop actions use role='danger' instead of hardcoded
colors. RecurringDownloadsTable keeps its export name and props for
Archive.js. Updates BulkTagModal.test.js to render through the real
Mantine-backed components instead of mocking the retired ./Theme module, and
drops the three files from semantic-baseline.json now that they no longer
import Semantic UI.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Replace semantic-ui-react and ./Theme imports with ./ui across both
files: Grid -> Group/flexbox (two-up rows), Loader -> Loading, Segment
-> Panel, Statistic/StatisticLabel/StatisticValue -> Statistic +
StatisticGroup, Tab -> Tabs, Accordion -> Mantine Accordion.Item with
controlled string value, Dropdown -> Select, Message/Label/Placeholder
-> ui equivalents. Buttons take role (primary/save/retry/danger/cancel)
instead of Semantic color names where a role fits.
Zim unsubscribe now goes through a destructive Confirm (via APIButton's
confirmContent) instead of firing immediately, matching the
Confirm+destructive rule for irreversible actions; the outdated-Zims
delete flow keeps its rich custom Modal (dynamic file lists) but its
trigger and confirm buttons are styled destructive and labeled "Delete".
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Replaces semantic-ui-react and ./Theme imports with the token-driven ./ui
library: Segment->Panel, Tab->Tabs (Tabs.List/Tab/Panel, keepMounted=false
to preserve per-tab lazy render/fetch), Loader active->Loading, Input->
TextInput, Grid/Grid.Column->Mantine Grid/Grid.Col, Container->plain div,
color=/APIButton->role=. Comment/CommentGroup and Transition have no ui
equivalent, so the comment thread and audio-poster fade are rebuilt from
plain elements/tokens. The audio poster wrapper (custom play/pause/volume
glyphs drawn over the poster image) gets .media so night mode tints the
composite as one unit; the video/audio elements and poster img stay
untagged since they are already filtered leaves. Added ARIA button
semantics to the poster click target since it has no native href/button.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
All 43 cypress specs pass now (was 36 of 43). Five of the seven failures were
stale selectors, two were real.
Real, and both mine:
The search modal stopped focusing its input. Mantine mounts modal content on
a later tick, so the effect that focused it ran while the ref was still null;
Semantic's modal mounted synchronously, which is why focusing there had been
enough. Fixed declaratively -- the field asks for focus itself, and SearchBox
also sets `data-autofocus`, which is what Mantine's focus trap honours when it
takes focus away from a plain autoFocus.
Escape closed the suggestion list and the enclosing modal at once, so a user
correcting a mistyped query lost the whole modal. Escape now dismisses the
suggestions and stops there; a second Escape, with the list closed, still
closes the modal.
Stale selectors, all in the specs: Semantic's icon font (`i.search.icon`) is
SVG now, its `.ui.modal`/`.ui.dimmer` are the library's modal and overlay, and
one test asserted a button carried the classes `ui button secondary`. That last
one is rewritten to check what a user can actually perceive -- that Edit is a
real link pointing at the edit route -- because framework class names say
nothing and break on every library change.
Two accessibility gaps the tests walked into:
The nav's search control was an anchor with an onClick, no name and no
keyboard path. It now has a name, a role and Enter/Space activation, which is
also what let the spec stop matching on icon-font classes.
The search dialog had no accessible name at all; it has one now.
One `{force: true}` remains, with its reason recorded: Cypress judges that
field covered because it sits in a fixed modal under a sticky header, and I
verified by hand at Cypress's own viewport that it is the topmost element at
its centre, unobscured, and focused on open.
Also closed a hole in the ratchet that this uncovered. A file could drop its
`semantic-ui-react` imports, leave the baseline, and still render Semantic
through Theme.tsx's wrappers -- which is exactly how a Semantic modal survived
in the search shortcut long after that file looked migrated. A test now
forbids it, and the three files doing it (Calculators, KeyboardShortcutsProvider,
and Common.js reaching for theme names) import from the library or from
themes/names instead.
Greptile P1, and a real one: App.js migrated and took `<SemanticToastContainer/>`
with it, but seven pages -- Archive, Channels, Docs, FilePreview, Playlists,
Zim, ControllerPage -- still raised their toasts through the Semantic library.
With no container mounted those notifications went nowhere. No error, no
warning; a user deletes a document and is simply never told whether it worked.
Fixed by pointing those seven at the library's `toast`, which takes the same
options, rather than by mounting a second toast system. Nothing calls the
Semantic toast library now, so a test asserts that at zero: a file that goes
back to it would raise notifications that silently never appear, and that is
not something a reviewer would spot.
Also reverted Archive.js to its last good state. An interrupted agent had
migrated part of it -- Panel, Tabs, and Mantine's Grid.Col -- while the rest of
the file still used Semantic's Segment, Tab and Grid.Row, so a single `Grid`
identifier had to be two different components at once. A half-migrated
1400-line file is worse than an unmigrated one; it goes back on the list and
gets done in one piece.
That break was masked: react-scripts caches ESLint results per file, so the
earlier build passed while Archive.js sat on a stale pass. Editing its import
invalidated the entry and the errors appeared. Verified with the cache cleared
this time, which is the only way that check means anything after a wide change.
Phase 2, part 4. Baseline 50 -> 15.
Migrated: all eight calculators, the rest of inventory and collections, the
Map/Zim/Docs cluster, Videos, Files, Search, Archive, DashboardPage,
DatesSelector, HelpModal, Events, Tags, Flasher's neighbours, admin Settings
and Status, Download, and App.
Also the three shared modules everything imports: api.js and customHooks.js
swap the Semantic toast library for the library's shim (same call shape), and
useForm.js renders the library's Message. useForm accepts both the old
`{content, header, positive|negative}` shape and the new `{kind, title}`,
because the pages building those objects migrate separately.
Two library gaps closed, both found by call sites needing them:
ActionInput is a forwardRef now. Its ref reaches the input rather than the
wrapper, because callers use it to focus or select the field -- a ref to the
surrounding div would do neither, which is why one call site had resorted to
hand-copying the component's markup.
Header takes a `color` token name. Three separate call sites had reached for
an inline style to colour a heading that carries meaning ("3 items to add"
against "2 to remove"), which is how a hex ends up in markup.
Two files needed repair after being interrupted mid-edit: MapViewer had a
Segment left in its error branch, and VideoPlayer had lost the useContext
import its own code still used. Neither jest nor tsc noticed; only
`npm run build` did, which is the whole reason that check exists.
Fifteen files remain: Archive, BulkTagModal, Channels, Docs, FileBrowser,
FilePreview, Flasher, Nav, Playlists, Theme.tsx, Upload, VideoPlayer, Zim,
ControllerPage, admin/Downloads. Theme.tsx is deliberately last -- the
unmigrated files still import its Semantic-backed wrappers, so it can only go
once they have.
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.
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.
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.
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.