From 7d602f7ea8a088e32190ea677bddc42519f750b9 Mon Sep 17 00:00:00 2001 From: Roland Date: Mon, 3 Aug 2026 15:02:15 -0600 Subject: [PATCH] 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) --- app/src/components/ui/ui-layout.cy.js | 115 ++++++++++++++++++++++++-- app/src/components/ui/ui.css | 25 +++++- 2 files changed, 134 insertions(+), 6 deletions(-) diff --git a/app/src/components/ui/ui-layout.cy.js b/app/src/components/ui/ui-layout.cy.js index 1287328b..c245e056 100644 --- a/app/src/components/ui/ui-layout.cy.js +++ b/app/src/components/ui/ui-layout.cy.js @@ -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) =>
{children}
; + /* + * "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) => +
{children}
; const sixStatistics = @@ -1709,6 +1730,16 @@ const servePoster = (width, height) => cy.intercept( const narrowCard = (children) =>
{children}
; +/* + * 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) => +
{children}
; + /* * 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()); + + 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()); + + 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', () => { {}} data-testid='small'/> {}} data-testid='big'/> {}} data-testid='huge'/> + {/* The reference: what `small` is claimed to translate INTO. */} + {}} data-testid='reference-sm'/> ); 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')); diff --git a/app/src/components/ui/ui.css b/app/src/components/ui/ui.css index b0ccbc11..9c2852a6 100644 --- a/app/src/components/ui/ui.css +++ b/app/src/components/ui/ui.css @@ -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 {