ui: three from the dashboard and the video pages

The Download/Or/Upload panel.  Semantic drew it as a `Segment placeholder` with
a vertical `Divider`: two equal fields, a rule down the true middle, a button
centred in each.  The migrated version was a `Group` with a divider between two
buttons, so the halves were sized to their labels -- the pair huddled in the
middle with the rule wherever they left it.  `.wrolpi-or-split` is a grid, so
the halves stay equal whatever the labels say, and the rule is the second
column's border rather than an element between them (an element would be a third
grid item and push the middle off centre by its own width).  Below the tablet
breakpoint the halves stack and the rule turns horizontal, as Semantic's
`stackable` grid did.

The video page's panels touched the left edge.  Its route is deliberately
outside `VideosTabLayout`'s `PageContainer` so the player can run to both edges
-- which is the most viewing area a phone can give it, and worth keeping -- but
everything below the player inherited that too.  `.wrolpi-page-inset` gives the
stack under the player the same inset `PageContainer` gives a page, at the same
breakpoint, so a phone still has no side padding anywhere.

A video's duration chip was a literal `#ffffff` with black text.  The poster
under it IS filtered by night mode, being a `.media` leaf, but the chip is drawn
beside the image rather than inside it, so the filter never reached it: a white
tile on a card whose picture had gone dim red, and the brightest thing on the
videos page in the theme whose whole point is that nothing is bright.  Tokens
now, opaque because it sits on an arbitrary poster.

Nine guards, all verified to fail against the old code.  One thing they turned
up: cypress component tests default to a 500x500 viewport, which is BELOW the
699px breakpoint, so viewport-keyed CSS is exercised in its phone branch unless
a test says otherwise.  The desktop assertions set the viewport explicitly and
say why; the stacked branch has a test of its own.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Roland 2026-08-05 17:38:19 -06:00
parent 4aebcd551c
commit af4712718f
No known key found for this signature in database
GPG key ID: 1BA620CE6C9A4680
6 changed files with 315 additions and 12 deletions

View file

@ -184,12 +184,23 @@ pre.wrap-text {
word-wrap: break-word; /* Internet Explorer 5.5+ */
}
/*
* A video's length, pinned to the corner of its poster.
*
* Tokens, not a literal white-on-black. The poster under it is filtered by night mode -- it is a
* `.media` leaf -- but this chip is drawn beside the image rather than inside it, so it was never
* filtered: a white tile on a card whose picture had gone dim red, which is the brightest thing on
* the videos page in the theme meant to have nothing bright on it.
*
* Opaque, because it sits on an arbitrary poster and has to be legible over any of them.
*/
.duration-overlay {
position: absolute;
top: 0.25rem;
right: 0.25rem;
color: black;
background: #ffffff;
color: var(--text);
background: var(--panel);
border: 1px solid var(--border);
padding: 0px 0.25rem;
border-radius: 0.1875rem;
}

View file

@ -238,15 +238,19 @@ export function Getters() {
}
const getter = <Panel>
<Group justify='center' align='stretch' gap='xl' wrap='wrap'>
<Button role='primary' icon='download' onClick={e => handleSetGetter(e, 'downloads')}>
Download
</Button>
<Divider orientation='vertical' label='Or' labelPosition='center'/>
<Button role='save' icon='upload' onClick={e => handleSetGetter(e, 'upload')}>
Upload
</Button>
</Group>
<div className='wrolpi-or-split'>
<div>
<Button role='primary' icon='download' onClick={e => handleSetGetter(e, 'downloads')}>
Download
</Button>
</div>
<div>
<Button role='save' icon='upload' onClick={e => handleSetGetter(e, 'upload')}>
Upload
</Button>
</div>
<span className='wrolpi-or-label'>Or</span>
</div>
</Panel>;
let getterModal;

View file

@ -366,6 +366,24 @@ export function ThemeSamplePage() {
</Panel>
</Section>
<Section label='Two choices of equal weight'>
<Panel>
{/* Semantic's `Segment placeholder` with a vertical Divider, which is what the
dashboard's Download/Upload panel is. The halves are a grid so they stay equal
whatever the labels say, and the rule falls at the panel's true middle. */}
<div className='wrolpi-or-split'>
<div><Button role='primary' icon='download'>Download</Button></div>
<div><Button role='save' icon='upload'>Upload</Button></div>
<span className='wrolpi-or-label'>Or</span>
</div>
</Panel>
<p style={{fontSize: '0.75rem', color: 'var(--muted)'}}>
The disc masks the rule behind it and is painted with <code>--panel</code>, so it
only disappears into the surface on a Panel — on any other ground it reads as a
chip. Below the tablet breakpoint the halves stack and the rule turns horizontal.
</p>
</Section>
<Section label='Toasts'>
<Panel>
{/*

View file

@ -474,7 +474,7 @@ function VideoPage({videoFile, prevFile, nextFile, fetchVideo, ...props}) {
{/* `wrolpi-stack`: everything below the player is stacked in here rather than in the
page, so this is what spaces it. */}
<div className='wrolpi-stack' style={{marginTop: '1em'}}>
<div className='wrolpi-stack wrolpi-page-inset' style={{marginTop: '1em'}}>
<Panel>
<Header as='h2'>{title}</Header>

View file

@ -433,6 +433,142 @@ describe('a row of buttons is spaced and aligned by the row', () => {
});
});
describe('two choices of equal weight are split down the middle', () => {
/*
* The dashboard's Download/Upload panel, which Semantic drew as a `Segment placeholder` with a
* vertical `Divider`. The migrated version was a `Group` with a divider between the buttons,
* so the halves were sized to their labels: the pair huddled in the middle of the panel with
* the rule wherever they happened to leave it, rather than two equal fields split at the centre.
*/
/*
* The viewport is set explicitly. Cypress component tests default to 500x500, which is BELOW
* the 699px breakpoint where these halves stack -- so without this every assertion below would
* be measuring the phone layout while claiming to measure the desktop one.
*/
const mountSplit = (width = 1000) => cy.viewport(width, 700).then(() => cy.mountUI(<div>
<Panel>
<div className='wrolpi-or-split'>
<div><Button role='primary'>Download</Button></div>
<div><Button role='save'>Upload</Button></div>
<span className='wrolpi-or-label'>Or</span>
</div>
</Panel>
</div>));
it('gives each choice half the panel, whatever its label says', () => {
mountSplit();
cy.get('.wrolpi-or-split').should(($split) => {
const halves = [...$split[0].children]
.filter(el => !el.classList.contains('wrolpi-or-label'))
.map(el => el.getBoundingClientRect());
expect(halves, 'two halves').to.have.length(2);
// Equal, and each really is half -- not two content-sized boxes that happen to match.
expect(halves[1].width, 'the halves are equal').to.be.closeTo(halves[0].width, 1);
const split = $split[0].getBoundingClientRect();
expect(halves[0].width, 'and each is half the row').to.be.closeTo(split.width / 2, 1);
});
});
it('centres each button in its own half', () => {
// Semantic centred them; the Group version left them either side of the divider instead.
mountSplit();
cy.get('.wrolpi-or-split').should(($split) => {
const halves = [...$split[0].children]
.filter(el => !el.classList.contains('wrolpi-or-label'));
halves.forEach((half) => {
const box = half.getBoundingClientRect();
const button = half.querySelector('button').getBoundingClientRect();
expect(button.left + button.width / 2, 'button centred in its half')
.to.be.closeTo(box.left + box.width / 2, 1);
});
});
});
it('puts the rule and its label at the true middle', () => {
mountSplit();
cy.get('.wrolpi-or-split').should(($split) => {
const split = $split[0].getBoundingClientRect();
const middle = split.left + split.width / 2;
// The rule is the second half's left border, so its edge IS the middle.
const second = $split[0].children[1];
expect(parseFloat(getComputedStyle(second).borderLeftWidth), 'a rule is drawn')
.to.be.greaterThan(0);
expect(second.getBoundingClientRect().left, 'the rule falls at the middle')
.to.be.closeTo(middle, 1);
const label = $split[0].querySelector('.wrolpi-or-label').getBoundingClientRect();
expect(label.left + label.width / 2, 'and the label rides it').to.be.closeTo(middle, 1);
expect(label.top + label.height / 2, 'halfway down')
.to.be.closeTo(split.top + split.height / 2, 1);
});
});
it('stacks the halves below the tablet breakpoint', () => {
// The other branch, and the one the suite would otherwise test by default.
mountSplit(400);
cy.get('.wrolpi-or-split').should(($split) => {
const halves = [...$split[0].children]
.filter(el => !el.classList.contains('wrolpi-or-label'));
const boxes = halves.map(el => el.getBoundingClientRect());
expect(boxes[1].top, 'the second sits below the first').to.be.greaterThan(boxes[0].top);
expect(boxes[1].width, 'each takes the full width').to.be.closeTo(boxes[0].width, 1);
const second = getComputedStyle(halves[1]);
expect(parseFloat(second.borderLeftWidth), 'no vertical rule').to.equal(0);
expect(parseFloat(second.borderTopWidth), 'a horizontal one instead').to.be.greaterThan(0);
});
});
it('masks the rule behind its label rather than letting it strike through', () => {
// An "Or" with a line through it is the thing the disc exists to prevent, so it has to be
// painted -- and with the Panel's own color, or it reads as a chip laid on the surface.
mountSplit();
cy.get('.wrolpi-or-label').should(($label) => {
const panel = getComputedStyle(Cypress.$('.wrolpi-panel')[0]).backgroundColor;
expect(getComputedStyle($label[0]).backgroundColor, 'painted with the panel')
.to.equal(panel);
});
});
});
describe('the video page insets what sits under its player', () => {
/*
* The video route is deliberately outside `VideosTabLayout`'s `PageContainer` so the player can
* run to both edges of the window -- the most viewing area a phone can give it. Everything
* below the player inherited that, so the panels touched the left edge of the page.
*/
it('pads the stack under the player, and nothing above it', () => {
// Above the tablet breakpoint; below it a page has no side padding and neither has this.
cy.viewport(1000, 700);
cy.mountUI(<div>
<video data-testid='player' style={{width: '100%'}}/>
<div className='wrolpi-stack wrolpi-page-inset'>
<Panel>About</Panel>
</div>
</div>);
cy.get('.wrolpi-page-inset').should(($inset) => {
const styles = getComputedStyle($inset[0]);
const padding = parseFloat(styles.paddingLeft);
expect(padding, 'the stack is inset').to.be.greaterThan(0);
expect(parseFloat(styles.paddingRight), 'on both sides').to.be.closeTo(padding, 0.5);
// The player keeps the full width; only what is under it moves in.
const player = Cypress.$('[data-testid="player"]')[0].getBoundingClientRect();
const panel = $inset[0].querySelector('.wrolpi-panel').getBoundingClientRect();
expect(panel.left - player.left, 'the panel clears the edge the player touches')
.to.be.closeTo(padding, 0.5);
});
});
});
/* `--panel` is authored as a hex; computed backgrounds come back as rgb(). */
const hexToRgb = (hex) => {
const value = hex.replace('#', '');
@ -2166,6 +2302,51 @@ describe('theme tokens actually resolve', () => {
});
});
describe("a video's duration chip belongs to the theme, not to the poster", () => {
/*
* The chip was a literal `#ffffff` with black text. The poster under it IS filtered by night
* mode -- it is a `.media` leaf -- but the chip is drawn beside the image rather than inside it,
* so the filter never reached it: a white tile on a card whose picture had gone dim red, and the
* brightest thing on the videos page in the theme whose whole point is that nothing is bright.
*/
themeNames.forEach((theme) => {
it(`takes the surface and text of ${theme}`, () => {
cy.mountUI(<><Panel>Surface</Panel><div className='duration-overlay'>12:34</div></>,
{theme, mediaFilter: theme === 'night' ? 'night-red' : undefined});
cy.get('.duration-overlay').should(($chip) => {
const chip = getComputedStyle($chip[0]);
const panel = getComputedStyle(Cypress.$('.wrolpi-panel')[0]);
// The theme's surface, not a hardcoded one.
expect(chip.backgroundColor, 'painted with the theme surface')
.to.equal(panel.backgroundColor);
expect(chip.color, 'and the theme text').to.equal(panel.color);
// And still readable, which is what it is for: a length nobody can read is chrome.
expect(contrast(chip.color, chip.backgroundColor), 'legible')
.to.be.at.least(4.5);
});
});
});
it('is never the brightest thing on a night card', () => {
/*
* The report, stated as something measurable. In night the chip sat at full white against
* a panel around 4% luminance -- the specific complaint was "the timestamp is white".
*/
cy.mountUI(<><Panel>Card</Panel><div className='duration-overlay'>12:34</div></>,
{theme: 'night', mediaFilter: 'night-red'});
cy.get('.duration-overlay').should(($chip) => {
const chip = getComputedStyle($chip[0]);
const page = getComputedStyle(document.documentElement).backgroundColor;
expect(luminance(chip.backgroundColor), 'no brighter than the page it sits on')
.to.be.at.most(luminance(page) + 0.05);
});
});
});
describe('media filtering', () => {
it('tints a bare image in night', () => {
cy.mountUI(<img alt='poster' src='/logo.png' width='80' height='80'/>,

View file

@ -1354,6 +1354,95 @@ html[data-theme="amber"] .wrolpi-progress-fill {
gap: 0.625rem;
}
/*
* The horizontal inset a page gets from `PageContainer`, for content that cannot be inside one.
*
* The video page is the case: its route sits OUTSIDE `VideosTabLayout`'s `PageContainer` so the
* player can run to both edges of the window, which is the most viewing area a phone can give it.
* Everything below the player inherited that, so the panels touched the left edge too.
*
* The 700px is fresnel's `tablet` breakpoint (contexts.js), matching `PageContainer`: below it a
* page has no side padding at all, and this has to agree or the video page would be inset on a
* phone where every other page is not.
*/
.wrolpi-page-inset {
padding-left: 1em;
padding-right: 1em;
}
@media (max-width: 699px) {
.wrolpi-page-inset {
padding-left: 0;
padding-right: 0;
}
}
/* ------------------------------------------------------------- Or divider */
/*
* Two choices of equal weight, split down the middle -- Semantic's `Segment placeholder` with a
* vertical `Divider`, which the dashboard's Download/Upload panel was built as.
*
* The migrated version was a `Group` with a divider between two buttons, so the halves were sized
* to their buttons rather than to the panel: the pair huddled in the centre with the rule wherever
* they left it, instead of two equal fields with the rule at the true middle.
*
* A grid, so the halves are equal whatever the labels say, and the rule is drawn on the second
* column's edge rather than as an element between them -- an element would be a third grid item
* and push the middle off centre by its own width.
*/
.wrolpi-or-split {
display: grid;
grid-template-columns: 1fr 1fr;
position: relative;
}
.wrolpi-or-split > * {
display: flex;
align-items: center;
justify-content: center;
padding: 1.5rem 1rem;
}
.wrolpi-or-split > * + * {
border-left: 1px solid var(--border);
}
/*
* The "Or" sits ON the rule, in a disc that masks the line behind it. `--panel` because that is
* what the surrounding Panel is painted with; on any other surface the disc would show as a chip.
*/
.wrolpi-or-label {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
display: flex;
align-items: center;
justify-content: center;
width: 2rem;
height: 2rem;
border-radius: 50%;
background: var(--panel);
border: 1px solid var(--border);
color: var(--muted);
font-size: 0.8125rem;
font-weight: 600;
text-transform: uppercase;
}
/* Stacked on a phone: the rule turns horizontal, and the disc rides it. */
@media (max-width: 699px) {
.wrolpi-or-split {
grid-template-columns: 1fr;
}
.wrolpi-or-split > * + * {
border-left: none;
border-top: 1px solid var(--border);
}
}
/* ----------------------------------------------------------------- Panels */
.wrolpi-panel {