wrolpi/app/src/components/Common.test.js

811 lines
33 KiB
JavaScript
Raw Normal View History

import React from 'react';
ui: stop /archives/settings wrapping itself in a second PageContainer `ArchiveRoute` wraps its `<Routes>` in a PageContainer, and `ArchiveSettingsPage` wrapped itself in another. PageContainer carries `margin-top: 1em` and `padding: 1em`, so that page sat 1em lower and 1em further in on all four sides than /archives/domains beside it -- which is exactly what "seems to have more margin" was. It is a fragment now, so its panels are blocks of the route's own stack. Guarded by a source scan rather than a render, since a doubled margin throws nothing: it takes each PageContainer that wraps a router, reads the components it routes to, and fails if one of them renders a PageContainer of its own. The first version of that scan passed against the live bug. It looked at only the first `<PageContainer>` in a file, and in Archive.js that is the offender itself, whose block holds no `<Routes>` -- so the file was skipped before the route wrapper 400 lines below was reached. It reads every occurrence now, and the premise assertions count both the wrappers found and the routes within them. Two more top-level blocks gave up margins that were being added to the page's gap, both headers "matching" each other's pattern: the domain filter row (35.2px against 17.6px) and the channel filter row. /archives/settings and /archives/domains now measure identically -- tab bar bottom at 136.8px, first block 17.6px below it, content inset 17.6px -- as do the statistics and settings pages of every other tab group. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-05 15:53:04 -06:00
import fs from 'fs';
import path from 'path';
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-07-30 10:00:48 -06:00
import {act, render, screen, waitFor} from '../test-utils';
import userEvent from '@testing-library/user-event';
ui: tags look like tags, and stay readable in every theme Four things, three cosmetic and one a real legibility bug. * Tags were square chips. They now have a pointed left edge and an eyelet, like a tag tied to a garment -- the shape Semantic gave them, and the reason a tag reads as a tag rather than as a small button. The point is a square rotated behind the left edge, sized so its diagonal equals the body height and its two edges meet the body at the corners. Only where something is actually a tag. `Label` carries the count badges in the Tags table too -- file, zim, channel and domain counts -- and a point on a number reads as an arrow, so the shape is behind a `tag` prop and `NameToTagLabel` opts in. * They were smaller than what they replaced: 11.5px text with 3px of padding, against Semantic's 12px and ~7px. Now 12.5px and 5px, which also keeps them a reasonable thing to tap. * Wrapped rows of tags sat on the row above, so they carry a bottom margin, plus a left margin for the point to occupy. * The text colour was unreadable in night and wrong in amber. Tags.js calculates black-or-light against the colour the user chose for each tag, and wrote the answer to `color` inline -- a declaration no stylesheet rule can outrank. Night turns a tag into an outline over a near-black page, so a near-white tag kept the black text calculated for its fill and was left legible only by its border: 1.1:1, measured. Amber replaces every fill with its own bright orange and was painting `#dddddd` on `#ff9500`, about 1.9:1, and a neutral grey in a theme whose whole point is that no other hue reaches the eye. The fix is to stop hard-coding `color` so the theme can decide: the calculated colour goes into `--label-text`, and night and amber override it alongside the fill they already override. There is no second contrast function to write, which is what this looked like it needed -- once a theme has discarded the user's colour there is nothing left to calculate against, and the answer is just the theme's own token. `.wrolpi-label-white` and `-black` moved to the same variable. Setting `color` there outranked the base rule, so amber was painting `--white` on `--text`: light orange on orange. `contrastingColor` was also picking the worse of its two options. It compared a brightness figure against a threshold, and the figure weighted the gamma-encoded bytes without linearising them, so Semantic's blue `#2185d0` got light text at 2.9:1 where dark text gives 5.3:1. Every mid-tone blue, teal and purple a user might pick sat in that band. It now measures the WCAG contrast ratio of both candidates and returns the better one, which needs no threshold at all. Tests ----- `Tags.test.js` is new, and covers the chip nothing covered: every other spec takes `NameToTagLabel` from `tagsContextFixture`, which stubs it as a function returning the name. That is how a tag could be unreadable without a single test noticing. Two of these were verified red against the inline `color`. `cy.contrastRatio()` measures text against the surface *actually* behind it, walking up to the first ancestor that paints something -- a transparent tag has no background of its own, so a naive check reads rgba(0,0,0,0) and concludes anything is legible. Twelve cases, three tag colours across four themes, at WCAG AA. Plus a test asserting the old inline-`color` version still measures below 1.5:1, so the check is provably able to fail. The gallery had label swatches in theme colours and no user-coloured tag, which is why none of this was visible in it. It has eleven now, spanning the range where the black-or-light decision flips.
2026-07-30 19:46:10 -06:00
import {
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-02 11:35:49 -06:00
CardLink, contrastingColor, contrastRatio, DirectorySearch, ExternalCardLink, HelpHeader,
InfoHeader, loadRole,
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-05 15:40:27 -06:00
PageContainer,
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-01 18:41:58 -06:00
SearchResultsInput, TAG_TEXT_DARK, TAG_TEXT_LIGHT,
ui: tags look like tags, and stay readable in every theme Four things, three cosmetic and one a real legibility bug. * Tags were square chips. They now have a pointed left edge and an eyelet, like a tag tied to a garment -- the shape Semantic gave them, and the reason a tag reads as a tag rather than as a small button. The point is a square rotated behind the left edge, sized so its diagonal equals the body height and its two edges meet the body at the corners. Only where something is actually a tag. `Label` carries the count badges in the Tags table too -- file, zim, channel and domain counts -- and a point on a number reads as an arrow, so the shape is behind a `tag` prop and `NameToTagLabel` opts in. * They were smaller than what they replaced: 11.5px text with 3px of padding, against Semantic's 12px and ~7px. Now 12.5px and 5px, which also keeps them a reasonable thing to tap. * Wrapped rows of tags sat on the row above, so they carry a bottom margin, plus a left margin for the point to occupy. * The text colour was unreadable in night and wrong in amber. Tags.js calculates black-or-light against the colour the user chose for each tag, and wrote the answer to `color` inline -- a declaration no stylesheet rule can outrank. Night turns a tag into an outline over a near-black page, so a near-white tag kept the black text calculated for its fill and was left legible only by its border: 1.1:1, measured. Amber replaces every fill with its own bright orange and was painting `#dddddd` on `#ff9500`, about 1.9:1, and a neutral grey in a theme whose whole point is that no other hue reaches the eye. The fix is to stop hard-coding `color` so the theme can decide: the calculated colour goes into `--label-text`, and night and amber override it alongside the fill they already override. There is no second contrast function to write, which is what this looked like it needed -- once a theme has discarded the user's colour there is nothing left to calculate against, and the answer is just the theme's own token. `.wrolpi-label-white` and `-black` moved to the same variable. Setting `color` there outranked the base rule, so amber was painting `--white` on `--text`: light orange on orange. `contrastingColor` was also picking the worse of its two options. It compared a brightness figure against a threshold, and the figure weighted the gamma-encoded bytes without linearising them, so Semantic's blue `#2185d0` got light text at 2.9:1 where dark text gives 5.3:1. Every mid-tone blue, teal and purple a user might pick sat in that band. It now measures the WCAG contrast ratio of both candidates and returns the better one, which needs no threshold at all. Tests ----- `Tags.test.js` is new, and covers the chip nothing covered: every other spec takes `NameToTagLabel` from `tagsContextFixture`, which stubs it as a function returning the name. That is how a tag could be unreadable without a single test noticing. Two of these were verified red against the inline `color`. `cy.contrastRatio()` measures text against the surface *actually* behind it, walking up to the first ancestor that paints something -- a transparent tag has no background of its own, so a naive check reads rgba(0,0,0,0) and concludes anything is legible. Twelve cases, three tag colours across four themes, at WCAG AA. Plus a test asserting the old inline-`color` version still measures below 1.5:1, so the check is provably able to fail. The gallery had label swatches in theme colours and no user-coloured tag, which is why none of this was visible in it. It has eleven now, spanning the range where the black-or-light decision flips.
2026-07-30 19:46:10 -06:00
} from './Common';
test: act on review — the browser test named LoadStatistic but never mounted it The suite was titled "a load reading ranks itself", commented as covering the thresholds, and cross-referenced from Common.test.js as the place the painted result is checked. It mounted a hand-built `<Statistic color='warning'/>`. So it proved that role tokens resolve distinctly -- which the roles suite two blocks up already covers -- and would have stayed green with LoadStatistic reverted to `orange`/`red`. The same shape as the column-border test in #609: a claim that outran its measurement. It mounts the real helper at the real thresholds now, and the reversion fails it. The source guards are gone. They scraped function bodies out of files with indexOf slices, covered two helpers, and never executed a branch. `loadRole` and `healthRole` are pure exported functions instead, tested as mappings: either side of both load thresholds, every SMART assessment, lower case (the value comes from smartctl), and the case that matters most -- an unreadable drive must report as unknown, never as healthy. That is also the better answer to the jsdom problem that produced the guards. jsdom rejects `color: var(--danger)` and drops it, so the mapping cannot be read back off a rendered component; extracting it is the honest fix rather than asserting on source text. And the gallery was itself half-migrated: 15 Min. Load became `danger` while 5 Min. Load stayed `orange` -- in the one sample whose caption tells the reader to go and check night and amber, where orange is byte-identical to --text. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-01 18:17:18 -06:00
import {healthRole} from './admin/ControllerPage';
// Mock debounce to make tests run faster
jest.mock('lodash/debounce', () => jest.fn(fn => {
fn.cancel = jest.fn();
return fn;
}));
// Mock useSearchDirectories hook to avoid async state updates that cause act() warnings
const mockSetDirectoryName = jest.fn();
let mockHookState = {
directoryName: '',
directories: [],
channelDirectories: [],
domainDirectories: [],
isDir: false,
loading: false,
};
jest.mock('../hooks/customHooks', () => ({
...jest.requireActual('../hooks/customHooks'),
useSearchDirectories: (value) => {
// Return current mock state - tests control state via setMockHookState
return {
...mockHookState,
setDirectoryName: (newValue) => {
mockHookState.directoryName = newValue;
mockSetDirectoryName(newValue);
},
};
},
}));
describe('DirectorySearch', () => {
const mockOnSelect = jest.fn();
const mockSearchResults = {
directories: [
{path: 'videos/nature'},
{path: 'videos/tech'}
],
channelDirectories: [
{path: 'videos/channels/news', name: 'News Channel'}
],
domainDirectories: [
{path: 'archive/example.com', domain: 'example.com'}
],
};
// Helper to reset mock hook state with specific values
const setMockHookState = (overrides = {}) => {
mockHookState = {
directoryName: '',
directories: [],
channelDirectories: [],
domainDirectories: [],
isDir: false,
loading: false,
...overrides,
};
};
beforeEach(() => {
jest.clearAllMocks();
// Reset mock hook state with default search results
setMockHookState({
...mockSearchResults,
isDir: false,
});
});
describe('Rendering', () => {
it('renders with placeholder text', () => {
2025-12-06 14:49:46 -07:00
render(<DirectorySearch onSelect={mockOnSelect} value=""/>);
const input = screen.getByPlaceholderText(/search directory names/i);
expect(input).toBeInTheDocument();
});
it('shows initial value when provided', () => {
setMockHookState({
...mockSearchResults,
directoryName: 'videos/test',
});
2025-12-06 14:49:46 -07:00
render(<DirectorySearch onSelect={mockOnSelect} value="videos/test"/>);
const input = screen.getByDisplayValue('videos/test');
expect(input).toBeInTheDocument();
});
it('applies disabled state correctly', () => {
2025-12-06 14:49:46 -07:00
render(<DirectorySearch onSelect={mockOnSelect} value="" disabled/>);
const input = screen.getByPlaceholderText(/search directory names/i);
expect(input).toBeDisabled();
});
it('renders the dropdown without crashing when directoryName is null', async () => {
// An unset form field (e.g. the new Channel page) passes a null value. With
// isDir=false a "New Directory" result is built from directoryName; if that title is
// null, Semantic UI's Search crashes reading null.length when the menu opens.
setMockHookState({
directories: [],
channelDirectories: [],
domainDirectories: [],
isDir: false,
directoryName: null,
});
render(<DirectorySearch onSelect={mockOnSelect} value={null}/>);
const input = screen.getByPlaceholderText(/search directory names/i);
// Opening the menu (as Tab/focus does in the browser) must not throw.
await userEvent.click(input);
await waitFor(() => {
expect(screen.getByText(/New Directory/i)).toBeInTheDocument();
});
});
it('displays with required indicator', () => {
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-07-30 10:00:48 -06:00
render(
2025-12-06 14:49:46 -07:00
<DirectorySearch onSelect={mockOnSelect} value="" required/>
);
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-07-30 10:00:48 -06:00
// Unlike Semantic UI's Search, the SearchBox forwards `required` straight
// onto the input.
const input = screen.getByPlaceholderText(/search directory names/i);
expect(input).toBeRequired();
});
});
describe('Search Functionality', () => {
it('triggers setDirectoryName on value change', async () => {
2025-12-06 14:49:46 -07:00
render(<DirectorySearch onSelect={mockOnSelect} value=""/>);
const input = screen.getByPlaceholderText(/search directory names/i);
await userEvent.type(input, 'videos');
// Verify setDirectoryName was called (via mocked hook)
expect(mockSetDirectoryName).toHaveBeenCalled();
// Debounce is mocked, so each character triggers a call
// Verify it was called 6 times (one per character in "videos")
expect(mockSetDirectoryName.mock.calls.length).toBe(6);
});
it('shows loading indicator when loading state is true', () => {
setMockHookState({
...mockSearchResults,
loading: true,
});
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-07-30 10:00:48 -06:00
const {container} = render(<DirectorySearch onSelect={mockOnSelect} value=""/>);
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-07-30 10:00:48 -06:00
// SearchBox renders a Loader alongside the input while `loading` is true.
expect(container.querySelector('.wrolpi-searchbox-loading')).toBeInTheDocument();
});
it('hides loading indicator when loading state is false', () => {
setMockHookState({
...mockSearchResults,
loading: false,
});
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-07-30 10:00:48 -06:00
const {container} = render(<DirectorySearch onSelect={mockOnSelect} value=""/>);
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-07-30 10:00:48 -06:00
expect(container.querySelector('.wrolpi-searchbox-loading')).not.toBeInTheDocument();
});
it('displays categorized results', async () => {
2025-12-06 14:49:46 -07:00
render(<DirectorySearch onSelect={mockOnSelect} value=""/>);
const input = screen.getByPlaceholderText(/search directory names/i);
// Click to open dropdown
await userEvent.click(input);
await waitFor(() => {
// Should show category names
expect(screen.getAllByText(/Directories/i).length).toBeGreaterThan(0);
expect(screen.getAllByText(/Channels/i).length).toBeGreaterThan(0);
expect(screen.getAllByText(/Domains/i).length).toBeGreaterThan(0);
});
});
it('shows "New Directory" when path doesn\'t exist (isDir=false)', async () => {
setMockHookState({
directories: [],
channelDirectories: [],
domainDirectories: [],
isDir: false,
directoryName: 'new/path',
});
2025-12-06 14:49:46 -07:00
render(<DirectorySearch onSelect={mockOnSelect} value=""/>);
const input = screen.getByPlaceholderText(/search directory names/i);
await userEvent.click(input);
await waitFor(() => {
expect(screen.getByText(/New Directory/i)).toBeInTheDocument();
});
});
it('hides "New Directory" when path exists (isDir=true)', async () => {
setMockHookState({
directories: [{path: 'videos/nature/wildlife'}],
channelDirectories: [],
domainDirectories: [],
isDir: true,
directoryName: 'videos/nature',
});
2025-12-06 14:49:46 -07:00
render(<DirectorySearch onSelect={mockOnSelect} value=""/>);
const input = screen.getByPlaceholderText(/search directory names/i);
await userEvent.click(input);
// "New Directory" should not appear when is_dir=true
expect(screen.queryByText(/New Directory/i)).not.toBeInTheDocument();
});
it('debounces rapid typing (verifies setDirectoryName is called)', async () => {
2025-12-06 14:49:46 -07:00
render(<DirectorySearch onSelect={mockOnSelect} value=""/>);
const input = screen.getByPlaceholderText(/search directory names/i);
// Type rapidly
await userEvent.type(input, 'abc', {delay: 10});
// Verify setDirectoryName was called
expect(mockSetDirectoryName).toHaveBeenCalled();
});
});
describe('User Interactions', () => {
it('calls onSelect when result is clicked', async () => {
2025-12-06 14:49:46 -07:00
render(<DirectorySearch onSelect={mockOnSelect} value=""/>);
const input = screen.getByPlaceholderText(/search directory names/i);
await userEvent.click(input);
await waitFor(() => {
expect(screen.getByText('videos/nature')).toBeInTheDocument();
});
// Click on a result
const result = screen.getByText('videos/nature');
await userEvent.click(result);
expect(mockOnSelect).toHaveBeenCalledWith('videos/nature');
});
it('commits typed value on blur when directoryName differs from value', async () => {
// Set up mock state where directoryName differs from the prop value
// This simulates what happens after user types in the input
setMockHookState({
...mockSearchResults,
directoryName: 'typed/path', // User has typed this
});
// Render with empty value prop (different from directoryName)
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-07-30 10:00:48 -06:00
render(<DirectorySearch onSelect={mockOnSelect} value=""/>);
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-07-30 10:00:48 -06:00
// The SearchBox wires `onBlur` straight to the input (there is no longer a
// wrapping Search component to blur instead).
const input = screen.getByPlaceholderText(/search directory names/i);
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-07-30 10:00:48 -06:00
// Blur the input - should trigger onBlur which calls onSelect
await act(async () => {
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-07-30 10:00:48 -06:00
const {fireEvent} = require('../test-utils');
fireEvent.blur(input);
});
// Should call onSelect with directoryName from hook state
expect(mockOnSelect).toHaveBeenCalledWith('typed/path');
});
it('does not call onSelect on blur if value unchanged', async () => {
setMockHookState({
...mockSearchResults,
directoryName: 'existing/path',
});
2025-12-06 14:49:46 -07:00
render(<DirectorySearch onSelect={mockOnSelect} value="existing/path"/>);
const input = screen.getByDisplayValue('existing/path');
// Blur without changing value
await act(async () => {
input.blur();
});
// Should not call onSelect since value didn't change
expect(mockOnSelect).not.toHaveBeenCalled();
});
it('disabled state prevents interactions', () => {
2025-12-06 14:49:46 -07:00
render(<DirectorySearch onSelect={mockOnSelect} value="" disabled/>);
const input = screen.getByPlaceholderText(/search directory names/i);
// Input should be disabled
expect(input).toBeDisabled();
});
it('handles rapid selection changes', async () => {
2025-12-06 14:49:46 -07:00
render(<DirectorySearch onSelect={mockOnSelect} value=""/>);
const input = screen.getByPlaceholderText(/search directory names/i);
await userEvent.click(input);
await waitFor(() => {
expect(screen.getByText('videos/nature')).toBeInTheDocument();
});
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-07-30 10:00:48 -06:00
// Click multiple results in succession. Choosing a result closes the
// dropdown (as any combobox would) without moving focus off the input
// (the option is chosen on mousedown, with the default prevented), so
// pressing a key that reopens the list -- same as a real user -- is
// needed before the next selection.
await userEvent.click(screen.getByText('videos/nature'));
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-07-30 10:00:48 -06:00
await userEvent.keyboard('{ArrowDown}');
await waitFor(() => {
expect(screen.getByText('videos/tech')).toBeInTheDocument();
});
await userEvent.click(screen.getByText('videos/tech'));
// Should call onSelect for each selection
expect(mockOnSelect).toHaveBeenCalledWith('videos/nature');
expect(mockOnSelect).toHaveBeenCalledWith('videos/tech');
});
});
describe('Hook Integration', () => {
it('calls setDirectoryName from hook on search change', async () => {
2025-12-06 14:49:46 -07:00
render(<DirectorySearch onSelect={mockOnSelect} value=""/>);
const input = screen.getByPlaceholderText(/search directory names/i);
await userEvent.type(input, 'archive');
expect(mockSetDirectoryName).toHaveBeenCalled();
});
it('displays results from hook state', async () => {
2025-12-06 14:49:46 -07:00
render(<DirectorySearch onSelect={mockOnSelect} value=""/>);
const input = screen.getByPlaceholderText(/search directory names/i);
await userEvent.click(input);
await waitFor(() => {
// Should display results from mock hook state
expect(screen.getByText('videos/nature')).toBeInTheDocument();
expect(screen.getByText('videos/tech')).toBeInTheDocument();
expect(screen.getByText('News Channel')).toBeInTheDocument();
});
});
});
describe('Edge Cases', () => {
2025-12-05 18:26:36 -07:00
it('renders without error when directories are null (initial state)', async () => {
// Simulate the initial state before API call completes
setMockHookState({
directories: null,
channelDirectories: null,
domainDirectories: null,
isDir: false,
directoryName: '',
loading: true,
});
// This should not throw an error
2025-12-06 14:49:46 -07:00
render(<DirectorySearch onSelect={mockOnSelect} value=""/>);
2025-12-05 18:26:36 -07:00
const input = screen.getByPlaceholderText(/search directory names/i);
// Clicking input should not throw "Cannot read properties of null"
await userEvent.click(input);
// Component should still be rendered
expect(input).toBeInTheDocument();
});
it('handles null/undefined initial value', () => {
setMockHookState({
...mockSearchResults,
directoryName: '',
});
2025-12-06 14:49:46 -07:00
render(<DirectorySearch onSelect={mockOnSelect} value={null}/>);
const input = screen.getByPlaceholderText(/search directory names/i);
expect(input).toHaveValue('');
});
it('clears results display when empty', async () => {
setMockHookState({
directories: [],
channelDirectories: [],
domainDirectories: [],
isDir: false,
directoryName: '',
});
2025-12-06 14:49:46 -07:00
render(<DirectorySearch onSelect={mockOnSelect} value=""/>);
const input = screen.getByPlaceholderText(/search directory names/i);
expect(input).toHaveValue('');
});
it('maintains value when component remounts', () => {
setMockHookState({
...mockSearchResults,
directoryName: 'videos/test',
});
const {rerender} = render(
2025-12-06 14:49:46 -07:00
<DirectorySearch onSelect={mockOnSelect} value="videos/test"/>
);
expect(screen.getByDisplayValue('videos/test')).toBeInTheDocument();
// Remount with same value
2025-12-06 14:49:46 -07:00
rerender(<DirectorySearch onSelect={mockOnSelect} value="videos/test"/>);
expect(screen.getByDisplayValue('videos/test')).toBeInTheDocument();
});
it('handles special characters in path', async () => {
2025-12-06 14:49:46 -07:00
render(<DirectorySearch onSelect={mockOnSelect} value=""/>);
const input = screen.getByPlaceholderText(/search directory names/i);
// Type path with special characters
const specialPath = 'videos/test-folder_2024/v1.0';
await userEvent.type(input, specialPath);
expect(mockSetDirectoryName).toHaveBeenCalled();
});
});
});
describe('SearchResultsInput', () => {
const results = {
tags: {name: 'Tags', results: [{type: 'tag', title: 'Cooking'}]},
videos: {name: 'Videos', results: [
{title: 'How To Sharpen An Axe', description: 'Wranglerstar', location: '/videos/1'},
]},
};
it('hands the selected suggestion to its caller as {result}', async () => {
/*
* Regression: the migration passed the bare result, but every caller destructures
* `{result}` (see Search.js), so `result.location` was undefined and clicking a
* search suggestion navigated nowhere.
*/
const handleResultSelect = jest.fn();
render(<SearchResultsInput searchStr='axe' onSubmit={jest.fn()} results={results}
handleResultSelect={handleResultSelect}/>);
await userEvent.click(screen.getByRole('combobox'));
await userEvent.click(screen.getByText('How To Sharpen An Axe'));
expect(handleResultSelect).toHaveBeenCalledWith(
{result: expect.objectContaining({location: '/videos/1'})});
});
it('forwards resultRenderer so a caller keeps its own suggestion markup', async () => {
// Search.js renders a tag suggestion as a tag chip, not a line of text.
const resultRenderer = (result) => <span>rendered:{result.title}</span>;
render(<SearchResultsInput searchStr='cook' onSubmit={jest.fn()} results={results}
handleResultSelect={jest.fn()} resultRenderer={resultRenderer}/>);
await userEvent.click(screen.getByRole('combobox'));
expect(screen.getByText('rendered:Cooking')).toBeInTheDocument();
});
});
ui: tags look like tags, and stay readable in every theme Four things, three cosmetic and one a real legibility bug. * Tags were square chips. They now have a pointed left edge and an eyelet, like a tag tied to a garment -- the shape Semantic gave them, and the reason a tag reads as a tag rather than as a small button. The point is a square rotated behind the left edge, sized so its diagonal equals the body height and its two edges meet the body at the corners. Only where something is actually a tag. `Label` carries the count badges in the Tags table too -- file, zim, channel and domain counts -- and a point on a number reads as an arrow, so the shape is behind a `tag` prop and `NameToTagLabel` opts in. * They were smaller than what they replaced: 11.5px text with 3px of padding, against Semantic's 12px and ~7px. Now 12.5px and 5px, which also keeps them a reasonable thing to tap. * Wrapped rows of tags sat on the row above, so they carry a bottom margin, plus a left margin for the point to occupy. * The text colour was unreadable in night and wrong in amber. Tags.js calculates black-or-light against the colour the user chose for each tag, and wrote the answer to `color` inline -- a declaration no stylesheet rule can outrank. Night turns a tag into an outline over a near-black page, so a near-white tag kept the black text calculated for its fill and was left legible only by its border: 1.1:1, measured. Amber replaces every fill with its own bright orange and was painting `#dddddd` on `#ff9500`, about 1.9:1, and a neutral grey in a theme whose whole point is that no other hue reaches the eye. The fix is to stop hard-coding `color` so the theme can decide: the calculated colour goes into `--label-text`, and night and amber override it alongside the fill they already override. There is no second contrast function to write, which is what this looked like it needed -- once a theme has discarded the user's colour there is nothing left to calculate against, and the answer is just the theme's own token. `.wrolpi-label-white` and `-black` moved to the same variable. Setting `color` there outranked the base rule, so amber was painting `--white` on `--text`: light orange on orange. `contrastingColor` was also picking the worse of its two options. It compared a brightness figure against a threshold, and the figure weighted the gamma-encoded bytes without linearising them, so Semantic's blue `#2185d0` got light text at 2.9:1 where dark text gives 5.3:1. Every mid-tone blue, teal and purple a user might pick sat in that band. It now measures the WCAG contrast ratio of both candidates and returns the better one, which needs no threshold at all. Tests ----- `Tags.test.js` is new, and covers the chip nothing covered: every other spec takes `NameToTagLabel` from `tagsContextFixture`, which stubs it as a function returning the name. That is how a tag could be unreadable without a single test noticing. Two of these were verified red against the inline `color`. `cy.contrastRatio()` measures text against the surface *actually* behind it, walking up to the first ancestor that paints something -- a transparent tag has no background of its own, so a naive check reads rgba(0,0,0,0) and concludes anything is legible. Twelve cases, three tag colours across four themes, at WCAG AA. Plus a test asserting the old inline-`color` version still measures below 1.5:1, so the check is provably able to fail. The gallery had label swatches in theme colours and no user-coloured tag, which is why none of this was visible in it. It has eleven now, spanning the range where the black-or-light decision flips.
2026-07-30 19:46:10 -06:00
describe('contrastingColor', () => {
/*
* This decides the text color on every tag, against a fill the user picked, so it is the
ui: tags look like tags, and stay readable in every theme Four things, three cosmetic and one a real legibility bug. * Tags were square chips. They now have a pointed left edge and an eyelet, like a tag tied to a garment -- the shape Semantic gave them, and the reason a tag reads as a tag rather than as a small button. The point is a square rotated behind the left edge, sized so its diagonal equals the body height and its two edges meet the body at the corners. Only where something is actually a tag. `Label` carries the count badges in the Tags table too -- file, zim, channel and domain counts -- and a point on a number reads as an arrow, so the shape is behind a `tag` prop and `NameToTagLabel` opts in. * They were smaller than what they replaced: 11.5px text with 3px of padding, against Semantic's 12px and ~7px. Now 12.5px and 5px, which also keeps them a reasonable thing to tap. * Wrapped rows of tags sat on the row above, so they carry a bottom margin, plus a left margin for the point to occupy. * The text colour was unreadable in night and wrong in amber. Tags.js calculates black-or-light against the colour the user chose for each tag, and wrote the answer to `color` inline -- a declaration no stylesheet rule can outrank. Night turns a tag into an outline over a near-black page, so a near-white tag kept the black text calculated for its fill and was left legible only by its border: 1.1:1, measured. Amber replaces every fill with its own bright orange and was painting `#dddddd` on `#ff9500`, about 1.9:1, and a neutral grey in a theme whose whole point is that no other hue reaches the eye. The fix is to stop hard-coding `color` so the theme can decide: the calculated colour goes into `--label-text`, and night and amber override it alongside the fill they already override. There is no second contrast function to write, which is what this looked like it needed -- once a theme has discarded the user's colour there is nothing left to calculate against, and the answer is just the theme's own token. `.wrolpi-label-white` and `-black` moved to the same variable. Setting `color` there outranked the base rule, so amber was painting `--white` on `--text`: light orange on orange. `contrastingColor` was also picking the worse of its two options. It compared a brightness figure against a threshold, and the figure weighted the gamma-encoded bytes without linearising them, so Semantic's blue `#2185d0` got light text at 2.9:1 where dark text gives 5.3:1. Every mid-tone blue, teal and purple a user might pick sat in that band. It now measures the WCAG contrast ratio of both candidates and returns the better one, which needs no threshold at all. Tests ----- `Tags.test.js` is new, and covers the chip nothing covered: every other spec takes `NameToTagLabel` from `tagsContextFixture`, which stubs it as a function returning the name. That is how a tag could be unreadable without a single test noticing. Two of these were verified red against the inline `color`. `cy.contrastRatio()` measures text against the surface *actually* behind it, walking up to the first ancestor that paints something -- a transparent tag has no background of its own, so a naive check reads rgba(0,0,0,0) and concludes anything is legible. Twelve cases, three tag colours across four themes, at WCAG AA. Plus a test asserting the old inline-`color` version still measures below 1.5:1, so the check is provably able to fail. The gallery had label swatches in theme colours and no user-coloured tag, which is why none of this was visible in it. It has eleven now, spanning the range where the black-or-light decision flips.
2026-07-30 19:46:10 -06:00
* one place in the app where legibility is computed rather than designed.
*/
it('picks whichever of the two options actually reads better', () => {
// Semantic's blue. The old threshold check chose light text here, at 2.9:1, when dark
// text on the same fill gives 5.3:1 -- the worse of the only two available answers.
// Every mid-tone blue, teal and purple a user might choose sat in that band.
const blue = '#2185d0';
const chosen = contrastingColor(blue);
const rejected = chosen === TAG_TEXT_DARK ? TAG_TEXT_LIGHT : TAG_TEXT_DARK;
expect(contrastRatio(chosen, blue)).toBeGreaterThan(contrastRatio(rejected, blue));
expect(chosen).toBe(TAG_TEXT_DARK);
});
it('never picks the worse option, for any color', () => {
ui: tags look like tags, and stay readable in every theme Four things, three cosmetic and one a real legibility bug. * Tags were square chips. They now have a pointed left edge and an eyelet, like a tag tied to a garment -- the shape Semantic gave them, and the reason a tag reads as a tag rather than as a small button. The point is a square rotated behind the left edge, sized so its diagonal equals the body height and its two edges meet the body at the corners. Only where something is actually a tag. `Label` carries the count badges in the Tags table too -- file, zim, channel and domain counts -- and a point on a number reads as an arrow, so the shape is behind a `tag` prop and `NameToTagLabel` opts in. * They were smaller than what they replaced: 11.5px text with 3px of padding, against Semantic's 12px and ~7px. Now 12.5px and 5px, which also keeps them a reasonable thing to tap. * Wrapped rows of tags sat on the row above, so they carry a bottom margin, plus a left margin for the point to occupy. * The text colour was unreadable in night and wrong in amber. Tags.js calculates black-or-light against the colour the user chose for each tag, and wrote the answer to `color` inline -- a declaration no stylesheet rule can outrank. Night turns a tag into an outline over a near-black page, so a near-white tag kept the black text calculated for its fill and was left legible only by its border: 1.1:1, measured. Amber replaces every fill with its own bright orange and was painting `#dddddd` on `#ff9500`, about 1.9:1, and a neutral grey in a theme whose whole point is that no other hue reaches the eye. The fix is to stop hard-coding `color` so the theme can decide: the calculated colour goes into `--label-text`, and night and amber override it alongside the fill they already override. There is no second contrast function to write, which is what this looked like it needed -- once a theme has discarded the user's colour there is nothing left to calculate against, and the answer is just the theme's own token. `.wrolpi-label-white` and `-black` moved to the same variable. Setting `color` there outranked the base rule, so amber was painting `--white` on `--text`: light orange on orange. `contrastingColor` was also picking the worse of its two options. It compared a brightness figure against a threshold, and the figure weighted the gamma-encoded bytes without linearising them, so Semantic's blue `#2185d0` got light text at 2.9:1 where dark text gives 5.3:1. Every mid-tone blue, teal and purple a user might pick sat in that band. It now measures the WCAG contrast ratio of both candidates and returns the better one, which needs no threshold at all. Tests ----- `Tags.test.js` is new, and covers the chip nothing covered: every other spec takes `NameToTagLabel` from `tagsContextFixture`, which stubs it as a function returning the name. That is how a tag could be unreadable without a single test noticing. Two of these were verified red against the inline `color`. `cy.contrastRatio()` measures text against the surface *actually* behind it, walking up to the first ancestor that paints something -- a transparent tag has no background of its own, so a naive check reads rgba(0,0,0,0) and concludes anything is legible. Twelve cases, three tag colours across four themes, at WCAG AA. Plus a test asserting the old inline-`color` version still measures below 1.5:1, so the check is provably able to fail. The gallery had label swatches in theme colours and no user-coloured tag, which is why none of this was visible in it. It has eleven now, spanning the range where the black-or-light decision flips.
2026-07-30 19:46:10 -06:00
// The property, rather than a list of examples: whatever it returns must be at least as
// legible as the alternative would have been.
const colors = [
ui: tags look like tags, and stay readable in every theme Four things, three cosmetic and one a real legibility bug. * Tags were square chips. They now have a pointed left edge and an eyelet, like a tag tied to a garment -- the shape Semantic gave them, and the reason a tag reads as a tag rather than as a small button. The point is a square rotated behind the left edge, sized so its diagonal equals the body height and its two edges meet the body at the corners. Only where something is actually a tag. `Label` carries the count badges in the Tags table too -- file, zim, channel and domain counts -- and a point on a number reads as an arrow, so the shape is behind a `tag` prop and `NameToTagLabel` opts in. * They were smaller than what they replaced: 11.5px text with 3px of padding, against Semantic's 12px and ~7px. Now 12.5px and 5px, which also keeps them a reasonable thing to tap. * Wrapped rows of tags sat on the row above, so they carry a bottom margin, plus a left margin for the point to occupy. * The text colour was unreadable in night and wrong in amber. Tags.js calculates black-or-light against the colour the user chose for each tag, and wrote the answer to `color` inline -- a declaration no stylesheet rule can outrank. Night turns a tag into an outline over a near-black page, so a near-white tag kept the black text calculated for its fill and was left legible only by its border: 1.1:1, measured. Amber replaces every fill with its own bright orange and was painting `#dddddd` on `#ff9500`, about 1.9:1, and a neutral grey in a theme whose whole point is that no other hue reaches the eye. The fix is to stop hard-coding `color` so the theme can decide: the calculated colour goes into `--label-text`, and night and amber override it alongside the fill they already override. There is no second contrast function to write, which is what this looked like it needed -- once a theme has discarded the user's colour there is nothing left to calculate against, and the answer is just the theme's own token. `.wrolpi-label-white` and `-black` moved to the same variable. Setting `color` there outranked the base rule, so amber was painting `--white` on `--text`: light orange on orange. `contrastingColor` was also picking the worse of its two options. It compared a brightness figure against a threshold, and the figure weighted the gamma-encoded bytes without linearising them, so Semantic's blue `#2185d0` got light text at 2.9:1 where dark text gives 5.3:1. Every mid-tone blue, teal and purple a user might pick sat in that band. It now measures the WCAG contrast ratio of both candidates and returns the better one, which needs no threshold at all. Tests ----- `Tags.test.js` is new, and covers the chip nothing covered: every other spec takes `NameToTagLabel` from `tagsContextFixture`, which stubs it as a function returning the name. That is how a tag could be unreadable without a single test noticing. Two of these were verified red against the inline `color`. `cy.contrastRatio()` measures text against the surface *actually* behind it, walking up to the first ancestor that paints something -- a transparent tag has no background of its own, so a naive check reads rgba(0,0,0,0) and concludes anything is legible. Twelve cases, three tag colours across four themes, at WCAG AA. Plus a test asserting the old inline-`color` version still measures below 1.5:1, so the check is provably able to fail. The gallery had label swatches in theme colours and no user-coloured tag, which is why none of this was visible in it. It has eleven now, spanning the range where the black-or-light decision flips.
2026-07-30 19:46:10 -06:00
'#000000', '#ffffff', '#f2f2f2', '#1b1c1d', '#2185d0', '#21ba45', '#db2828',
'#fbbd08', '#6435c9', '#a5673f', '#00b5ad', '#e03997', '#b5cc18', '#767676',
'#808080', '#7f7f7f',
];
for (const color of colors) {
const chosen = contrastingColor(color);
ui: tags look like tags, and stay readable in every theme Four things, three cosmetic and one a real legibility bug. * Tags were square chips. They now have a pointed left edge and an eyelet, like a tag tied to a garment -- the shape Semantic gave them, and the reason a tag reads as a tag rather than as a small button. The point is a square rotated behind the left edge, sized so its diagonal equals the body height and its two edges meet the body at the corners. Only where something is actually a tag. `Label` carries the count badges in the Tags table too -- file, zim, channel and domain counts -- and a point on a number reads as an arrow, so the shape is behind a `tag` prop and `NameToTagLabel` opts in. * They were smaller than what they replaced: 11.5px text with 3px of padding, against Semantic's 12px and ~7px. Now 12.5px and 5px, which also keeps them a reasonable thing to tap. * Wrapped rows of tags sat on the row above, so they carry a bottom margin, plus a left margin for the point to occupy. * The text colour was unreadable in night and wrong in amber. Tags.js calculates black-or-light against the colour the user chose for each tag, and wrote the answer to `color` inline -- a declaration no stylesheet rule can outrank. Night turns a tag into an outline over a near-black page, so a near-white tag kept the black text calculated for its fill and was left legible only by its border: 1.1:1, measured. Amber replaces every fill with its own bright orange and was painting `#dddddd` on `#ff9500`, about 1.9:1, and a neutral grey in a theme whose whole point is that no other hue reaches the eye. The fix is to stop hard-coding `color` so the theme can decide: the calculated colour goes into `--label-text`, and night and amber override it alongside the fill they already override. There is no second contrast function to write, which is what this looked like it needed -- once a theme has discarded the user's colour there is nothing left to calculate against, and the answer is just the theme's own token. `.wrolpi-label-white` and `-black` moved to the same variable. Setting `color` there outranked the base rule, so amber was painting `--white` on `--text`: light orange on orange. `contrastingColor` was also picking the worse of its two options. It compared a brightness figure against a threshold, and the figure weighted the gamma-encoded bytes without linearising them, so Semantic's blue `#2185d0` got light text at 2.9:1 where dark text gives 5.3:1. Every mid-tone blue, teal and purple a user might pick sat in that band. It now measures the WCAG contrast ratio of both candidates and returns the better one, which needs no threshold at all. Tests ----- `Tags.test.js` is new, and covers the chip nothing covered: every other spec takes `NameToTagLabel` from `tagsContextFixture`, which stubs it as a function returning the name. That is how a tag could be unreadable without a single test noticing. Two of these were verified red against the inline `color`. `cy.contrastRatio()` measures text against the surface *actually* behind it, walking up to the first ancestor that paints something -- a transparent tag has no background of its own, so a naive check reads rgba(0,0,0,0) and concludes anything is legible. Twelve cases, three tag colours across four themes, at WCAG AA. Plus a test asserting the old inline-`color` version still measures below 1.5:1, so the check is provably able to fail. The gallery had label swatches in theme colours and no user-coloured tag, which is why none of this was visible in it. It has eleven now, spanning the range where the black-or-light decision flips.
2026-07-30 19:46:10 -06:00
const rejected = chosen === TAG_TEXT_DARK ? TAG_TEXT_LIGHT : TAG_TEXT_DARK;
expect(contrastRatio(chosen, color))
.toBeGreaterThanOrEqual(contrastRatio(rejected, color));
ui: tags look like tags, and stay readable in every theme Four things, three cosmetic and one a real legibility bug. * Tags were square chips. They now have a pointed left edge and an eyelet, like a tag tied to a garment -- the shape Semantic gave them, and the reason a tag reads as a tag rather than as a small button. The point is a square rotated behind the left edge, sized so its diagonal equals the body height and its two edges meet the body at the corners. Only where something is actually a tag. `Label` carries the count badges in the Tags table too -- file, zim, channel and domain counts -- and a point on a number reads as an arrow, so the shape is behind a `tag` prop and `NameToTagLabel` opts in. * They were smaller than what they replaced: 11.5px text with 3px of padding, against Semantic's 12px and ~7px. Now 12.5px and 5px, which also keeps them a reasonable thing to tap. * Wrapped rows of tags sat on the row above, so they carry a bottom margin, plus a left margin for the point to occupy. * The text colour was unreadable in night and wrong in amber. Tags.js calculates black-or-light against the colour the user chose for each tag, and wrote the answer to `color` inline -- a declaration no stylesheet rule can outrank. Night turns a tag into an outline over a near-black page, so a near-white tag kept the black text calculated for its fill and was left legible only by its border: 1.1:1, measured. Amber replaces every fill with its own bright orange and was painting `#dddddd` on `#ff9500`, about 1.9:1, and a neutral grey in a theme whose whole point is that no other hue reaches the eye. The fix is to stop hard-coding `color` so the theme can decide: the calculated colour goes into `--label-text`, and night and amber override it alongside the fill they already override. There is no second contrast function to write, which is what this looked like it needed -- once a theme has discarded the user's colour there is nothing left to calculate against, and the answer is just the theme's own token. `.wrolpi-label-white` and `-black` moved to the same variable. Setting `color` there outranked the base rule, so amber was painting `--white` on `--text`: light orange on orange. `contrastingColor` was also picking the worse of its two options. It compared a brightness figure against a threshold, and the figure weighted the gamma-encoded bytes without linearising them, so Semantic's blue `#2185d0` got light text at 2.9:1 where dark text gives 5.3:1. Every mid-tone blue, teal and purple a user might pick sat in that band. It now measures the WCAG contrast ratio of both candidates and returns the better one, which needs no threshold at all. Tests ----- `Tags.test.js` is new, and covers the chip nothing covered: every other spec takes `NameToTagLabel` from `tagsContextFixture`, which stubs it as a function returning the name. That is how a tag could be unreadable without a single test noticing. Two of these were verified red against the inline `color`. `cy.contrastRatio()` measures text against the surface *actually* behind it, walking up to the first ancestor that paints something -- a transparent tag has no background of its own, so a naive check reads rgba(0,0,0,0) and concludes anything is legible. Twelve cases, three tag colours across four themes, at WCAG AA. Plus a test asserting the old inline-`color` version still measures below 1.5:1, so the check is provably able to fail. The gallery had label swatches in theme colours and no user-coloured tag, which is why none of this was visible in it. It has eleven now, spanning the range where the black-or-light decision flips.
2026-07-30 19:46:10 -06:00
}
});
it('returns one of the two text colors and nothing else', () => {
ui: tags look like tags, and stay readable in every theme Four things, three cosmetic and one a real legibility bug. * Tags were square chips. They now have a pointed left edge and an eyelet, like a tag tied to a garment -- the shape Semantic gave them, and the reason a tag reads as a tag rather than as a small button. The point is a square rotated behind the left edge, sized so its diagonal equals the body height and its two edges meet the body at the corners. Only where something is actually a tag. `Label` carries the count badges in the Tags table too -- file, zim, channel and domain counts -- and a point on a number reads as an arrow, so the shape is behind a `tag` prop and `NameToTagLabel` opts in. * They were smaller than what they replaced: 11.5px text with 3px of padding, against Semantic's 12px and ~7px. Now 12.5px and 5px, which also keeps them a reasonable thing to tap. * Wrapped rows of tags sat on the row above, so they carry a bottom margin, plus a left margin for the point to occupy. * The text colour was unreadable in night and wrong in amber. Tags.js calculates black-or-light against the colour the user chose for each tag, and wrote the answer to `color` inline -- a declaration no stylesheet rule can outrank. Night turns a tag into an outline over a near-black page, so a near-white tag kept the black text calculated for its fill and was left legible only by its border: 1.1:1, measured. Amber replaces every fill with its own bright orange and was painting `#dddddd` on `#ff9500`, about 1.9:1, and a neutral grey in a theme whose whole point is that no other hue reaches the eye. The fix is to stop hard-coding `color` so the theme can decide: the calculated colour goes into `--label-text`, and night and amber override it alongside the fill they already override. There is no second contrast function to write, which is what this looked like it needed -- once a theme has discarded the user's colour there is nothing left to calculate against, and the answer is just the theme's own token. `.wrolpi-label-white` and `-black` moved to the same variable. Setting `color` there outranked the base rule, so amber was painting `--white` on `--text`: light orange on orange. `contrastingColor` was also picking the worse of its two options. It compared a brightness figure against a threshold, and the figure weighted the gamma-encoded bytes without linearising them, so Semantic's blue `#2185d0` got light text at 2.9:1 where dark text gives 5.3:1. Every mid-tone blue, teal and purple a user might pick sat in that band. It now measures the WCAG contrast ratio of both candidates and returns the better one, which needs no threshold at all. Tests ----- `Tags.test.js` is new, and covers the chip nothing covered: every other spec takes `NameToTagLabel` from `tagsContextFixture`, which stubs it as a function returning the name. That is how a tag could be unreadable without a single test noticing. Two of these were verified red against the inline `color`. `cy.contrastRatio()` measures text against the surface *actually* behind it, walking up to the first ancestor that paints something -- a transparent tag has no background of its own, so a naive check reads rgba(0,0,0,0) and concludes anything is legible. Twelve cases, three tag colours across four themes, at WCAG AA. Plus a test asserting the old inline-`color` version still measures below 1.5:1, so the check is provably able to fail. The gallery had label swatches in theme colours and no user-coloured tag, which is why none of this was visible in it. It has eleven now, spanning the range where the black-or-light decision flips.
2026-07-30 19:46:10 -06:00
// It is written into `--label-text`; anything undefined leaves the tag unstyled.
for (const color of ['#000000', '#ffffff', '#2185d0']) {
expect([TAG_TEXT_DARK, TAG_TEXT_LIGHT]).toContain(contrastingColor(color));
ui: tags look like tags, and stay readable in every theme Four things, three cosmetic and one a real legibility bug. * Tags were square chips. They now have a pointed left edge and an eyelet, like a tag tied to a garment -- the shape Semantic gave them, and the reason a tag reads as a tag rather than as a small button. The point is a square rotated behind the left edge, sized so its diagonal equals the body height and its two edges meet the body at the corners. Only where something is actually a tag. `Label` carries the count badges in the Tags table too -- file, zim, channel and domain counts -- and a point on a number reads as an arrow, so the shape is behind a `tag` prop and `NameToTagLabel` opts in. * They were smaller than what they replaced: 11.5px text with 3px of padding, against Semantic's 12px and ~7px. Now 12.5px and 5px, which also keeps them a reasonable thing to tap. * Wrapped rows of tags sat on the row above, so they carry a bottom margin, plus a left margin for the point to occupy. * The text colour was unreadable in night and wrong in amber. Tags.js calculates black-or-light against the colour the user chose for each tag, and wrote the answer to `color` inline -- a declaration no stylesheet rule can outrank. Night turns a tag into an outline over a near-black page, so a near-white tag kept the black text calculated for its fill and was left legible only by its border: 1.1:1, measured. Amber replaces every fill with its own bright orange and was painting `#dddddd` on `#ff9500`, about 1.9:1, and a neutral grey in a theme whose whole point is that no other hue reaches the eye. The fix is to stop hard-coding `color` so the theme can decide: the calculated colour goes into `--label-text`, and night and amber override it alongside the fill they already override. There is no second contrast function to write, which is what this looked like it needed -- once a theme has discarded the user's colour there is nothing left to calculate against, and the answer is just the theme's own token. `.wrolpi-label-white` and `-black` moved to the same variable. Setting `color` there outranked the base rule, so amber was painting `--white` on `--text`: light orange on orange. `contrastingColor` was also picking the worse of its two options. It compared a brightness figure against a threshold, and the figure weighted the gamma-encoded bytes without linearising them, so Semantic's blue `#2185d0` got light text at 2.9:1 where dark text gives 5.3:1. Every mid-tone blue, teal and purple a user might pick sat in that band. It now measures the WCAG contrast ratio of both candidates and returns the better one, which needs no threshold at all. Tests ----- `Tags.test.js` is new, and covers the chip nothing covered: every other spec takes `NameToTagLabel` from `tagsContextFixture`, which stubs it as a function returning the name. That is how a tag could be unreadable without a single test noticing. Two of these were verified red against the inline `color`. `cy.contrastRatio()` measures text against the surface *actually* behind it, walking up to the first ancestor that paints something -- a transparent tag has no background of its own, so a naive check reads rgba(0,0,0,0) and concludes anything is legible. Twelve cases, three tag colours across four themes, at WCAG AA. Plus a test asserting the old inline-`color` version still measures below 1.5:1, so the check is provably able to fail. The gallery had label swatches in theme colours and no user-coloured tag, which is why none of this was visible in it. It has eleven now, spanning the range where the black-or-light decision flips.
2026-07-30 19:46:10 -06:00
}
});
it('measures contrast on the WCAG scale', () => {
// Anchors the ratio itself, so "better" above is comparing real numbers.
expect(contrastRatio('#000000', '#ffffff')).toBeCloseTo(21, 1);
expect(contrastRatio('#ffffff', '#ffffff')).toBeCloseTo(1, 5);
});
});
describe('contrastingColor with shorthand hex', () => {
/*
* Tag colors reach the UI from the database, and a tags config is a file a user can edit
* by hand -- configs are the source of truth in WROLPi -- so a perfectly valid `#fff` can
* arrive here. `hexToRGBArray` used to reject anything but six digits, which left the
* luminance at zero: a near-white tag was treated as black and given light text.
*/
it('reads a three-digit hex as the color it is', () => {
expect(contrastRatio('#000000', '#fff')).toBeCloseTo(21, 1);
expect(contrastRatio('#000000', '#ffffff')).toBeCloseTo(contrastRatio('#000000', '#fff'), 5);
});
it('picks dark text for a near-white shorthand fill', () => {
expect(contrastingColor('#fff')).toBe(TAG_TEXT_DARK);
});
it('picks light text for a near-black shorthand fill', () => {
expect(contrastingColor('#000')).toBe(TAG_TEXT_LIGHT);
});
it('agrees with the six-digit form of the same color', () => {
for (const [short, long] of [['#fff', '#ffffff'], ['#000', '#000000'],
['#f00', '#ff0000'], ['#1b2', '#11bb22']]) {
expect(contrastingColor(short)).toBe(contrastingColor(long));
}
});
});
ui: route genuine success and failure through the roles Sixteen call sites, chosen by reading each one rather than by grepping for a hue. Four sub-agents judged them independently against one rule: convert only what genuinely means success or failure; identification, category and decoration stay hue-named. Converted: SMART drive health (all four branches -- PASS/WARN/FAIL/unknown) config validity: valid / invalid / empty "Failed to load preview" the two "Download error" triggers expired inventory items, desktop and mobile the refresh phases: complete / active / not reached a refresh Progress bar in its error state cookie status: Unlocked / Locked a chip-mismatch label on the flasher CPU load thresholds (LoadStatistic) the outcome of an API call in useAPIButton the gallery's "Danger zone" heading and its load readings Left alone, deliberately: the file browser toolbar -- every action has its own colour to be told apart, and delete already uses role='danger' (Roland's ruling) diff add/remove in the config and inventory import previews. Adding is not a success and removing is not a failure; in Overwrite mode the removals are exactly what the user asked for, and painting them danger would report their intended action as broken seasons in DatesSelector, the uploader star and favourite heart, TagIcon, downloader category buttons, "Recommended to Keep" Three sites were wrong in a way worth naming. Both "Download error" triggers guard on the same condition and render the same modal, but one was `orange` and the other `red` -- and `orange` is the single worst choice, byte-identical to `--text` in night, so that error button was indistinguishable from an ordinary control. Config validity drew its valid state in `--violet`, which no user parses as "valid". And `LoadStatistic` -- the thing users actually watch on the Status page -- rendered its warning threshold in that same invisible orange. Also fixed a stale comment in Videos.js that claimed no token existed for these. Two notes on the tests: The first LoadStatistic test I wrote rendered the component and asserted the inline colour. jsdom REJECTS `color: var(--danger)` as an invalid declaration and drops it, so every colour reads back as the empty string -- two of the four assertions passed for that reason alone and would have kept passing with the hues restored. It is a source guard now, with the painted result asserted in the browser instead. That browser test then found something real: an uncoloured reading inherits `--text`, which in night is 4.98:1 against `--warning`'s 4.91:1, so a machine under load is marginally QUIETER than one that is fine. Not fixed here. The only way up on a dark panel is lightness, and night's danger would desaturate from #ff3e3e to about #ff5d5d -- trading the theme's "red only" character for loudness. That is a palette decision, so the test documents it and asserts what does hold rather than quietly making it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-01 18:08:34 -06:00
test: act on review — the browser test named LoadStatistic but never mounted it The suite was titled "a load reading ranks itself", commented as covering the thresholds, and cross-referenced from Common.test.js as the place the painted result is checked. It mounted a hand-built `<Statistic color='warning'/>`. So it proved that role tokens resolve distinctly -- which the roles suite two blocks up already covers -- and would have stayed green with LoadStatistic reverted to `orange`/`red`. The same shape as the column-border test in #609: a claim that outran its measurement. It mounts the real helper at the real thresholds now, and the reversion fails it. The source guards are gone. They scraped function bodies out of files with indexOf slices, covered two helpers, and never executed a branch. `loadRole` and `healthRole` are pure exported functions instead, tested as mappings: either side of both load thresholds, every SMART assessment, lower case (the value comes from smartctl), and the case that matters most -- an unreadable drive must report as unknown, never as healthy. That is also the better answer to the jsdom problem that produced the guards. jsdom rejects `color: var(--danger)` and drops it, so the mapping cannot be read back off a rendered component; extracting it is the honest fix rather than asserting on source text. And the gallery was itself half-migrated: 15 Min. Load became `danger` while 5 Min. Load stayed `orange` -- in the one sample whose caption tells the reader to go and check night and amber, where orange is byte-identical to --text. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-01 18:17:18 -06:00
describe('loadRole', () => {
ui: route genuine success and failure through the roles Sixteen call sites, chosen by reading each one rather than by grepping for a hue. Four sub-agents judged them independently against one rule: convert only what genuinely means success or failure; identification, category and decoration stay hue-named. Converted: SMART drive health (all four branches -- PASS/WARN/FAIL/unknown) config validity: valid / invalid / empty "Failed to load preview" the two "Download error" triggers expired inventory items, desktop and mobile the refresh phases: complete / active / not reached a refresh Progress bar in its error state cookie status: Unlocked / Locked a chip-mismatch label on the flasher CPU load thresholds (LoadStatistic) the outcome of an API call in useAPIButton the gallery's "Danger zone" heading and its load readings Left alone, deliberately: the file browser toolbar -- every action has its own colour to be told apart, and delete already uses role='danger' (Roland's ruling) diff add/remove in the config and inventory import previews. Adding is not a success and removing is not a failure; in Overwrite mode the removals are exactly what the user asked for, and painting them danger would report their intended action as broken seasons in DatesSelector, the uploader star and favourite heart, TagIcon, downloader category buttons, "Recommended to Keep" Three sites were wrong in a way worth naming. Both "Download error" triggers guard on the same condition and render the same modal, but one was `orange` and the other `red` -- and `orange` is the single worst choice, byte-identical to `--text` in night, so that error button was indistinguishable from an ordinary control. Config validity drew its valid state in `--violet`, which no user parses as "valid". And `LoadStatistic` -- the thing users actually watch on the Status page -- rendered its warning threshold in that same invisible orange. Also fixed a stale comment in Videos.js that claimed no token existed for these. Two notes on the tests: The first LoadStatistic test I wrote rendered the component and asserted the inline colour. jsdom REJECTS `color: var(--danger)` as an invalid declaration and drops it, so every colour reads back as the empty string -- two of the four assertions passed for that reason alone and would have kept passing with the hues restored. It is a source guard now, with the painted result asserted in the browser instead. That browser test then found something real: an uncoloured reading inherits `--text`, which in night is 4.98:1 against `--warning`'s 4.91:1, so a machine under load is marginally QUIETER than one that is fine. Not fixed here. The only way up on a dark panel is lightness, and night's danger would desaturate from #ff3e3e to about #ff5d5d -- trading the theme's "red only" character for loudness. That is a palette decision, so the test documents it and asserts what does hold rather than quietly making it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-01 18:08:34 -06:00
/*
test: act on review — the browser test named LoadStatistic but never mounted it The suite was titled "a load reading ranks itself", commented as covering the thresholds, and cross-referenced from Common.test.js as the place the painted result is checked. It mounted a hand-built `<Statistic color='warning'/>`. So it proved that role tokens resolve distinctly -- which the roles suite two blocks up already covers -- and would have stayed green with LoadStatistic reverted to `orange`/`red`. The same shape as the column-border test in #609: a claim that outran its measurement. It mounts the real helper at the real thresholds now, and the reversion fails it. The source guards are gone. They scraped function bodies out of files with indexOf slices, covered two helpers, and never executed a branch. `loadRole` and `healthRole` are pure exported functions instead, tested as mappings: either side of both load thresholds, every SMART assessment, lower case (the value comes from smartctl), and the case that matters most -- an unreadable drive must report as unknown, never as healthy. That is also the better answer to the jsdom problem that produced the guards. jsdom rejects `color: var(--danger)` and drops it, so the mapping cannot be read back off a rendered component; extracting it is the honest fix rather than asserting on source text. And the gallery was itself half-migrated: 15 Min. Load became `danger` while 5 Min. Load stayed `orange` -- in the one sample whose caption tells the reader to go and check night and amber, where orange is byte-identical to --text. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-01 18:17:18 -06:00
* The branches, tested as branches. This used to be a source guard scraping the
* function body out of Common.js, because the obvious render test cannot work: jsdom
* REJECTS `color: var(--danger)` as an invalid declaration and drops it, so every inline
* color reads back as the empty string and the assertions passed for that reason alone.
test: act on review — the browser test named LoadStatistic but never mounted it The suite was titled "a load reading ranks itself", commented as covering the thresholds, and cross-referenced from Common.test.js as the place the painted result is checked. It mounted a hand-built `<Statistic color='warning'/>`. So it proved that role tokens resolve distinctly -- which the roles suite two blocks up already covers -- and would have stayed green with LoadStatistic reverted to `orange`/`red`. The same shape as the column-border test in #609: a claim that outran its measurement. It mounts the real helper at the real thresholds now, and the reversion fails it. The source guards are gone. They scraped function bodies out of files with indexOf slices, covered two helpers, and never executed a branch. `loadRole` and `healthRole` are pure exported functions instead, tested as mappings: either side of both load thresholds, every SMART assessment, lower case (the value comes from smartctl), and the case that matters most -- an unreadable drive must report as unknown, never as healthy. That is also the better answer to the jsdom problem that produced the guards. jsdom rejects `color: var(--danger)` and drops it, so the mapping cannot be read back off a rendered component; extracting it is the honest fix rather than asserting on source text. And the gallery was itself half-migrated: 15 Min. Load became `danger` while 5 Min. Load stayed `orange` -- in the one sample whose caption tells the reader to go and check night and amber, where orange is byte-identical to --text. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-01 18:17:18 -06:00
* Pulling the mapping out as a pure function is the honest fix.
ui: route genuine success and failure through the roles Sixteen call sites, chosen by reading each one rather than by grepping for a hue. Four sub-agents judged them independently against one rule: convert only what genuinely means success or failure; identification, category and decoration stay hue-named. Converted: SMART drive health (all four branches -- PASS/WARN/FAIL/unknown) config validity: valid / invalid / empty "Failed to load preview" the two "Download error" triggers expired inventory items, desktop and mobile the refresh phases: complete / active / not reached a refresh Progress bar in its error state cookie status: Unlocked / Locked a chip-mismatch label on the flasher CPU load thresholds (LoadStatistic) the outcome of an API call in useAPIButton the gallery's "Danger zone" heading and its load readings Left alone, deliberately: the file browser toolbar -- every action has its own colour to be told apart, and delete already uses role='danger' (Roland's ruling) diff add/remove in the config and inventory import previews. Adding is not a success and removing is not a failure; in Overwrite mode the removals are exactly what the user asked for, and painting them danger would report their intended action as broken seasons in DatesSelector, the uploader star and favourite heart, TagIcon, downloader category buttons, "Recommended to Keep" Three sites were wrong in a way worth naming. Both "Download error" triggers guard on the same condition and render the same modal, but one was `orange` and the other `red` -- and `orange` is the single worst choice, byte-identical to `--text` in night, so that error button was indistinguishable from an ordinary control. Config validity drew its valid state in `--violet`, which no user parses as "valid". And `LoadStatistic` -- the thing users actually watch on the Status page -- rendered its warning threshold in that same invisible orange. Also fixed a stale comment in Videos.js that claimed no token existed for these. Two notes on the tests: The first LoadStatistic test I wrote rendered the component and asserted the inline colour. jsdom REJECTS `color: var(--danger)` as an invalid declaration and drops it, so every colour reads back as the empty string -- two of the four assertions passed for that reason alone and would have kept passing with the hues restored. It is a source guard now, with the painted result asserted in the browser instead. That browser test then found something real: an uncoloured reading inherits `--text`, which in night is 4.98:1 against `--warning`'s 4.91:1, so a machine under load is marginally QUIETER than one that is fine. Not fixed here. The only way up on a dark panel is lightness, and night's danger would desaturate from #ff3e3e to about #ff5d5d -- trading the theme's "red only" character for loudness. That is a palette decision, so the test documents it and asserts what does hold rather than quietly making it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-01 18:08:34 -06:00
*
test: act on review — the browser test named LoadStatistic but never mounted it The suite was titled "a load reading ranks itself", commented as covering the thresholds, and cross-referenced from Common.test.js as the place the painted result is checked. It mounted a hand-built `<Statistic color='warning'/>`. So it proved that role tokens resolve distinctly -- which the roles suite two blocks up already covers -- and would have stayed green with LoadStatistic reverted to `orange`/`red`. The same shape as the column-border test in #609: a claim that outran its measurement. It mounts the real helper at the real thresholds now, and the reversion fails it. The source guards are gone. They scraped function bodies out of files with indexOf slices, covered two helpers, and never executed a branch. `loadRole` and `healthRole` are pure exported functions instead, tested as mappings: either side of both load thresholds, every SMART assessment, lower case (the value comes from smartctl), and the case that matters most -- an unreadable drive must report as unknown, never as healthy. That is also the better answer to the jsdom problem that produced the guards. jsdom rejects `color: var(--danger)` and drops it, so the mapping cannot be read back off a rendered component; extracting it is the honest fix rather than asserting on source text. And the gallery was itself half-migrated: 15 Min. Load became `danger` while 5 Min. Load stayed `orange` -- in the one sample whose caption tells the reader to go and check night and amber, where orange is byte-identical to --text. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-01 18:17:18 -06:00
* Thresholds are halves and three quarters of the core count, so each is asserted either
* side of its boundary rather than at one comfortable value.
ui: route genuine success and failure through the roles Sixteen call sites, chosen by reading each one rather than by grepping for a hue. Four sub-agents judged them independently against one rule: convert only what genuinely means success or failure; identification, category and decoration stay hue-named. Converted: SMART drive health (all four branches -- PASS/WARN/FAIL/unknown) config validity: valid / invalid / empty "Failed to load preview" the two "Download error" triggers expired inventory items, desktop and mobile the refresh phases: complete / active / not reached a refresh Progress bar in its error state cookie status: Unlocked / Locked a chip-mismatch label on the flasher CPU load thresholds (LoadStatistic) the outcome of an API call in useAPIButton the gallery's "Danger zone" heading and its load readings Left alone, deliberately: the file browser toolbar -- every action has its own colour to be told apart, and delete already uses role='danger' (Roland's ruling) diff add/remove in the config and inventory import previews. Adding is not a success and removing is not a failure; in Overwrite mode the removals are exactly what the user asked for, and painting them danger would report their intended action as broken seasons in DatesSelector, the uploader star and favourite heart, TagIcon, downloader category buttons, "Recommended to Keep" Three sites were wrong in a way worth naming. Both "Download error" triggers guard on the same condition and render the same modal, but one was `orange` and the other `red` -- and `orange` is the single worst choice, byte-identical to `--text` in night, so that error button was indistinguishable from an ordinary control. Config validity drew its valid state in `--violet`, which no user parses as "valid". And `LoadStatistic` -- the thing users actually watch on the Status page -- rendered its warning threshold in that same invisible orange. Also fixed a stale comment in Videos.js that claimed no token existed for these. Two notes on the tests: The first LoadStatistic test I wrote rendered the component and asserted the inline colour. jsdom REJECTS `color: var(--danger)` as an invalid declaration and drops it, so every colour reads back as the empty string -- two of the four assertions passed for that reason alone and would have kept passing with the hues restored. It is a source guard now, with the painted result asserted in the browser instead. That browser test then found something real: an uncoloured reading inherits `--text`, which in night is 4.98:1 against `--warning`'s 4.91:1, so a machine under load is marginally QUIETER than one that is fine. Not fixed here. The only way up on a dark panel is lightness, and night's danger would desaturate from #ff3e3e to about #ff5d5d -- trading the theme's "red only" character for loudness. That is a palette decision, so the test documents it and asserts what does hold rather than quietly making it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-01 18:08:34 -06:00
*/
test: act on review — the browser test named LoadStatistic but never mounted it The suite was titled "a load reading ranks itself", commented as covering the thresholds, and cross-referenced from Common.test.js as the place the painted result is checked. It mounted a hand-built `<Statistic color='warning'/>`. So it proved that role tokens resolve distinctly -- which the roles suite two blocks up already covers -- and would have stayed green with LoadStatistic reverted to `orange`/`red`. The same shape as the column-border test in #609: a claim that outran its measurement. It mounts the real helper at the real thresholds now, and the reversion fails it. The source guards are gone. They scraped function bodies out of files with indexOf slices, covered two helpers, and never executed a branch. `loadRole` and `healthRole` are pure exported functions instead, tested as mappings: either side of both load thresholds, every SMART assessment, lower case (the value comes from smartctl), and the case that matters most -- an unreadable drive must report as unknown, never as healthy. That is also the better answer to the jsdom problem that produced the guards. jsdom rejects `color: var(--danger)` and drops it, so the mapping cannot be read back off a rendered component; extracting it is the honest fix rather than asserting on source text. And the gallery was itself half-migrated: 15 Min. Load became `danger` while 5 Min. Load stayed `orange` -- in the one sample whose caption tells the reader to go and check night and amber, where orange is byte-identical to --text. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-01 18:17:18 -06:00
it('says nothing about a load below half the cores', () => {
expect(loadRole(1.9, 4)).toBeUndefined();
expect(loadRole(0, 4)).toBeUndefined();
});
it('warns from half the cores', () => {
expect(loadRole(2.0, 4)).toBe('warning');
expect(loadRole(2.9, 4)).toBe('warning');
});
it('calls it danger from three quarters', () => {
expect(loadRole(3.0, 4)).toBe('danger');
expect(loadRole(99, 4)).toBe('danger');
});
it('says nothing when the core count is unknown', () => {
// Both thresholds are guarded on `cores`. Without it every load would read as fine,
// which is the safe direction, but worth pinning so it stays deliberate.
expect(loadRole(12, undefined)).toBeUndefined();
expect(loadRole(12, 0)).toBeUndefined();
});
it('never returns a hue', () => {
// The regression this exists for: `orange` is byte-identical to `--text` in night,
// so a warning load rendered as an ordinary uncolored number.
test: act on review — the browser test named LoadStatistic but never mounted it The suite was titled "a load reading ranks itself", commented as covering the thresholds, and cross-referenced from Common.test.js as the place the painted result is checked. It mounted a hand-built `<Statistic color='warning'/>`. So it proved that role tokens resolve distinctly -- which the roles suite two blocks up already covers -- and would have stayed green with LoadStatistic reverted to `orange`/`red`. The same shape as the column-border test in #609: a claim that outran its measurement. It mounts the real helper at the real thresholds now, and the reversion fails it. The source guards are gone. They scraped function bodies out of files with indexOf slices, covered two helpers, and never executed a branch. `loadRole` and `healthRole` are pure exported functions instead, tested as mappings: either side of both load thresholds, every SMART assessment, lower case (the value comes from smartctl), and the case that matters most -- an unreadable drive must report as unknown, never as healthy. That is also the better answer to the jsdom problem that produced the guards. jsdom rejects `color: var(--danger)` and drops it, so the mapping cannot be read back off a rendered component; extracting it is the honest fix rather than asserting on source text. And the gallery was itself half-migrated: 15 Min. Load became `danger` while 5 Min. Load stayed `orange` -- in the one sample whose caption tells the reader to go and check night and amber, where orange is byte-identical to --text. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-01 18:17:18 -06:00
[loadRole(1, 4), loadRole(2.5, 4), loadRole(4, 4)].filter(Boolean).forEach(role =>
expect(role).not.toMatch(/^(red|green|amber|yellow|orange|blue)$/));
ui: route genuine success and failure through the roles Sixteen call sites, chosen by reading each one rather than by grepping for a hue. Four sub-agents judged them independently against one rule: convert only what genuinely means success or failure; identification, category and decoration stay hue-named. Converted: SMART drive health (all four branches -- PASS/WARN/FAIL/unknown) config validity: valid / invalid / empty "Failed to load preview" the two "Download error" triggers expired inventory items, desktop and mobile the refresh phases: complete / active / not reached a refresh Progress bar in its error state cookie status: Unlocked / Locked a chip-mismatch label on the flasher CPU load thresholds (LoadStatistic) the outcome of an API call in useAPIButton the gallery's "Danger zone" heading and its load readings Left alone, deliberately: the file browser toolbar -- every action has its own colour to be told apart, and delete already uses role='danger' (Roland's ruling) diff add/remove in the config and inventory import previews. Adding is not a success and removing is not a failure; in Overwrite mode the removals are exactly what the user asked for, and painting them danger would report their intended action as broken seasons in DatesSelector, the uploader star and favourite heart, TagIcon, downloader category buttons, "Recommended to Keep" Three sites were wrong in a way worth naming. Both "Download error" triggers guard on the same condition and render the same modal, but one was `orange` and the other `red` -- and `orange` is the single worst choice, byte-identical to `--text` in night, so that error button was indistinguishable from an ordinary control. Config validity drew its valid state in `--violet`, which no user parses as "valid". And `LoadStatistic` -- the thing users actually watch on the Status page -- rendered its warning threshold in that same invisible orange. Also fixed a stale comment in Videos.js that claimed no token existed for these. Two notes on the tests: The first LoadStatistic test I wrote rendered the component and asserted the inline colour. jsdom REJECTS `color: var(--danger)` as an invalid declaration and drops it, so every colour reads back as the empty string -- two of the four assertions passed for that reason alone and would have kept passing with the hues restored. It is a source guard now, with the painted result asserted in the browser instead. That browser test then found something real: an uncoloured reading inherits `--text`, which in night is 4.98:1 against `--warning`'s 4.91:1, so a machine under load is marginally QUIETER than one that is fine. Not fixed here. The only way up on a dark panel is lightness, and night's danger would desaturate from #ff3e3e to about #ff5d5d -- trading the theme's "red only" character for loudness. That is a palette decision, so the test documents it and asserts what does hold rather than quietly making it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-01 18:08:34 -06:00
});
});
test: act on review — the browser test named LoadStatistic but never mounted it The suite was titled "a load reading ranks itself", commented as covering the thresholds, and cross-referenced from Common.test.js as the place the painted result is checked. It mounted a hand-built `<Statistic color='warning'/>`. So it proved that role tokens resolve distinctly -- which the roles suite two blocks up already covers -- and would have stayed green with LoadStatistic reverted to `orange`/`red`. The same shape as the column-border test in #609: a claim that outran its measurement. It mounts the real helper at the real thresholds now, and the reversion fails it. The source guards are gone. They scraped function bodies out of files with indexOf slices, covered two helpers, and never executed a branch. `loadRole` and `healthRole` are pure exported functions instead, tested as mappings: either side of both load thresholds, every SMART assessment, lower case (the value comes from smartctl), and the case that matters most -- an unreadable drive must report as unknown, never as healthy. That is also the better answer to the jsdom problem that produced the guards. jsdom rejects `color: var(--danger)` and drops it, so the mapping cannot be read back off a rendered component; extracting it is the honest fix rather than asserting on source text. And the gallery was itself half-migrated: 15 Min. Load became `danger` while 5 Min. Load stayed `orange` -- in the one sample whose caption tells the reader to go and check night and amber, where orange is byte-identical to --text. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-01 18:17:18 -06:00
describe('healthRole', () => {
it('maps every SMART assessment to a severity', () => {
expect(healthRole('PASS')).toBe('success');
expect(healthRole('WARN')).toBe('warning');
expect(healthRole('FAIL')).toBe('danger');
});
it('is not case sensitive, because the value comes from smartctl', () => {
expect(healthRole('pass')).toBe('success');
expect(healthRole('Fail')).toBe('danger');
});
it('treats a missing or unrecognised assessment as unknown, not as healthy', () => {
// Reporting an unreadable drive as PASS would be the dangerous direction.
[undefined, null, '', 'SOMETHING_ELSE'].forEach(value =>
expect(healthRole(value)).toBe('neutral'));
ui: route genuine success and failure through the roles Sixteen call sites, chosen by reading each one rather than by grepping for a hue. Four sub-agents judged them independently against one rule: convert only what genuinely means success or failure; identification, category and decoration stay hue-named. Converted: SMART drive health (all four branches -- PASS/WARN/FAIL/unknown) config validity: valid / invalid / empty "Failed to load preview" the two "Download error" triggers expired inventory items, desktop and mobile the refresh phases: complete / active / not reached a refresh Progress bar in its error state cookie status: Unlocked / Locked a chip-mismatch label on the flasher CPU load thresholds (LoadStatistic) the outcome of an API call in useAPIButton the gallery's "Danger zone" heading and its load readings Left alone, deliberately: the file browser toolbar -- every action has its own colour to be told apart, and delete already uses role='danger' (Roland's ruling) diff add/remove in the config and inventory import previews. Adding is not a success and removing is not a failure; in Overwrite mode the removals are exactly what the user asked for, and painting them danger would report their intended action as broken seasons in DatesSelector, the uploader star and favourite heart, TagIcon, downloader category buttons, "Recommended to Keep" Three sites were wrong in a way worth naming. Both "Download error" triggers guard on the same condition and render the same modal, but one was `orange` and the other `red` -- and `orange` is the single worst choice, byte-identical to `--text` in night, so that error button was indistinguishable from an ordinary control. Config validity drew its valid state in `--violet`, which no user parses as "valid". And `LoadStatistic` -- the thing users actually watch on the Status page -- rendered its warning threshold in that same invisible orange. Also fixed a stale comment in Videos.js that claimed no token existed for these. Two notes on the tests: The first LoadStatistic test I wrote rendered the component and asserted the inline colour. jsdom REJECTS `color: var(--danger)` as an invalid declaration and drops it, so every colour reads back as the empty string -- two of the four assertions passed for that reason alone and would have kept passing with the hues restored. It is a source guard now, with the painted result asserted in the browser instead. That browser test then found something real: an uncoloured reading inherits `--text`, which in night is 4.98:1 against `--warning`'s 4.91:1, so a machine under load is marginally QUIETER than one that is fine. Not fixed here. The only way up on a dark panel is lightness, and night's danger would desaturate from #ff3e3e to about #ff5d5d -- trading the theme's "red only" character for loudness. That is a palette decision, so the test documents it and asserts what does hold rather than quietly making it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-01 18:08:34 -06:00
});
});
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-01 18:41:58 -06:00
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-01 19:00:32 -06:00
describe('a heading keeps its help icon on the same row as the text', () => {
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-01 18:41:58 -06:00
/*
* `HelpHeader` and `InfoHeader` put their icon in a sibling `<span>` beside a `<label>`,
* held on one line by `.inline-header h1..h5 {display: inline-block}` in App.css. That
* worked while the heading was a bare `<h2>`; the moment it gained a block wrapper the
* label's block child forced a break, and every help icon in the app dropped below its
* own heading -- visible on "Root CA Certificate" in Settings.
*
* They pass the icon through the heading's `after` slot now. Containment is the claim
* jsdom can judge; that the two actually share a line is asserted in ui-layout.cy.js,
* since jsdom has no layout at all.
*/
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-01 19:00:32 -06:00
it("renders the help control in the heading's row", () => {
const {container} = render(
<HelpHeader headerSize='h3' headerContent='Root CA Certificate'
helpPath='/system/certificates/'/>);
expect(container.querySelector('.wrolpi-header-row'))
.toContainElement(screen.getByRole('button', {name: 'Help'}));
// And not inside the heading itself, which would put it in the accessible name.
expect(screen.getByRole('heading')).toHaveAccessibleName('Root CA Certificate');
});
it("renders the info popup in the heading's row", () => {
const {container} = render(
<InfoHeader headerSize='h4' headerContent='Video Resolutions'
popupContent='Highest available is tried first.'/>);
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-01 18:41:58 -06:00
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-01 19:00:32 -06:00
expect(container.querySelector('.wrolpi-header-after svg')).not.toBeNull();
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-01 18:41:58 -06:00
});
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-01 19:00:32 -06:00
it('renders no label at all when there is no field to name', () => {
/*
* Several call sites pass an InfoHeader as the `label` prop of InputForm/ToggleForm,
* which wraps it in a `<label>` of its own. An unconditional label here nested one
* inside another -- invalid HTML, and it makes clicking the text ambiguous.
*/
const {container} = render(
<InfoHeader headerSize='h5' headerContent='Video File Format' popupContent='...'/>);
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-01 18:41:58 -06:00
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-01 19:00:32 -06:00
expect(container.querySelector('label')).toBeNull();
expect(screen.getByRole('heading', {name: /Video File Format/})).toBeInTheDocument();
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-01 18:41:58 -06:00
});
it('keeps the label association, which is what `for_` is for', () => {
// Three call sites use it to tie the heading text to the field below it.
render(<InfoHeader headerSize='h4' headerContent='Video Resolutions'
for_='video_resolutions_input' popupContent='...'/>);
expect(screen.getByText(/Video Resolutions/).closest('label'))
.toHaveAttribute('for', 'video_resolutions_input');
});
it('leaves no .inline-header behind', () => {
// The class and its stylesheet rules are gone; a stray one would be dead markup
// relying on CSS that no longer exists.
const {container} = render(<HelpHeader headerContent='Root CA Certificate' helpPath='/x/'/>);
expect(container.querySelector('.inline-header')).toBeNull();
});
});
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-02 11:35:49 -06:00
describe('card links', () => {
/*
* `.card-link` is what makes a card's title read as the card's text rather than as a
* link, so a title that misses it is the one thing on the card in the wrong color.
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-02 11:35:49 -06:00
* Both helpers set the class and then spread the caller's props over it, so any call
* site passing a className of its own -- which is every archive title, via
* `card-title-ellipsis` -- silently replaced the class instead of adding to it.
*/
it.each([
['CardLink', <CardLink to='/archives/1' className='card-title-ellipsis'>Title</CardLink>],
['ExternalCardLink',
<ExternalCardLink to='http://x/a.html' className='card-title-ellipsis'>Title</ExternalCardLink>],
])('%s keeps its own classes when the call site adds one', (_name, element) => {
render(element);
const link = screen.getByText('Title');
expect(link).toHaveClass('card-link');
expect(link).toHaveClass('no-link-underscore');
expect(link).toHaveClass('card-title-ellipsis');
});
});
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-05 15:40:27 -06:00
describe('PageContainer', () => {
/*
* The wrapper that decides how far apart a page's blocks sit. The spacing itself is a
* stylesheet rule and is measured in ui-layout.cy.js, where there is a layout engine; what
* this asserts is that the rule is REACHED -- the class has to be on both of the responsive
* branches, or the spacing silently applies on one viewport and not the other.
*/
it('marks both of its viewport branches as a stack', () => {
const {container} = render(<PageContainer><div>Body</div></PageContainer>);
const stacks = container.querySelectorAll('.wrolpi-stack');
// Mobile and tablet-and-up, both rendered; `Media` hides one with CSS.
expect(stacks).toHaveLength(2);
stacks.forEach(stack => expect(stack.textContent).toBe('Body'));
});
ui: stop /archives/settings wrapping itself in a second PageContainer `ArchiveRoute` wraps its `<Routes>` in a PageContainer, and `ArchiveSettingsPage` wrapped itself in another. PageContainer carries `margin-top: 1em` and `padding: 1em`, so that page sat 1em lower and 1em further in on all four sides than /archives/domains beside it -- which is exactly what "seems to have more margin" was. It is a fragment now, so its panels are blocks of the route's own stack. Guarded by a source scan rather than a render, since a doubled margin throws nothing: it takes each PageContainer that wraps a router, reads the components it routes to, and fails if one of them renders a PageContainer of its own. The first version of that scan passed against the live bug. It looked at only the first `<PageContainer>` in a file, and in Archive.js that is the offender itself, whose block holds no `<Routes>` -- so the file was skipped before the route wrapper 400 lines below was reached. It reads every occurrence now, and the premise assertions count both the wrappers found and the routes within them. Two more top-level blocks gave up margins that were being added to the page's gap, both headers "matching" each other's pattern: the domain filter row (35.2px against 17.6px) and the channel filter row. /archives/settings and /archives/domains now measure identically -- tab bar bottom at 136.8px, first block 17.6px below it, content inset 17.6px -- as do the statistics and settings pages of every other tab group. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-05 15:53:04 -06:00
it('is never rendered by a page that a PageContainer already wraps', () => {
/*
* `PageContainer` carries `margin-top: 1em` and `padding: 1em`, so nesting one inside
* another applies both twice: the page is pushed 1em further down and inset 1em further
* in on all four sides than every other page in the app. `/archives/settings` did this
* -- `ArchiveRoute` wraps its `<Routes>` in one, and `ArchiveSettingsPage` wrapped itself
* in another -- and read as "more margin than /archives/domains", which is exactly what
* it was.
*
* Nothing about a doubled margin throws, so this is a source scan. It reads each route
* wrapper (a `PageContainer` holding `<Routes>` or `<Outlet/>`), takes the components it
* routes to, and checks the ones defined in the same file -- which is where a route and
* its pages normally live together.
*/
const src = path.join(__dirname, '..');
const files = (dir) => fs.readdirSync(dir, {withFileTypes: true}).flatMap(entry => {
const full = path.join(dir, entry.name);
if (entry.isDirectory()) return files(full);
if (!/\.jsx?$/.test(entry.name)) return [];
if (/\.(test|cy)\.jsx?$/.test(entry.name)) return [];
return [full];
});
const offenders = [];
for (const file of files(src)) {
const text = fs.readFileSync(file, 'utf8');
/*
* EVERY `<PageContainer>` in the file, not just the first. Checking only the first
* is what let this bug through a version of this test: in Archive.js the first one
* is `ArchiveSettingsPage`'s -- the offender -- and its block holds no `<Routes>`, so
* the file was skipped before the route wrapper 400 lines below was ever looked at.
*/
for (const block of routeWrapperBlocks(text)) {
const routed = [...block.matchAll(/element=\{<(\w+)\s*\/>\}/g)].map(m => m[1]);
for (const name of routed) {
// The component's body, bounded by the next top-level declaration.
const declaration = new RegExp(`^(?:export\\s+)?(?:function|const)\\s+${name}\\b`, 'm');
const found = declaration.exec(text);
if (!found) continue; // Defined elsewhere; out of this scan's reach.
const after = text.slice(found.index + 1);
const next = after.search(/^(?:export\s+)?(?:function|const|class)\s/m);
const body = next === -1 ? after : after.slice(0, next);
if (body.includes('<PageContainer>')) {
offenders.push(`${path.relative(src, file)}: ${name}`);
}
}
}
}
/*
* The premise: a scan that found no route wrappers, or routed to no components, would
* report no offenders and read exactly like a clean codebase.
*/
const wrappers = files(src).flatMap(file => routeWrapperBlocks(fs.readFileSync(file, 'utf8')));
expect(wrappers.length).toBeGreaterThan(2);
expect(wrappers.flatMap(b => [...b.matchAll(/element=\{<(\w+)\s*\/>\}/g)]).length)
.toBeGreaterThan(8);
expect(offenders).toEqual([]);
});
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-05 15:40:27 -06:00
});
ui: stop /archives/settings wrapping itself in a second PageContainer `ArchiveRoute` wraps its `<Routes>` in a PageContainer, and `ArchiveSettingsPage` wrapped itself in another. PageContainer carries `margin-top: 1em` and `padding: 1em`, so that page sat 1em lower and 1em further in on all four sides than /archives/domains beside it -- which is exactly what "seems to have more margin" was. It is a fragment now, so its panels are blocks of the route's own stack. Guarded by a source scan rather than a render, since a doubled margin throws nothing: it takes each PageContainer that wraps a router, reads the components it routes to, and fails if one of them renders a PageContainer of its own. The first version of that scan passed against the live bug. It looked at only the first `<PageContainer>` in a file, and in Archive.js that is the offender itself, whose block holds no `<Routes>` -- so the file was skipped before the route wrapper 400 lines below was reached. It reads every occurrence now, and the premise assertions count both the wrappers found and the routes within them. Two more top-level blocks gave up margins that were being added to the page's gap, both headers "matching" each other's pattern: the domain filter row (35.2px against 17.6px) and the channel filter row. /archives/settings and /archives/domains now measure identically -- tab bar bottom at 136.8px, first block 17.6px below it, content inset 17.6px -- as do the statistics and settings pages of every other tab group. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-05 15:53:04 -06:00
/**
* Every `<PageContainer>...</PageContainer>` in `text` that wraps a router -- so, the ones that
* wrap a whole section of the app rather than one page. All of them, because a file may hold both
* a route wrapper and the pages it routes to, in either order.
*/
const routeWrapperBlocks = (text) => {
const blocks = [];
let from = 0;
for (;;) {
const start = text.indexOf('<PageContainer>', from);
if (start === -1) return blocks;
const end = text.indexOf('</PageContainer>', start);
if (end === -1) return blocks;
const block = text.slice(start, end);
if (/<Routes>|<Outlet\s*\/>/.test(block)) blocks.push(block);
from = end + 1;
}
};