Plan 016 B2, phase 2. Phase 1 took the seek bar and the volume out of the phone's bottom bar -- 4px of height is not a thumb target, and a phone's volume belongs to its hardware keys -- and promised them a full-screen view. This is it, reached from a button over the mini player's cover art. **It composes the transport rather than reimplementing it.** The same `seek-bar`, `player-controls` and `volume-control` the desktop bar uses; a phone layout that copies them is a second transport to fix every bug in, and the seek bar in particular carries interpolation rules that took a plan of their own to get right. The seek bar thickens its own track below the breakpoint, in its own stylesheet, because the track size lives on a wa-slider inside its shadow root where a custom property from the host cannot reach. **It is a detail view, not a primary one.** It is somewhere you go and come back from, so index.ts pushes the current view and Back pops it -- which is also why it is not a fifth tab: a tab you cannot leave by pressing it again is not a tab. Two things came from reading a screenshot rather than from a failing test, and both were invisible to assertions that were individually correct. **The mini player was still under the full-screen view**, repeating it in 4em of an 844px phone. index.css hides the bottom bar while `#main-content[data-active-view="now-playing"]`, through `:has()` rather than a class toggled from index.ts, because the active view is already published as an attribute. That takes the queue button with it, so the view carries its own. **And phase 1's shell rules had never applied.** A media query adds no specificity, and the phone block sat above the plain rules it meant to override, so at 390px the header kept its 2em gutters (32px), its 16px gap and its 24px title, and the bottom bar kept a fixed 320px first column. Nothing failed: the shell fits because of `min-width: 0` and each component's own media query, which live in their own stylesheets and have no later rule to lose to -- so what was dead was exactly the cosmetic half no assertion looks at. The phone rules are one section at the end of the file now, and it says why it is last. Measured after: 12px, 8px, 17.6px, `154px 187px 33px`.
171 lines
6.6 KiB
TypeScript
171 lines
6.6 KiB
TypeScript
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 a later phase's
|
||
// full-screen now-playing view.
|
||
await expect(app.locator('audio-player volume-control')).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();
|
||
});
|
||
});
|