diff --git a/.planning/NOTES.md b/.planning/NOTES.md index 19ca079..030ffcc 100644 --- a/.planning/NOTES.md +++ b/.planning/NOTES.md @@ -2828,3 +2828,77 @@ for boot ok". `pick_device` now resolves `ANDROID_SERIAL` from `ro.boot.qemu.avd_name`, since serials are assigned in boot order and the AVD name is the stable identity. Verified with both emulators running: it selects `yj-test` and installs. + +## The phone shell fits, and what it cost to make it fit (2026-08-16) + +Plan 016 B2, phase 1: the shell below 600px. Measured at 360×780 and +390×844 against the real app (`make dev-headless` + Playwright, which +is the tier that can answer this — server mode serves the same document +an Android WebView renders). + +**What overflowed, and by how much.** The body was 652px wide in a +360px viewport before any of this. Walking every element and its shadow +roots for a `right` past the viewport named the causes in order: + +| element | width | why | +|---|---|---| +| `header.top-bar` | 580 | its children's minimums, summed | +| `search-bar` | 320 | `.search-container { min-width: 200px }` | +| `job-indicator` | 157 | the label, "3 background jobs" | + +A `min-width` in a flex row is a *hard* floor — it does not shrink — and +a grid item's implicit minimum is `auto`, i.e. its content. So the +header could not get smaller than the sum of what it held, the body grew +to the header, and `overflow-x: hidden` would then have hidden a third +of the app rather than fitting it. `min-width: 0` on the boxes between +the viewport and the content, plus each component standing its own +non-essential parts down in its own stylesheet, takes 360 → 360 exactly. +At 320px (400% zoom, the width WCAG 1.4.10 names) it is also exact. + +**So an existing spec now asserts the opposite of what it did**, and +that is the fix landing rather than the test being weakened. +`layout-overflow.spec.ts` used to assert that the 464px of app behind +`overflow: hidden` *could be scrolled to* with a wheel gesture, which +was the remedy available when the shell had one layout. It reflows now, +which is what 1.4.10 asks for; scrolling to the overflow was the +concession. + +**And a shared component brings its test handles with it.** +`bottom-nav`'s "More" opens the *existing* `` in a drawer — +the whole point being not to write a second list of destinations — but +rendering it unconditionally put a second `data-testid="nav-home"` (and +ten siblings) in the DOM. **30 existing specs failed** with "strict mode +violation: resolved to 2 elements", on a *desktop* viewport where +`bottom-nav` is `display: none` and the drawer can never open. Lazy +rendering fixes it; the component test asserts the absence, because the +failure is invisible from inside the component and appears in files +nobody touched. + +Three smaller things worth keeping: + +- **A new icon name is a runtime failure, not a build one.** `bars` was + not in `src/icons/names.txt`, so `offline-icons.spec.ts` caught it — + the sweep asserts `window.__yjIconMisses` is empty. `node + frontend/scripts/fetch-icons.mjs` re-vendors after adding a line. +- **A `wa-drawer` animates, so a test asserts its events**, not its + `open` property: setting `open = false` starts a hide that has not + finished on the next microtask, and a test reading the property in + between sees the state it is leaving. +- **`update(el)` in the component tier takes two arguments** + (`update(el, {})`), which is only visible from `tsc`, not from a + failing test. + +### A pre-existing failure this uncovered but did not cause + +`requested-badge.spec.ts` fails two of its three tests, **on clean +`main` as well** (verified by stashing every change and re-running). +The symptom is `download.Service.AddRequest` not settling in 10s. + +What is now known, and narrows it for whoever picks it up: the same +method with the same arguments, called straight at the runtime endpoint +with `curl`, **returns in 4ms** (it inserted, and answered `2`). So it +is not the backend and not the documented read-pool trap — `Queries` is +built over the writer, and `Reconciler.Trigger` is a non-blocking +select. It is the page-side path: `__yjEvents.call` → the `fetch` hook +→ `/wails/runtime`. A third test in that file fails only *after* those +two, so it is state, not a third bug. diff --git a/.planning/plans/pending/016-android-feature-parity.md b/.planning/plans/pending/016-android-feature-parity.md index 436a80e..58208af 100644 --- a/.planning/plans/pending/016-android-feature-parity.md +++ b/.planning/plans/pending/016-android-feature-parity.md @@ -246,6 +246,13 @@ view's template is two templates to fix every bug in. Where a view cannot serve both, the split belongs at the chunk boundary that already exists. +Phase 1 followed that rule and found its cost: reusing `` +inside the drawer means reusing its `data-testid`s too, and a second +copy standing by in the DOM broke 30 specs that had nothing to do with +the phone. The rule holds — a second list of destinations would be +worse — but a shared component must be rendered only when it is wanted, +and the guard belongs in a test that names the reason. + ## What is worth doing regardless of that decision Cheap, independently useful, and each unblocks measurement: @@ -308,8 +315,14 @@ places had to agree — `abiFilters`, the Makefile's `android:package` anchor is what stops it also matching the fat APK's line. Adding the ABI back, if modernc ever fixes `Xlstat64`, is those same three edits. -**B2, the desktop shell.** The largest remaining piece, and the scope -is now decided — see "The phone gets a subset" below. +**B2, the desktop shell.** Scope decided (below) and **phase 1 is +done**: the shell itself. Below 600px the sidebar column is gone, +`` is the primary navigation, and the shell fits 320px +exactly — measured, from 652px in a 360px viewport before. What is left +is the *views*: a full-screen now-playing (which is where seeking and +volume went), long-press for the context menus that are right-click +today, and the track list's resizable columns, which are a pointer +feature with no touch equivalent. **B3/B4** are unchanged, and B3 is now *possible* where it was not: with all-files access, `tagwriter` can write in place. diff --git a/CLAUDE.md b/CLAUDE.md index 303da19..7e36d58 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1008,6 +1008,43 @@ this app promises, no scrollbar appears. Note that `overflow: hidden` still permits *programmatic* scrolling, so a probe that sets `scrollLeft` passes on the broken build; the spec uses a wheel gesture. +**Below 600px it reflows instead, and that is the phone.** The sideways +scroll above was the concession available while the shell had one +layout; plan 016 B2 gives it a second. Under 600px the grid drops its +sidebar column, `` takes over as the primary navigation, +the header's controls shrink or stand down, and the shell measures +exactly 320px in a 320px viewport — so `layout-overflow.spec.ts` now +asserts *nothing needs scrolling to*, which is what WCAG 1.4.10 wanted +all along. 600 rather than the sidebar's 900 because 900 is a laptop: +the answer there is a narrower sidebar, which is still a sidebar. + +Three rules in it are load-bearing, and the second cost 30 specs. + +**A grid item's implicit minimum is its content**, so one child that +insists on 580px makes the *body* 580px wide inside a 360px viewport +and `overflow-x: hidden` then hides a third of the app rather than +fitting it. Every box between the viewport and the content that must +shrink carries `min-width: 0`, and the things that cannot shrink say so +in their own stylesheet — `search-bar`'s 200px floor, `job-indicator`'s +label, `audio-player`'s seek bar and volume. A media query inside a +shadow root is answered by the viewport, so a component states what it +drops at phone width itself rather than the shell reaching in. + +**A duplicated component duplicates its handles.** `bottom-nav`'s +"More" opens the *same* `` in a `wa-drawer` rather than +listing the destinations again — but rendering it unconditionally put a +second copy of every `data-testid="nav-*"` in the DOM, and 30 existing +specs failed with "strict mode violation: resolved to 2 elements" on a +desktop viewport where the element is not even visible. It renders only +while the drawer is open, and `bottom-nav.test.ts` asserts its absence +before that. + +**The tab bar is four destinations and a way to the rest.** Three to +five is where touch targets stop being thumb-sized; eleven over 360px +is 32px each. Which four is plan 016's committed subset, and everything +else — Settings included, because a phone still needs it — is behind +"More". + **The playing row is a shape, not a hue.** `track-list` and `queue-panel` draw a `::before` triangle in each row's own left padding, plus `aria-current` — before, both rows were a background tint diff --git a/e2e/specs/layout-overflow.spec.ts b/e2e/specs/layout-overflow.spec.ts index 5bfbaff..f5cd20a 100644 --- a/e2e/specs/layout-overflow.spec.ts +++ b/e2e/specs/layout-overflow.spec.ts @@ -199,35 +199,39 @@ test.describe('the shell reflows rather than hiding what does not fit', () => { }); }); - test('what does not fit sideways can be scrolled to', async ({ app }) => { + test('nothing needs scrolling to at 320px, because it all fits', async ({ app }) => { // 320 CSS px is 400% page zoom of a 1280px viewport, which is the - // size 1.4.10 names. The shell is 784px wide there, so 464px of the - // app — the job indicator and the queue button among it — used to - // be behind `overflow: hidden` with no way to reach it. + // size 1.4.10 names. + // + // **This assertion is the inverse of the one it replaces, and that + // is the fix landing rather than the test being weakened.** The + // shell used to be 784px wide here, so 464px of the app — the job + // indicator and the queue button among it — sat behind + // `overflow: hidden` with no way to reach it; making the axis + // scrollable was the remedy available at the time. 016 B2's phone + // layout reflows instead: below 600px the sidebar becomes a bottom + // tab bar, the header's controls shrink, and the shell measures + // exactly 320px in a 320px viewport. Reflow is what 1.4.10 asks + // for; being able to scroll to the overflow was the concession. await app.setViewportSize({ width: 320, height: 256 }); - // A *gesture*, not `scrollLeft = 9999`: `overflow: hidden` still - // permits programmatic scrolling, so the obvious probe passes on - // the build that has the bug. It did, first time. - await app.mouse.move(160, 20); - await app.mouse.wheel(400, 400); - await app.waitForTimeout(200); - - const reach = await app.evaluate(() => { + const fit = await app.evaluate(() => { const se = document.scrollingElement!; - return { left: se.scrollLeft, top: se.scrollTop }; + return { + scrollWidth: se.scrollWidth, + clientWidth: document.documentElement.clientWidth, + scrollHeight: se.scrollHeight, + clientHeight: document.documentElement.clientHeight, + }; }); - expect(reach.left).toBeGreaterThan(0); + expect(fit.scrollWidth).toBeLessThanOrEqual(fit.clientWidth); // And the vertical axis stays fixed, which is what keeps the - // transport where a desktop player's transport belongs. - expect(reach.top).toBe(0); + // transport where a player's transport belongs. + expect(fit.scrollHeight).toBeLessThanOrEqual(fit.clientHeight); - await app.evaluate(() => { - document.scrollingElement!.scrollLeft = 0; - }); await app.setViewportSize({ width: 1440, height: 900 }); }); diff --git a/e2e/specs/phone-shell.spec.ts b/e2e/specs/phone-shell.spec.ts new file mode 100644 index 0000000..d921cce --- /dev/null +++ b/e2e/specs/phone-shell.spec.ts @@ -0,0 +1,135 @@ +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('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(); + }); +}); diff --git a/frontend/index.css b/frontend/index.css index 63adc99..994e41d 100644 --- a/frontend/index.css +++ b/frontend/index.css @@ -41,6 +41,90 @@ body { overflow-y: hidden; } +/* --------------------------------------------------------------- + The phone shell (plan 016 B2). + + 600px, not the sidebar's 900: 900 is a *laptop* and the response to + it is a narrower sidebar, which is still a sidebar. Below 600 there + is no room for one at all -- 360px of viewport over a 200px nav is + not a layout -- so the navigation moves to the bottom, where a thumb + is, and the eleven-item list moves into `bottom-nav`'s drawer. + + The grid loses its sidebar column rather than hiding the element in + place: a named area with nothing in it still reserves its track. + --------------------------------------------------------------- */ +@media (max-width: 599px) { + body { + grid-template: + "top-bar" 3.25em + "main-panel" 1fr + "bottom-bar" auto + "bottom-nav" auto + / 1fr; + /* Nothing may scroll sideways here. On a desktop the shell is + allowed to overflow a zoomed-in window (a11y.21 above); a + phone *is* the small viewport, so the shell has to fit it. */ + overflow-x: hidden; + } + + body div.sidebar { + display: none; + } + + bottom-nav { + grid-area: bottom-nav; + } + + /* The 2em gutters are half a thumb each at this width, and the + subtitle is already gone from 900 down. + + `min-width: 0` is the load-bearing half. A grid item's implicit + minimum is `auto` -- its content -- so a header whose children + ask for 580px makes the *body* 580px wide inside a 360px + viewport, and `overflow-x: hidden` then hides the right-hand + third of the app rather than fitting it. Every box between the + viewport and the content that must shrink needs this. */ + .top-bar { + padding-left: 0.75em; + padding-right: 0.75em; + gap: 0.5em; + min-width: 0; + overflow: hidden; + } + + .content-area, + .main-panel, + .bottom-bar { + min-width: 0; + } + + .title { + font-size: 1.1em; + } + + /* The search box is the one header control worth its width; the + library filter is a rarely-changed setting and reachable from + the drawer's Settings. */ + .top-bar library-filter { + display: none; + } + + .top-bar search-bar { + flex: 1 1 auto; + min-width: 0; + } +} + +/* Above the phone breakpoint the tab bar does not exist. It is in the + markup unconditionally and eagerly, for the reason notification-host + is: navigation that has to fetch a chunk before it can navigate is + not navigation. */ +@media (min-width: 600px) { + bottom-nav { + display: none; + } +} + p { margin: 0; /* I want to set paragraph margins myself */ @@ -130,6 +214,21 @@ body div.sidebar { contain: layout style paint; } +/* The transport at phone width: art, title and the controls, with the + seek bar and volume dropped by the components that own them. The + desktop's three fixed columns start with a 320px now-playing, which + at 360px of viewport leaves the controls 40px. */ +@media (max-width: 599px) { + .bottom-bar { + grid-template-columns: minmax(0, 1fr) auto auto; + gap: 0.25em; + } + + .bottom-bar audio-player { + margin: 0.25em; + } +} + .bottom-bar { grid-area: bottom-bar; padding: 0.25em; diff --git a/frontend/index.html b/frontend/index.html index 86cb586..7af95aa 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -41,6 +41,13 @@ + + diff --git a/frontend/index.ts b/frontend/index.ts index 095ca2b..1490b63 100644 --- a/frontend/index.ts +++ b/frontend/index.ts @@ -21,6 +21,7 @@ import '@components/audio-player/audio-player.ts'; import '@components/track-list/track-list.ts'; import '@components/now-playing/now-playing.ts'; import '@components/sidebar/app-sidebar.ts'; +import '@components/bottom-nav/bottom-nav.ts'; import '@components/queue-panel/queue-panel.ts'; import '@components/search-bar/search-bar.ts'; import '@components/library-filter/library-filter.ts'; diff --git a/frontend/src/assets/icons/fa/solid/bars.svg b/frontend/src/assets/icons/fa/solid/bars.svg new file mode 100644 index 0000000..8cb740d --- /dev/null +++ b/frontend/src/assets/icons/fa/solid/bars.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/src/components/audio-player/audio-player.ts b/frontend/src/components/audio-player/audio-player.ts index bb5192b..f05bbf5 100644 --- a/frontend/src/components/audio-player/audio-player.ts +++ b/frontend/src/components/audio-player/audio-player.ts @@ -32,6 +32,23 @@ export class AudioPlayer extends LitElement { flex: 1; } + /* The phone transport (plan 016 B2): the buttons, and nothing + else. A media query inside a shadow root is answered by the + viewport, not by the host, so this is the component saying what + it drops at phone width rather than the shell reaching in. + + Volume goes because the hardware keys own it on a phone -- + Android routes them to the media stream, which is also why + mediacontrols' Android handler implements no volume callback. + The seek bar goes because a 4px-tall target dragged with a thumb + is not a seek control; seeking belongs to the full-screen + now-playing view, which is the next phase. */ + @media (max-width: 599px) { + volume-control, + seek-bar { + display: none; + } + } `]; override render() { diff --git a/frontend/src/components/bottom-nav/bottom-nav.ts b/frontend/src/components/bottom-nav/bottom-nav.ts new file mode 100644 index 0000000..b7fc825 --- /dev/null +++ b/frontend/src/components/bottom-nav/bottom-nav.ts @@ -0,0 +1,253 @@ +import { LitElement, html, css, nothing } from 'lit'; +import { customElement, state, query } from 'lit/decorators.js'; +import '@awesome.me/webawesome/dist/components/icon/icon.js'; +import '@awesome.me/webawesome/dist/components/drawer/drawer.js'; +import type WaDrawer from '@awesome.me/webawesome/dist/components/drawer/drawer.js'; +import { designTokens } from '../../styles/tokens.css'; +import '../sidebar/app-sidebar.js'; +import { nameDialog } from '@utils/name-dialog'; + +type View = 'home' | 'albums' | 'tracks' | 'playlists'; + +interface Tab { + id: View; + label: string; + icon: string; +} + +/** + * The phone's primary navigation: a bottom tab bar, shown only below + * the phone breakpoint (index.css owns that; this element is + * `display: none` above it). + * + * **Four destinations and a way to everything else.** A tab bar is + * three to five items before the targets stop being thumb-sized — + * 360 px over eleven sidebar entries is 32 px each — so the four here + * are the ones plan 016's subset says a phone is *for*, and "More" + * opens the existing `` in a drawer. That is deliberately + * a reuse rather than a second nav: two lists of destinations is two + * places to add the next view to, and the sidebar already carries the + * drag-to-navigate behaviour, the active state and the labels. + * + * It emits the same bubbling, composed `navigate` event the sidebar + * does, so `index.ts` needs no knowledge of it, and it listens for that + * event globally for the same reason the sidebar does: a navigation it + * did not send (a card click, a detail view, the drawer) still has to + * move the highlight. + */ +@customElement('bottom-nav') +export class BottomNav extends LitElement { + static override styles = [designTokens, css` + :host { + display: block; + background-color: var(--yj-bg-elevated, #343a40); + border-top: 1px solid var(--yj-border, #495057); + /* The home indicator on a gesture-navigation phone sits + under the last few pixels of the viewport, so the bar + pads itself out of the way where the browser reports + one and by nothing where it does not. */ + padding-bottom: env(safe-area-inset-bottom, 0); + } + + nav ul { + display: grid; + grid-auto-flow: column; + grid-auto-columns: 1fr; + margin: 0; + padding: 0; + list-style: none; + } + + button { + width: 100%; + /* 48px is the smallest target this should ever be; the + label sits under the icon rather than beside it, which + is what keeps five of them legible at 360px. */ + min-height: 48px; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 2px; + padding: 4px 0; + background: none; + border: none; + color: var(--yj-text-secondary, #adb5bd); + cursor: pointer; + font-family: inherit; + font-size: var(--yj-font-size-xs, 0.7rem); + } + + button wa-icon { + font-size: 1.15rem; + } + + button.active { + color: var(--yj-accent, #ffd43b); + } + + button:focus-visible { + outline: 2px solid var(--yj-accent, #ffd43b); + outline-offset: -2px; + } + + .label { + /* A tab label is an aid, not the name: the button's own + accessible name comes from its text, and truncating it + visually does not change that. */ + max-width: 100%; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + wa-drawer::part(body) { + padding: 0; + } + + app-sidebar { + /* The sidebar sizes itself inline and collapses to icons + below 900px, which is every phone. In the drawer there + is room for the labels, so it is told not to. */ + height: 100%; + } + `]; + + @state() + private activeView = 'home'; + + /** + * Whether the drawer has been asked for. + * + * The sidebar inside it is rendered only while this is true, and + * that is not an optimisation. `app-sidebar` carries a + * `data-testid` per destination, so a second copy standing by in + * the DOM makes every `nav-*` testid ambiguous **for the whole + * app** -- 30 existing specs failed with "strict mode violation: + * resolved to 2 elements" on a desktop viewport where this element + * is not even visible. A duplicate of a shared component is a + * duplicate of its handles. + */ + @state() + private drawerOpen = false; + + @query('wa-drawer') + private drawer?: WaDrawer; + + private static readonly TABS: Tab[] = [ + { id: 'home', label: 'Home', icon: 'house' }, + { id: 'albums', label: 'Albums', icon: 'compact-disc' }, + { id: 'tracks', label: 'Tracks', icon: 'music' }, + { id: 'playlists', label: 'Playlists', icon: 'list' }, + ]; + + override connectedCallback() { + super.connectedCallback(); + document.addEventListener( + 'navigate', + this.onGlobalNavigate as EventListener, + ); + } + + override disconnectedCallback() { + super.disconnectedCallback(); + document.removeEventListener( + 'navigate', + this.onGlobalNavigate as EventListener, + ); + } + + override updated() { + // Web Awesome renders its heading into its own shadow root and + // never points aria-labelledby at it, so the drawer would + // otherwise be announced unnamed -- the same fix, and the same + // reason, as every wa-dialog in the app. A drawer's shadow root + // has the same shape, so the helper needs no change. + nameDialog(this.drawer); + } + + private onGlobalNavigate = (e: Event) => { + const detail = (e as CustomEvent<{ view?: string }>).detail; + + if (detail?.view) this.activeView = detail.view; + + // A navigation from inside the drawer is the drawer's job done. + this.drawerOpen = false; + }; + + private openDrawer = () => { + this.drawerOpen = true; + }; + + /** + * Web Awesome closes itself on Escape and on a click outside, and + * tells us afterwards rather than asking -- so the flag follows the + * element, or the next `open` would be a no-op against a drawer + * that thinks it is already open. + */ + private onDrawerHide = () => { + this.drawerOpen = false; + }; + + private navigate(view: View) { + this.dispatchEvent(new CustomEvent('navigate', { + detail: { view }, + bubbles: true, + composed: true, + })); + } + + override render() { + return html` + + + + ${this.drawerOpen + ? html`` + : nothing} + + `; + } +} + +declare global { + interface HTMLElementTagNameMap { + 'bottom-nav': BottomNav; + } +} diff --git a/frontend/src/components/jobs/job-indicator.ts b/frontend/src/components/jobs/job-indicator.ts index 115868b..0791b04 100644 --- a/frontend/src/components/jobs/job-indicator.ts +++ b/frontend/src/components/jobs/job-indicator.ts @@ -149,6 +149,19 @@ export class JobIndicator extends LitElement { text-overflow: ellipsis; } + /* On a phone the ring is the whole indicator: "3 background + jobs" is 114px of a 360px header, and it pushed the + header past the viewport. Only the *visible* label + goes -- the live region in render() is what announces + this, and it is unaffected, so the ring keeps its + accessible name and screen readers keep hearing the + state change. */ + @media (max-width: 599px) { + .label { + display: none; + } + } + .alert-dot { width: 6px; height: 6px; diff --git a/frontend/src/components/search-bar/search-bar.ts b/frontend/src/components/search-bar/search-bar.ts index deaea42..c0b0ab9 100644 --- a/frontend/src/components/search-bar/search-bar.ts +++ b/frontend/src/components/search-bar/search-bar.ts @@ -67,6 +67,21 @@ export class SearchBar extends LitElement { transition: border-color 0.15s ease; } + /* The 200px floor is a desktop floor. On a phone the header is + the whole width there is, and a min-width in a flex row is a + *hard* one -- it does not shrink, so the header stayed 580px + wide inside a 360px viewport and the shell scrolled + sideways. Measured at 360px: 580 -> 360. */ + @media (max-width: 599px) { + :host { + min-width: 0; + } + + .search-container { + min-width: 0; + } + } + .search-container:focus-within { border-color: var(--yj-accent, #ffd43b); } diff --git a/frontend/src/components/sidebar/app-sidebar.ts b/frontend/src/components/sidebar/app-sidebar.ts index dcd55d7..5a185e7 100644 --- a/frontend/src/components/sidebar/app-sidebar.ts +++ b/frontend/src/components/sidebar/app-sidebar.ts @@ -1,5 +1,5 @@ import { LitElement, html, css } from 'lit'; -import { customElement, state } from 'lit/decorators.js'; +import { customElement, state, property } from 'lit/decorators.js'; import '@awesome.me/webawesome/dist/components/icon/icon.js'; import { designTokens } from '../../styles/tokens.css'; @@ -166,6 +166,17 @@ export class AppSidebar extends LitElement { @state() private collapsed = false; + /** + * Keep the labels regardless of the viewport, for a host that has + * made room for them -- `bottom-nav`'s drawer, which is the whole + * screen wide on the phone where this would otherwise auto-collapse + * to icons. The auto-collapse is a *width* response to a narrow + * shell, and inside a drawer the shell is not what the sidebar is + * sharing space with. + */ + @property({ type: Boolean, reflect: true }) + expanded = false; + /** The width the user chose, restored when the window grows back. */ private userWidth = DEFAULT_WIDTH; @@ -344,7 +355,8 @@ export class AppSidebar extends LitElement { */ private applyViewportWidth() { const narrow = - this.narrowViewport?.matches ?? false; + !this.expanded && + (this.narrowViewport?.matches ?? false); const width = narrow ? MIN_WIDTH : this.userWidth; diff --git a/frontend/src/icons/names.txt b/frontend/src/icons/names.txt index b07fb7b..f68ea52 100644 --- a/frontend/src/icons/names.txt +++ b/frontend/src/icons/names.txt @@ -22,6 +22,7 @@ solid/arrow-rotate-right solid/arrows-rotate solid/arrow-up-short-wide solid/backward-step +solid/bars regular/bookmark solid/bookmark solid/box-open diff --git a/frontend/test/components/bottom-nav.test.ts b/frontend/test/components/bottom-nav.test.ts new file mode 100644 index 0000000..1949c7f --- /dev/null +++ b/frontend/test/components/bottom-nav.test.ts @@ -0,0 +1,165 @@ +/** + * The phone's primary navigation (plan 016 B2). + * + * Three of these are about the thing that makes a second nav dangerous: + * it has to agree with the first one. `bottom-nav` emits the same + * bubbling, composed `navigate` event `app-sidebar` does and listens + * for that event globally, so a navigation from anywhere — a card, a + * detail view, the drawer's own sidebar — moves its highlight too. A + * tab bar that only tracks its own clicks looks right until the moment + * the user arrives somewhere by another route. + */ +import { describe, expect, it, beforeEach } from 'vitest'; + +import '@components/bottom-nav/bottom-nav'; +import type { BottomNav } from '@components/bottom-nav/bottom-nav'; +import { fixture, shadow, shadowAll, update } from '@test/support/render'; +import { resetHarness } from '@test/support/harness'; + +type Nav = BottomNav; + +const tabs = (el: HTMLElement) => + shadowAll(el, 'nav button'); + +/** Resolve on one occurrence of an event, or reject loudly on time. */ +const once = (el: Element, name: string, timeoutMs = 2000) => + new Promise((resolve, reject) => { + const timer = setTimeout( + () => reject(new Error(`${name} never fired`)), + timeoutMs, + ); + + el.addEventListener(name, () => { + clearTimeout(timer); + resolve(); + }, { once: true }); + }); + +describe('bottom-nav', () => { + beforeEach(() => { + resetHarness(); + }); + + it('offers the four phone destinations and a way to the rest', async () => { + const el = await fixture