diff --git a/app/src/components/ThemeSamplePage.js b/app/src/components/ThemeSamplePage.js
index e9d6b9cc..1b0e10f3 100644
--- a/app/src/components/ThemeSamplePage.js
+++ b/app/src/components/ThemeSamplePage.js
@@ -376,6 +376,55 @@ export function ThemeSamplePage() {
+
+
+ {/*
+ The label sits on BOTH halves of the bar, so every one of these is really a
+ contrast test: the text has to read over the fill and over the track, in all
+ four themes. That is why light brightens the fill rather than using the raw
+ token, and why night and amber drop it to --muted instead of a hue.
+ */}
+
+
+
+ Its own label instead of the percentage
+
+
+
+
+
+
+ No label at all, and unknown size
+
+
+ {/* Indeterminate: a band sliding inside the bar, for work whose size is
+ not known yet. It must stay within the track -- it used to slide out
+ past both edges, because nothing clipped it. */}
+
+
+
+
+ Carrying a meaning
+
+
+
+
+
+
+
+ A bar is 22px so its label has room; at 16px the text filled the bar edge to
+ edge. Check the mid-range ones — 37% and 62% are where the label crosses the
+ boundary between fill and track, which is the case that has to work.
+
+
+
+
@@ -683,9 +732,6 @@ 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/ui-layout.cy.js b/app/src/components/ui/ui-layout.cy.js
index 58b22fe1..6fab2aab 100644
--- a/app/src/components/ui/ui-layout.cy.js
+++ b/app/src/components/ui/ui-layout.cy.js
@@ -1,8 +1,8 @@
import React from 'react';
import {
ActionInput, Button, Header, Icon, IconStack, Loading, MultiSelect, Panel, PathInput,
- Message, Placeholder, Statistic, StatisticGroup, Status, TabBar, Table, tabClassName,
- TextInput,
+ Message, Placeholder, Progress, Statistic, StatisticGroup, Status, TabBar, Table,
+ tabClassName, TextInput,
} from './index';
import {contrastingColor, HelpHeader, LoadStatistic} from '../Common';
import {Notifications} from '@mantine/notifications';
@@ -509,6 +509,136 @@ describe('a heading opens a section rather than closing the last one', () => {
});
});
+describe('a progress bar has room for the label inside it', () => {
+ const bar = (props) => ;
+
+ it('leaves air above and below the text', () => {
+ /*
+ * The Status page draws 44 of these. At 16px tall the 11px bold label left about
+ * half a pixel of air, which is legible only if you already know what it says.
+ */
+ cy.mountUI(bar({percent: 62, label: 'CPU Usage (16 cores)'}), {theme: 'light'});
+
+ cy.get('.wrolpi-progress').should(($bar) => {
+ const box = $bar[0].getBoundingClientRect();
+ const text = $bar[0].querySelector('.wrolpi-progress-text')
+ .firstChild.getBoundingClientRect
+ ? $bar[0].querySelector('.wrolpi-progress-text').getBoundingClientRect()
+ : null;
+ expect(box.height, 'bar height').to.be.at.least(20);
+ // The line box, measured from the text node itself rather than its stretched
+ // flex container, which is inset:0 and therefore always the bar's height.
+ const range = document.createRange();
+ range.selectNodeContents($bar[0].querySelector('.wrolpi-progress-text'));
+ const line = range.getBoundingClientRect();
+ expect(box.height - line.height, 'air around the label').to.be.at.least(4);
+ });
+ });
+
+ it('keeps a long label on one line', () => {
+ /*
+ * `white-space: nowrap` on the label. The first version of this asserted the bar did
+ * not get wider -- which it cannot: the label is `position: absolute; inset: 0`, so it
+ * takes no part in its parent's width and the claim was unfalsifiable. What a long
+ * label really does is wrap to a second line inside a 22px bar and get cut in half by
+ * the clip, so that is what is measured.
+ */
+ cy.mountUI(
+
+
+
,
+ {theme: 'light'},
+ );
+
+ cy.get('.wrolpi-progress-text').should(($text) => {
+ const range = document.createRange();
+ range.selectNodeContents($text[0]);
+ const lines = range.getClientRects().length;
+ expect(lines, 'the label occupies one line box').to.equal(1);
+ });
+ });
+
+ it('separates bars that are stacked directly on one another', () => {
+ // The Status page's CPU and RAM bars are bare siblings in a Panel and were touching.
+ cy.mountUI(
+
+
+
+ ,
+ {theme: 'light'},
+ );
+
+ cy.get('.wrolpi-progress').should(($bars) => {
+ const [first, second] = [...$bars].map(b => b.getBoundingClientRect());
+ expect(second.top - first.bottom, 'gap between stacked bars').to.be.at.least(6);
+ });
+ });
+
+ it('adds no gap to a bar its container already spaces', () => {
+ /*
+ * The other half, and the reason for `* +` rather than a blanket margin: the drive
+ * bandwidth bars each sit alone in a Grid column, which supplies the gutters. A
+ * blanket margin would add a second one on top.
+ */
+ cy.mountUI(
+
+
+
+ ,
+ {theme: 'light'},
+ );
+
+ cy.get('.wrolpi-progress').should(($bars) => {
+ [...$bars].forEach((bar, index) =>
+ expect(getComputedStyle(bar).marginTop, `bar ${index} is an only child`)
+ .to.equal('0px'));
+ });
+ });
+
+ themeNames.forEach((theme) => {
+ it(`reads its label against the track in ${theme}`, () => {
+ /*
+ * The label spans the whole bar, so it sits on the fill AND on the track. This
+ * asserts the track half only, and that is deliberate: over the FILL it measures
+ * 5.49 in light but 2.35 / 2.48 / 2.74 in dark, night and amber -- below any
+ * legibility floor. Fixing that needs a per-half text colour, which is a design
+ * decision rather than a bug fix, so it is reported rather than quietly asserted
+ * at a threshold low enough to pass. See the note in ui.css.
+ */
+ cy.mountUI(bar({percent: 20, label: 'CPU Usage'}), {theme});
+
+ cy.get('.wrolpi-progress').should(($bar) => {
+ const text = toRgb(getComputedStyle(
+ $bar[0].querySelector('.wrolpi-progress-text')).color);
+ const track = toRgb(getComputedStyle($bar[0]).backgroundColor);
+ expect(contrast(text, track), 'label against the empty part of the bar')
+ .to.be.at.least(4.5);
+ });
+ });
+ });
+
+ it('keeps the indeterminate band inside the bar', () => {
+ /*
+ * The band slides on `margin-left`, from -35% to 100%. Those are its real layout
+ * positions -- it genuinely does extend past both edges, and `getBoundingClientRect`
+ * reports that whether or not it is visible. So the assertion is on the clip, plus
+ * the fact that the band escapes without one: together they show the clip is doing
+ * work rather than being decoration.
+ */
+ cy.mountUI(bar({indeterminate: true, label: 'Uploading…'}), {theme: 'light'});
+
+ cy.get('.wrolpi-progress-indeterminate').should(($bar) => {
+ expect(getComputedStyle($bar[0]).overflow, 'the bar clips its contents')
+ .to.equal('hidden');
+
+ const box = $bar[0].getBoundingClientRect();
+ const band = $bar[0].querySelector('.wrolpi-progress-fill').getBoundingClientRect();
+ expect(band.left < box.left - 1 || band.right > box.right + 1,
+ 'the band does leave the box, so the clip is load-bearing').to.equal(true);
+ });
+ });
+});
+
describe('a table header is a surface, not just bold text', () => {
themeNames.forEach((theme) => {
it(`separates the header from every body row in ${theme}`, () => {
diff --git a/app/src/components/ui/ui.css b/app/src/components/ui/ui.css
index 768c1494..05ea35a3 100644
--- a/app/src/components/ui/ui.css
+++ b/app/src/components/ui/ui.css
@@ -975,14 +975,37 @@ html .mantine-Notification-closeButton:hover {
/* --------------------------------------------------------------- Progress */
+/*
+ * Tall enough for the label inside it. At 16px a 14px line box left half a pixel of air
+ * above and below the text, which on the Status page is 44 bars of cramped 11px bold --
+ * legible only if you already knew what it said.
+ *
+ * `overflow: hidden` is what keeps the indeterminate band inside the bar. Without it the
+ * band slides from `margin-left: -35%` to `100%` in full view, so the animation ran from
+ * outside the left edge to outside the right one, painting over whatever sat beside it.
+ */
.wrolpi-progress {
position: relative;
- height: 16px;
+ height: 22px;
min-width: 120px;
+ overflow: hidden;
background: var(--bg);
border: 1px solid var(--border);
}
+/*
+ * Bars stacked directly on top of each other need a gap; bars that a container already
+ * spaces do not. On the Status page the CPU and RAM bars are bare siblings in a Panel and
+ * were touching, while the drive-bandwidth bars each sit alone in a Grid column and get
+ * their spacing from the Grid.
+ *
+ * `* +` distinguishes those two cases by itself: it fires only when a bar actually follows
+ * something, so an only-child bar in a column is untouched.
+ */
+* + .wrolpi-progress {
+ margin-top: 8px;
+}
+
.wrolpi-progress-fill {
height: 100%;
background: var(--progress-color, var(--blue));
@@ -1029,15 +1052,29 @@ html .mantine-Notification-closeButton:hover {
display: flex;
align-items: center;
justify-content: center;
- font-size: 11px;
+ /* 12px rather than 11: these labels carry units and device names, not just a number. */
+ font-size: 12px;
font-weight: 700;
color: var(--text);
font-variant-numeric: tabular-nums;
+ /* A long label must not push the bar wider than the column it was given. */
+ padding: 0 6px;
+ overflow: hidden;
+ white-space: nowrap;
}
/*
* The percent text sits on both the filled and empty halves of the bar, so the
* fill has to stay light enough in light mode for dark text to read over it.
+ *
+ * MEASURED, and only light actually clears it. The label against the fill is
+ * 5.49:1 in light (the brightness filter below is what buys that) but 2.35 in
+ * dark, 2.48 in night and 2.74 in amber; against the empty track it is 5.2-15:1
+ * everywhere. Reaching AA over the fill needs the label drawn twice, clipped to
+ * the fill boundary, so each half gets its own colour -- and in night not even
+ * that would do it, since `--text` is only ~4:1 against pure black and the fill
+ * is lighter than black by definition. Left as it is, deliberately, rather than
+ * asserted at a threshold chosen to pass.
*/
html[data-theme="light"] .wrolpi-progress {
background: var(--panel);