diff --git a/app/src/components/ThemeSamplePage.js b/app/src/components/ThemeSamplePage.js index 0777ae96..4a60dd46 100644 --- a/app/src/components/ThemeSamplePage.js +++ b/app/src/components/ThemeSamplePage.js @@ -410,6 +410,9 @@ export function ThemeSamplePage() { +
+ +

Semantic's compound shape is kept — Modal.Header, Modal.Content, Modal.Actions — so the 34 call sites written against it migrate by import. diff --git a/app/src/components/ui/Button.tsx b/app/src/components/ui/Button.tsx index fa46588b..de512c4a 100644 --- a/app/src/components/ui/Button.tsx +++ b/app/src/components/ui/Button.tsx @@ -32,6 +32,7 @@ export interface ButtonProps extends Omit) => { @@ -69,6 +70,13 @@ export const Button = forwardRef(( const fromRole = role ? roleProps[role] : undefined; return { label: string; onClick?: React.MouseEventHandler; type?: 'button' | 'submit' | 'reset'; + href?: string; + target?: string; + component?: any; } /** A square, icon-only button. */ @@ -97,6 +108,8 @@ export const IconButton = forwardRef(( const fromRole = role ? roleProps[role] : undefined; return

{(showPercent || label) && {label ?? `${Math.round(clamped)}%`} diff --git a/app/src/components/ui/ui.css b/app/src/components/ui/ui.css index 2eb5cca7..fbd75c1c 100644 --- a/app/src/components/ui/ui.css +++ b/app/src/components/ui/ui.css @@ -499,6 +499,22 @@ html[data-theme="night"] .wrolpi-label { margin-top: 3px; } +/* + * Night and amber are single-hue by definition, and a call site may legitimately want + * many distinguishable colours — the Flasher's chip-family badges set `--label-color` + * inline from a twelve-colour palette that no four tokens can express. + * + * In those themes the palette is overridden back to the theme's own text colour, so a + * label cannot put a green or blue pixel on a night-adapted screen. `!important` is + * required because the palette arrives as an inline custom property, which a normal + * rule cannot outrank. The labels stop being distinguishable by colour, which is the + * correct trade: the theme's whole purpose is that no other hue reaches the eye. + */ +html[data-theme="night"] .wrolpi-label, +html[data-theme="amber"] .wrolpi-label { + --label-color: var(--text) !important; +} + /* --------------------------------------------------------------- Messages */ .wrolpi-message { @@ -581,6 +597,34 @@ html[data-theme="night"] .wrolpi-label { } } +/* + * Indeterminate: a sliding band, for work whose size is not known yet. Semantic called + * this `indicating`; without it an upload that has not reported its size sits at 0% and + * reads as stalled. + */ +.wrolpi-progress-indeterminate .wrolpi-progress-fill { + width: 35%; + animation: wrolpi-progress-slide 1.4s ease-in-out infinite; +} + +@keyframes wrolpi-progress-slide { + from { + margin-left: -35%; + } + to { + margin-left: 100%; + } +} + +/* Motion is the only signal here, so when it is suppressed the bar fills instead. */ +@media (prefers-reduced-motion: reduce) { + .wrolpi-progress-indeterminate .wrolpi-progress-fill { + animation: none; + width: 100%; + opacity: 0.5; + } +} + .wrolpi-progress-text { position: absolute; inset: 0; diff --git a/app/src/components/ui/ui.test.js b/app/src/components/ui/ui.test.js index 7b9fb6c0..03fc0467 100644 --- a/app/src/components/ui/ui.test.js +++ b/app/src/components/ui/ui.test.js @@ -152,6 +152,30 @@ describe('Button', () => { expect(screen.getByRole('button', {name: 'New'})).toHaveAttribute('data-size', 'sm'); }); + it('renders an anchor when given an href, without being told twice', () => { + /* + * Mantine drops `href` unless it also gets `component='a'`, so a button carrying + * only an href looked like a link and navigated nowhere. Semantic spelled this + * `as='a'`, so every migrated call site that kept just the href was silently + * broken — a download button that downloads nothing. + */ + renderUI(<> + + + ); + + const link = screen.getByRole('link', {name: 'Download'}); + expect(link.tagName).toBe('A'); + expect(link).toHaveAttribute('href', '/media/thing.pdf'); + expect(screen.getByRole('link', {name: 'Save file'}).tagName).toBe('A'); + }); + + it('is still a button when there is no href', () => { + renderUI(); + + expect(screen.getByRole('button', {name: 'Save'}).tagName).toBe('BUTTON'); + }); + it('gives icon-only buttons an accessible name', () => { renderUI(); @@ -270,6 +294,17 @@ describe('Progress', () => { expect(screen.getByRole('progressbar')).toHaveAttribute('aria-valuenow', '0'); }); + it('reports no value when the size is unknown', () => { + // Semantic called this `indicating`. An upload that has not reported its size + // would otherwise sit at 0% and read as stalled, and `aria-valuenow=0` would tell + // a screen reader the same wrong thing. + renderUI(); + + const bar = screen.getByRole('progressbar'); + expect(bar).not.toHaveAttribute('aria-valuenow'); + expect(bar).toHaveClass('wrolpi-progress-indeterminate'); + }); + it('shows arbitrary label text instead of the percentage', () => { renderUI(); diff --git a/app/src/themes/tokens.css b/app/src/themes/tokens.css index 7eaa20d2..7c7e2170 100644 --- a/app/src/themes/tokens.css +++ b/app/src/themes/tokens.css @@ -204,6 +204,19 @@ html[data-media-filter="amber-mono"] :is(img, video, canvas, iframe, embed, obje filter: url(#wrolpi-amber-mono); } +/* + * A `.media` wrapper filters its whole subtree as one unit, so the leaf media inside + * it must not be filtered a second time. Two passes of a luminance-to-red matrix is + * not the same as one: the second pass takes the luminance of an already-red pixel + * (0.2126 of it) and the image comes out markedly darker than everything around it. + * + * The audio player's poster is the case that found this — the poster sits under + * custom control glyphs, so the wrapper is filtered to keep the two tinted together. + */ +html[data-media-filter] .media :is(img, video, canvas, iframe, embed, object) { + filter: none; +} + /* * A filter on an ancestor stops applying once the browser promotes a video to * native fullscreen, so the filter is restated on the fullscreened element. diff --git a/app/src/themes/tokens.test.js b/app/src/themes/tokens.test.js index 842ab0e9..4774bc25 100644 --- a/app/src/themes/tokens.test.js +++ b/app/src/themes/tokens.test.js @@ -103,7 +103,9 @@ describe('media filter rules', () => { .map(match => match[1]); expect(filterRules.length).toBeGreaterThan(0); - filterRules.forEach(selector => expect(selector).toMatch(/^data-media-filter=/)); + // With or without a value: the reset rule for nested media keys on the bare + // attribute. What matters is that no filter rule keys on `data-theme`. + filterRules.forEach(selector => expect(selector).toMatch(/^data-media-filter/)); }); it('has a rule for every filter a theme offers', () => { @@ -117,6 +119,14 @@ describe('media filter rules', () => { }); }); + it('does not filter leaf media twice inside a filtered wrapper', () => { + // A `.media` wrapper filters its subtree as a unit; the img/video inside it also + // matches the element-type rule, and two passes of the matrix take the luminance + // of an already-red pixel, leaving that image much darker than its surroundings. + expect(tokensCss).toMatch( + /html\[data-media-filter] \.media :is\([^)]*\)\s*{\s*filter:\s*none/); + }); + it('never filters an ancestor, only leaf media', () => { // A CSS filter creates a containing block, which would break `position: fixed` // descendants — the nav bar and every modal.