ui: progress bars get room for their label, and keep their band inside

Three things, plus an answer to why two sets of bars on the same page were
spaced differently.

The bar was 16px tall with an 11px bold label, leaving about half a pixel of air
above and below.  On the Status page that is 44 of them.  Now 22px with a 12px
label -- these labels carry device names and units, not just a number.

The indeterminate band escaped the bar.  It slides on `margin-left` from -35% to
100%, and nothing clipped it, so the animation ran from outside the left edge to
outside the right one.  `overflow: hidden` on the bar.

The CPU and RAM bars were touching while the drive-bandwidth bars on the same
page were not, and the difference was not the bars: CPU and RAM are bare
siblings inside a Panel, while each bandwidth bar sits alone in a Grid column
and takes its spacing from the Grid.  `* + .wrolpi-progress` distinguishes those
two by itself -- it fires only when a bar actually follows something, so an
only-child bar in a column is untouched.  A blanket margin would have doubled
the Grid's gutter, and that is asserted too.

The gallery gains a Progress section: six percentages, custom labels, no label,
indeterminate, and the four roles.  Its own demo gaps were removed so what it
shows is the spacing the component really has.

MEASURED while building the gallery, and NOT fixed: the label reads 5.49:1
against the fill in light -- the brightness filter 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 to
15:1 everywhere.  Reaching AA over the fill needs the label drawn twice and
clipped at the fill boundary so each half takes its own colour, and in night not
even that suffices: `--text` is only about 4:1 against pure black and a fill is
lighter than black by definition.  Recorded in ui.css and in the test, which
asserts the track half only rather than picking a threshold low enough to pass.

One test was unfalsifiable and is rewritten.  "Keeps a long label from widening
the bar" cannot fail: the label is `position: absolute; inset: 0` and takes no
part in its parent's width.  What a long label actually does is wrap to a second
line inside a 22px bar and get cut in half by the new clip, so the test measures
line boxes now.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Roland 2026-08-01 19:52:10 -06:00
parent fc0958369b
commit 5df38916f1
No known key found for this signature in database
GPG key ID: 1BA620CE6C9A4680
3 changed files with 220 additions and 7 deletions

View file

@ -376,6 +376,55 @@ export function ThemeSamplePage() {
</Panel>
</Section>
<Section label='Progress'>
<Panel>
{/*
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.
*/}
<div style={{maxWidth: 460}}>
<Progress percent={0}/>
<Progress percent={8}/>
<Progress percent={37}/>
<Progress percent={62}/>
<Progress percent={94}/>
<Progress percent={100}/>
</div>
<Header as='h5'>Its own label instead of the percentage</Header>
<div style={{maxWidth: 460}}>
<Progress percent={41} label='2.1 GB / 5.3 GB'/>
<Progress percent={73} label='CPU Usage (16 cores)'/>
<Progress percent={12} label='nbd0 read 4.2 MBps'/>
</div>
<Header as='h5'>No label at all, and unknown size</Header>
<div style={{maxWidth: 460}}>
<Progress percent={55} showPercent={false}/>
{/* 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. */}
<Progress indeterminate label='Uploading…'/>
<Progress indeterminate showPercent={false}/>
</div>
<Header as='h5'>Carrying a meaning</Header>
<div style={{maxWidth: 460}}>
<Progress percent={64} color='info' label='Downloading'/>
<Progress percent={100} color='success' label='Complete'/>
<Progress percent={80} color='warning' label='Disk 80% full'/>
<Progress percent={100} color='danger' label='Error: HTTP 403'/>
</div>
<p style={{fontSize: 12, color: 'var(--muted)', marginTop: 14, marginBottom: 0}}>
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.
</p>
</Panel>
</Section>
<Section label='Messages'>
<div style={{display: 'flex', flexDirection: 'column', gap: 10}}>
<Message kind='info' title='Refresh started'>
@ -683,9 +732,6 @@ export function ThemeSamplePage() {
<Row>
<Button role='cancel' icon='eye' onClick={() => setModalOpen(true)}>Open a modal</Button>
</Row>
<div style={{marginTop: 12, maxWidth: 320}}>
<Progress indeterminate label='Uploading…'/>
</div>
<p style={{fontSize: 12, color: 'var(--muted)', marginBottom: 0, marginTop: 10}}>
Semantic's compound shape is kept — Modal.Header, Modal.Content,
Modal.Actions — so the 34 call sites written against it migrate by import.

View file

@ -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) => <Panel><Progress {...props}/></Panel>;
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(
<div style={{width: 200}}>
<Progress percent={40} label='nbd0 read 128.4 MBps sustained over 5 minutes'/>
</div>,
{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(
<Panel>
<Progress percent={20} label='CPU Usage'/>
<Progress percent={70} label='RAM Usage'/>
</Panel>,
{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(
<Panel>
<div><Progress percent={20} label='nbd0 read'/></div>
<div><Progress percent={30} label='nbd0 write'/></div>
</Panel>,
{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}`, () => {

View file

@ -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);