diff --git a/app/src/App.css b/app/src/App.css index b768c854..28ad04d2 100644 --- a/app/src/App.css +++ b/app/src/App.css @@ -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; } diff --git a/app/src/DashboardPage.js b/app/src/DashboardPage.js index 3f60cd90..cdccb3ea 100644 --- a/app/src/DashboardPage.js +++ b/app/src/DashboardPage.js @@ -238,15 +238,19 @@ export function Getters() { } const getter = - - - - - +
+
+ +
+
+ +
+ Or +
; let getterModal; diff --git a/app/src/components/ThemeSamplePage.js b/app/src/components/ThemeSamplePage.js index 9c587644..2cb49950 100644 --- a/app/src/components/ThemeSamplePage.js +++ b/app/src/components/ThemeSamplePage.js @@ -366,6 +366,24 @@ export function ThemeSamplePage() { +
+ + {/* 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. */} +
+
+
+ Or +
+
+

+ The disc masks the rule behind it and is painted with --panel, 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. +

+
+
{/* diff --git a/app/src/components/VideoPlayer.js b/app/src/components/VideoPlayer.js index 3e25a547..ca360d0b 100644 --- a/app/src/components/VideoPlayer.js +++ b/app/src/components/VideoPlayer.js @@ -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. */} -
+
{title}
diff --git a/app/src/components/ui/ui-layout.cy.js b/app/src/components/ui/ui-layout.cy.js index 00776d7a..729a887c 100644 --- a/app/src/components/ui/ui-layout.cy.js +++ b/app/src/components/ui/ui-layout.cy.js @@ -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(
+ +
+
+
+ Or +
+
+
)); + + 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(
+
); + + 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(<>Surface
12:34
, + {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(<>Card
12:34
, + {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(poster, diff --git a/app/src/components/ui/ui.css b/app/src/components/ui/ui.css index 645a1360..656ee173 100644 --- a/app/src/components/ui/ui.css +++ b/app/src/components/ui/ui.css @@ -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 {