ui: let a landscape poster fill a one-column card, and fix three scale-blind tests

`--card-poster-max-height` is a fixed height, so it stops scaling with the card: held to
220px, a 16:9 thumbnail is 391px wide, and any card wider than that centred it with empty
panel on both sides.  That is every video card at one column -- a phone, or any window
under about 456px of content width.

The cap is now `max(var(--card-poster-max-height), 56.25cqw)` with the poster band as the
query container.  56.25cqw is nine sixteenths of the card: exactly the height of a 16:9
image that fills the width, and the same number states the rule for every other ratio --
a poster may be as tall as a 16:9 slice of this card and no taller.  So a portrait book
cover still reads as a band rather than a column, and now grows with the card instead of
stopping at 220px.  `max()` rather than `min()` so the floor wins on a narrow card.

The gap pre-dated the interface scale, below about 414px, but the scale widened the range
it shows in: the track minimum went from 200px to 220px, so one column now starts at 456px
of content width instead of 414.

Every existing poster case mounted a 233px card, which is why the wide card's own failure
went unseen for the whole migration.  Two cases added on a 451px card: a landscape poster
reaches the edges, and a portrait cover stays a band.

Three tests carried a hardcoded px expectation, which is a hidden assertion that the
interface scale is 1.  All three failed at 1.1, in names that said nothing about size --
and I did not catch them because I ran jest and the build for the scale change but not
cypress, which AGENTS.md asks for on frontend work:

- `cardPosterCap()` read `--card-poster-max-height` with parseFloat.  The cap is rem now,
  so it returned 12.5 and failed a within(80, 240) bound -- taking five poster tests with
  it.  It resolves through the root font-size and checks the unit.
- `inNarrowColumn` was a fixed 340px, chosen to hold two 150px statistic tracks.  At 165px
  tracks it became one column, so a test about the gap BETWEEN columns had no second
  column.  It is now 340px times the scale, because "narrow" means narrow relative to the
  type.
- The APIButton size test asserted 36px for `small`.  It now measures against a real `sm`
  button, which is the claim it was making -- that `small` resolves to `sm` -- and holds at
  any scale.

A `uiScale()` helper reads the token so no further test has to name a pixel count.

Verified: ui-layout 248 passing, 0 failing; 1141 jest tests / 65 suites; clean build;
browser-checked at 400/451/500/700/1200px of grid width, no side gap at any of them.  The
12 remaining component failures are the pre-existing Semantic-era selector failures in
Tags, Channels, Download and Inventory.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Roland 2026-08-03 15:02:15 -06:00
parent dbc9f823a4
commit 7d602f7ea8
No known key found for this signature in database
GPG key ID: 1BA620CE6C9A4680
2 changed files with 134 additions and 6 deletions

View file

@ -16,6 +16,20 @@ import {DesktopNav, NavIconWrapper} from '../Nav';
import {APIButton} from '../Common';
import {ShareButton} from '../Share';
/*
* The interface scale, so a test can express a length the way the stylesheet does.
*
* A hardcoded px expectation is a hidden assertion that the scale is 1. Three tests here
* carried one -- a container width, a poster cap and a button height -- and all three failed
* when it became 1.1, in names that said nothing about size.
*/
const uiScale = () => {
const value = parseFloat(getComputedStyle(document.documentElement)
.getPropertyValue('--ui-scale'));
expect(value, 'the interface scale is declared').to.be.within(0.5, 3);
return value;
};
/* `--panel` is authored as a hex; computed backgrounds come back as rgb(). */
const hexToRgb = (hex) => {
const value = hex.replace('#', '');
@ -891,7 +905,14 @@ describe('StatisticGroup separates its statistics with nothing but space', () =>
*/
// Narrow enough that six statistics cannot fit on one row at a 150px minimum.
const inNarrowColumn = (children) => <div style={{width: 340}}>{children}</div>;
/*
* "Narrow" has to mean narrow RELATIVE TO THE TYPE, or the width stops meaning anything
* when the interface scale changes. At a fixed 340px this held two 150px statistic
* tracks; the scale took the track to 165px and 340px became a single column, so a test
* about the gap between columns had no second column to measure.
*/
const inNarrowColumn = (children) =>
<div style={{width: `${Math.round(340 * uiScale())}px`}}>{children}</div>;
const sixStatistics = <StatisticGroup>
<Statistic value='1,432' label='Videos'/>
@ -1709,6 +1730,16 @@ const servePoster = (width, height) => cy.intercept(
const narrowCard = (children) =>
<MemoryRouter><div style={{width: 233}}>{children}</div></MemoryRouter>;
/*
* A card as wide as the grid makes it when it drops to ONE column -- a phone, or any window
* under about 456px of content width. Every case below used the narrow card, which is why
* the wide card's own failure went unseen for the whole migration: a 16:9 poster held to a
* 220px height is 391px wide, so a 451px card centred it with 30px of empty panel on either
* side. The height cap was doing its job for a book cover and letterboxing a video sideways.
*/
const wideCard = (children) =>
<MemoryRouter><div style={{width: 451}}>{children}</div></MemoryRouter>;
/*
* The height cap, read from the stylesheet rather than repeated here, so the two cannot
* drift. Deleting the declaration makes this NaN and every comparison against it fails,
@ -1716,10 +1747,20 @@ const narrowCard = (children) =>
* a test that any value at all satisfies.
*/
const cardPosterCap = () => {
const value = parseFloat(getComputedStyle(document.documentElement)
.getPropertyValue('--card-poster-max-height'));
/*
* Resolved through the root font-size, because the cap is declared in rem so the
* interface scale reaches it. `parseFloat('12.5rem')` is 12.5, which sailed through as a
* number and then failed the bound below -- five tests here broke that way when the scale
* landed, and none of them mention the cap in their names.
*/
const root = getComputedStyle(document.documentElement);
const declared = root.getPropertyValue('--card-poster-max-height').trim();
expect(declared, 'the cap is declared in rem, so it scales with the interface')
.to.match(/^[\d.]+rem$/);
const value = parseFloat(declared) * parseFloat(root.fontSize);
expect(value, 'a poster cap is declared, and is small enough to be a cap')
.to.be.within(80, 240);
.to.be.within(80, 300);
return value;
};
@ -1785,6 +1826,62 @@ describe('a card poster is bounded by the card, and keeps its ratio', () => {
.to.be.closeTo(box.height, 0.5);
});
});
it('lets a landscape poster fill a one-column card instead of letterboxing it', () => {
/*
* The cap is a fixed height, so it stops scaling with the card: past 391px of card
* width a 16:9 poster can no longer reach the edges and `justify-content: center`
* splits the remainder into two margins. On a phone -- one column, card the full
* width of the screen -- that is every video thumbnail in the library.
*
* A source wider than the card is the case that matters. 1280px of intrinsic width
* is available, so nothing here is upscaling: the poster is simply allowed to use
* the width it has.
*/
servePoster(1280, 720);
cy.mountUI(wideCard(<CardPoster file={posterFile(1280, 720)}/>));
cy.get('.wrolpi-card-poster img')
.should(($img) => expect($img[0].naturalWidth, 'poster loaded').to.equal(1280))
.should(($img) => {
const img = $img[0];
const box = img.getBoundingClientRect();
const band = img.closest('.wrolpi-card-poster').getBoundingClientRect();
expect(band.width - box.width, 'no empty panel beside the poster')
.to.be.at.most(1);
expect(box.width / box.height, 'and it still keeps its ratio')
.to.be.closeTo(1280 / 720, 0.01);
});
});
it('still keeps a portrait cover to a band on a one-column card', () => {
/*
* The other half, and the reason the cap exists at all: freed of it entirely, a
* 306x396 book cover in a 451px card would stand 451px tall above a two-line title,
* so the media dwarfs the thing it belongs to.
*
* The bound is stated as a fraction of the CARD rather than a pixel count, because
* that is the actual rule -- the poster may be as tall as a 16:9 slice of this card
* and no taller. A portrait cover therefore grows with the card, which it should,
* while never ceasing to be a band.
*/
servePoster(306, 396);
cy.mountUI(wideCard(<CardPoster file={posterFile(306, 396)}/>));
cy.get('.wrolpi-card-poster img')
.should(($img) => expect($img[0].naturalWidth, 'poster loaded').to.equal(306))
.should(($img) => {
const img = $img[0];
const box = img.getBoundingClientRect();
const band = img.closest('.wrolpi-card-poster').getBoundingClientRect();
expect(box.height / band.width, 'the cover stays a band, not a column')
.to.be.at.most(0.58);
expect(box.width / box.height, 'and keeps its ratio')
.to.be.closeTo(306 / 396, 0.01);
});
});
});
describe('a card reads as titles, not as a wall of links', () => {
@ -3469,13 +3566,21 @@ describe('APIButton honours the size its call site asks for', () => {
<APIButton size='small' icon='trash' onClick={() => {}} data-testid='small'/>
<APIButton size='big' icon='trash' onClick={() => {}} data-testid='big'/>
<APIButton size='huge' icon='trash' onClick={() => {}} data-testid='huge'/>
{/* The reference: what `small` is claimed to translate INTO. */}
<APIButton size='sm' icon='trash' onClick={() => {}} data-testid='reference-sm'/>
</div>);
cy.get('[data-testid="huge"]').should(($huge) => {
const height = (id) =>
Cypress.$(`[data-testid="${id}"]`)[0].getBoundingClientRect().height;
expect(height('small'), 'small is the old default').to.be.closeTo(36, 1);
/*
* Measured against a real `sm` button rather than against 36px. The literal was a
* hidden assertion that the interface scale is 1, and it broke at 1.1 -- while the
* claim it is making, that `small` resolves to `sm`, was never about a pixel count.
*/
expect(height('small'), "small resolves to Mantine's sm")
.to.be.closeTo(height('reference-sm'), 0.5);
expect(height('big'), 'big is taller than small').to.be.greaterThan(height('small'));
expect($huge[0].getBoundingClientRect().height, 'huge is taller than big')
.to.be.greaterThan(height('big'));

View file

@ -1291,6 +1291,12 @@ html[data-theme="amber"] .wrolpi-progress-fill {
.wrolpi-card-poster {
display: flex;
justify-content: center;
/*
* The band is the query container for the cap below, so the cap can be a fraction of the
* card's own width. Containment is safe here: the band's width comes from the card, never
* from the image inside it, and it has no fixed-position descendants.
*/
container-type: inline-size;
}
/*
@ -1346,7 +1352,24 @@ html[data-theme="amber"] .wrolpi-progress-fill {
width: auto;
height: auto;
max-width: 100%;
max-height: var(--card-poster-max-height);
/*
* The cap grows with the card, so a landscape poster can reach the card's edges.
*
* A fixed height stops scaling: held to 220px, a 16:9 thumbnail is 391px wide, so any card
* wider than that centred it with empty panel on both sides. That is every video card at
* one column -- a phone, or any window under about 456px of content -- which is where the
* whole library is browsed on a phone. The cases below all used a 233px card, so it went
* unseen for the entire migration.
*
* `56.25cqw` is nine sixteenths of the band: exactly the height of a 16:9 image that fills
* the width, and the same number states the rule for every other ratio -- a poster may be
* as tall as a 16:9 slice of this card and no taller. So a book cover still reads as a
* band rather than a column, and now grows with the card instead of stopping at 220px.
*
* `max()`, not `min()`: on a narrow card the fraction is the smaller value and the floor
* has to win, or the cap would tighten as cards got smaller and crop a portrait cover.
*/
max-height: max(var(--card-poster-max-height), 56.25cqw);
}
.wrolpi-card-icon {