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.