Files
yellowjacket/e2e/specs/phone-shell.spec.ts
T
logan dc8db159f9
CI / check (push) Skipped
CI / e2e (push) Skipped
CI / check (pull_request) Successful in 2m32s
CI / e2e (pull_request) Successful in 8m18s
feat(player): centre the transport and show the volume inline
Two issues over one bar, because they are one relayout. #42's own
findings say so: giving wa-slider a label grows it 6px to 14px and
moves the transport, which is #23's subject, so doing them in sequence
means measuring the bar twice and throwing the first set away.

The bar was `320px 1fr auto`, so the transport sat in the middle of
what the metadata and the queue button did not use — its centre was
~140px right of the window's at every width. The outer two tracks are
the same expression now, so the middle is centred by construction.

The side width is the metadata's, capped at a quarter of the bar, and
the cap was measured as a regression before it was a decision:
reserving the full `--now-playing-width` on both sides is perfectly
centred and takes the seek bar's track from 257px to 61px at 800px, and
to 0 at 200% text. The control you drag was paying for the symmetry.
With the cap it is 246, which is parity. It is a `min()` rather than a
breakpoint because that variable is user state — the metadata has a
drag handle — and tying both sides to it is also what keeps dragging
meaningful; a plain `1fr … 1fr` centres just as well and silently makes
the handle a no-op.

The volume moved out of `audio-player` into the bar because the
transport column has to hold the transport and nothing else, and it
joins the queue button in one cell rather than a second column, since
the centring compares columns.

It is a slider by default and a popup by setting. The stored flag names
the *popup*, which is this config's polarity rule — the zero value has
to be the intended answer, so an existing config.toml gets the new
default with no migration. Inline, the icon is the mute toggle and is
named after that action rather than the state, because with the slider
beside it there is nothing to disclose; the component tier now covers
both presentations rather than whichever is default.

Three nested rules in this block began with a bare element selector,
which Chrome 120 relaxed and the phone's Chrome 113 **silently drops** —
including the ellipsis on the bar's own title and artist, which has
therefore never truncated on the device. They are `&`-prefixed now.
Filed as #154 for the class and for a check.

`bottom-bar.spec.ts` pins both halves separately on purpose: an
uncapped build is perfectly centred and fails only the seek-bar width,
so a spec asserting centring alone would have passed the regression
above. Both were verified by mutation.

Closes #23
Closes #42
2026-08-20 00:40:52 -04:00

188 lines
7.4 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { test, expect } from '../support/fixtures.js';
/**
* The phone shell (plan 016 B2, phase 1).
*
* This is the tier that can actually answer the question. Wails v3's
* server mode serves the real frontend, so a Chromium at 390×844 is the
* same document an Android WebView renders — the only thing a device
* adds here is the WebView's own quirks, and CI runs the WebKit half
* for exactly that reason.
*
* The assertions are the three things B2 is *for*: the eleven-item
* sidebar is gone, the four destinations plan 016 committed to are
* reachable with a thumb, and nothing scrolls sideways. The last one is
* the one that hides: `overflow-x: auto` on `body` means a shell that
* does not fit produces a scrollbar rather than a broken layout, which
* looks survivable in a screenshot and is not.
*/
/** A common small phone. Narrower than any device this is likely to meet. */
const PHONE = { width: 390, height: 844 };
/** The narrowest thing still sold, near enough. */
const SMALL_PHONE = { width: 360, height: 780 };
const horizontalOverflow = (page: import('@playwright/test').Page) =>
page.evaluate(() => ({
scrollWidth: document.body.scrollWidth,
clientWidth: document.body.clientWidth,
}));
test.describe('the shell on a phone', () => {
test.beforeEach(async ({ app }) => {
await app.setViewportSize(PHONE);
});
test('replaces the sidebar with a bottom tab bar', async ({ app }) => {
await expect(app.locator('div.sidebar')).toBeHidden();
const nav = app.locator('bottom-nav');
await expect(nav).toBeVisible();
// Four tabs and a way to everything else, which is the shape the
// plan argues for: a tab bar is 3-5 items before the targets stop
// being thumb-sized.
for (const id of ['home', 'albums', 'tracks', 'playlists', 'more']) {
await expect(app.getByTestId(`tab-${id}`)).toBeVisible();
}
});
test('navigates from a tab', async ({ app }) => {
await app.getByTestId('tab-albums').click();
await expect(app.getByTestId('main-content'))
.toHaveAttribute('data-active-view', 'albums');
await app.getByTestId('tab-home').click();
await expect(app.getByTestId('main-content'))
.toHaveAttribute('data-active-view', 'home');
});
test('reaches the views with no tab through the drawer', async ({ app }) => {
await app.getByTestId('tab-more').click();
// Scoped to the drawer: the desktop sidebar is still in the DOM
// (hidden by the media query, not removed), so an unscoped testid
// matches two elements and Playwright's strict mode refuses --
// which is the right complaint, since the two really are different
// buttons.
//
// The drawer holds the *same* sidebar the desktop uses, so Settings
// -- which a phone still needs occasionally -- is reachable without
// a second list of destinations to keep in step.
const settings = app
.getByTestId('nav-drawer')
.getByTestId('nav-settings');
await expect(settings).toBeVisible();
await settings.click();
await expect(app.getByTestId('main-content'))
.toHaveAttribute('data-active-view', 'settings');
// And the drawer gets out of the way once it has done its job.
await expect(app.getByTestId('nav-drawer')).toBeHidden();
});
test('has a named drawer', async ({ app }) => {
await app.getByTestId('tab-more').click();
// The a11y snapshot never prints a dialog's name, so this asks for
// the role and the name together -- which is the check that caught
// eleven unnamed dialogs.
await expect(
app.getByRole('dialog', { name: 'All views' }),
).toBeVisible();
});
for (const vp of [PHONE, SMALL_PHONE]) {
test(`does not scroll sideways at ${vp.width}×${vp.height}`, async ({ app }) => {
await app.setViewportSize(vp);
await app.getByTestId('tab-tracks').click();
await expect(app.getByTestId('main-content'))
.toHaveAttribute('data-active-view', 'tracks');
const { scrollWidth, clientWidth } = await horizontalOverflow(app);
expect(scrollWidth, `body overflows by ${scrollWidth - clientWidth}px`)
.toBeLessThanOrEqual(clientWidth);
});
}
test('opens the full-screen now playing, and comes back', async ({ app }) => {
// Something has to be playing for the mini player to be a way in.
await app.getByTestId('tab-tracks').click();
await expect(app.getByTestId('main-content'))
.toHaveAttribute('data-active-view', 'tracks');
await app.locator('track-list .track-row').first().dblclick();
await expect(app.getByTestId('now-playing-title')).not.toBeEmpty();
await app.getByTestId('open-now-playing').click();
await expect(app.getByTestId('main-content'))
.toHaveAttribute('data-active-view', 'now-playing');
// The seek bar and volume that phase 1 took out of the bottom bar
// are here, and they are the *same* components -- this view
// composes the transport rather than reimplementing it.
await expect(app.locator('now-playing-view seek-bar')).toBeVisible();
await expect(app.locator('now-playing-view volume-control')).toBeVisible();
// Back goes where the user came from, through the nav stack.
await app.getByTestId('npv-back').click();
await expect(app.getByTestId('main-content'))
.toHaveAttribute('data-active-view', 'tracks');
});
test('offers no way in on a desktop, where the bar is whole', async ({ app }) => {
await app.setViewportSize({ width: 1440, height: 900 });
// The button exists in the markup at every size; CSS decides. If
// this becomes visible on a desktop it is a 48px hit target over
// the cover art, swallowing the clicks that open the preview.
await expect(app.getByTestId('open-now-playing')).toBeHidden();
});
test('keeps the transport, minus what a thumb cannot use', async ({ app }) => {
// The player bar stays: this is a music player, and what is playing
// has to be visible and pausable from every view.
await expect(app.locator('audio-player')).toBeVisible();
await expect(app.locator('now-playing')).toBeVisible();
// Volume is the hardware keys' job on a phone, and a 4px seek bar
// is not a thumb target -- both belong to the full-screen
// now-playing view.
//
// `.bottom-bar volume-control`, not `audio-player volume-control`:
// #42 moved the control out of that component and into the bar, and
// **the old locator would have kept passing** — `toBeHidden()` is
// satisfied by an element that does not exist, so this assertion
// would have gone on reporting success about nothing. Its partner
// below is what makes this one mean something.
await expect(app.locator('.bottom-bar volume-control')).toBeHidden();
// The element is there and hidden, rather than absent: the check
// above cannot tell those apart on its own.
await expect(app.locator('.bottom-bar volume-control')).toHaveCount(1);
// And the seek bar is still inside the transport, where it stands
// down by its own media query.
await expect(
app.locator('audio-player').locator('seek-bar'),
).toBeHidden();
});
});
test.describe('the desktop shell is unchanged', () => {
test('keeps the sidebar and hides the tab bar', async ({ app }) => {
await app.setViewportSize({ width: 1440, height: 900 });
await expect(app.locator('div.sidebar')).toBeVisible();
await expect(app.locator('bottom-nav')).toBeHidden();
});
});