Compare commits

...
Author SHA1 Message Date
logan 52d095e3c6 feat(android): the phone's "More" is a bottom sheet
CI / e2e (push) Skipped
CI / check (push) Skipped
CI / check (pull_request) Successful in 2m47s
CI / e2e (pull_request) Successful in 10m14s
The tab bar's fifth item opened `<app-sidebar>` in a `wa-drawer`
sliding in from the side, which is a desktop shape put on a phone: a
200px column of a 424px screen, opening away from the thumb that asked
for it, with the rest of its 400px band empty. It also had three nested
scrollers in it -- the dialog, its body, and the sidebar's own
`overflow-y: auto` host -- so which box a drag moved depended on where
the finger landed, which is the "only part of the screen scrolls under
my finger" in the report.

It is the same element with `placement="bottom"` and `without-header`,
so the surface is the sheet #60 already built rather than a second
pattern: a `wa-drawer` is a native `<dialog>` opened with `showModal()`,
which is exactly the top layer that finding rests on, so the focus
trap, Escape, tap-outside and `wa-after-hide` come along unchanged and
nothing new has to be proved about paint containment.

The sidebar is still mounted rather than re-listed as data, because the
shell's own copy is `display: none` below 600px rather than removed --
a second list drawing `nav-*` handles is the duplicate-testid failure
this component already renders conditionally to avoid. What `expanded`
means had to grow to say the host owns the *box*: `app-sidebar` writes
an inline width and caps itself at 400px, which beats any rule the host
could write, so the width, the scrolling and the mouse-only resize
handle now follow that attribute. The rows are 48px below 600px, stated
in the sidebar's own stylesheet since that is the only place it renders
there.

Measured in the running app at 424x439: the sheet is 424 wide, 373 tall
(85vh, so there is an outside to tap), rows 48px, one scroller with
`overscroll-behavior: contain`, and Settings' row reachable at the end
of it. Desktop and Compact are untouched.

Closes #71
2026-08-25 13:48:51 -04:00
logan 939915b1fa Merge pull request 'feat(android): the tap highlight goes, a press state replaces it' (#214) from feat/54-native-touch-feel into main
CI / check (push) Successful in 2m58s
CI / e2e (push) Canceled after 0s
2026-08-25 17:48:45 +00:00
logan 3aa2a434b4 feat(android): the tap highlight goes, a press state replaces it
CI / check (push) Skipped
CI / e2e (push) Skipped
CI / check (pull_request) Successful in 2m44s
CI / e2e (pull_request) Successful in 10m10s
The phone drew a grey box over the bounding rect of whatever was
tapped, which is the web view saying what it is. It is gone in one
declaration: `-webkit-tap-highlight-color` is inherited and an
inherited property crosses a shadow boundary, so `html` in index.css
reaches every shadow root in the app. Measured three roots deep,
rgba(0, 0, 0, 0.18) before and rgba(0, 0, 0, 0) after.

Removing it removes the only touch feedback several surfaces had, so
the press state is part of the same change rather than a later polish
item — with the highlight gone a held row measured the *hover* tint,
which on a phone is synthesised by the hold itself and outlives it.
The four lists' rows, the tab bar, the sidebar's destinations and the
shared context-menu item take --yj-press-overlay on :active; the cards
already had scale(0.97). The press selector carries a state class
because a row is .track-row.selected.active, so a bare :active shows
nothing on the row a phone is most likely to press. And those
surfaces' hover tints move behind (hover: hover) and (pointer: fine),
which is #68's gate applied to a tint rather than a revealed control.

user-select, the other half of the Findings, was already done: the
first rule in index.css covers the shadow roots for the same reason.
touch-action: manipulation is declined — the 300ms delay it is offered
for is already absent on a width=device-width viewport, and what it
would really change is the gesture stack tuned by measurement on a
device this session cannot measure.

Closes #54
2026-08-25 12:51:28 -04:00
logan 944995dc3c Merge pull request 'feat(android): a name is not a link on a phone, the menu carries it' (#208) from feat/67-entity-links-into-menus into main
CI / check (push) Successful in 2m40s
CI / e2e (push) Successful in 10m18s
2026-08-25 16:51:16 +00:00
15 changed files with 934 additions and 41 deletions
+35
View File
@@ -4994,3 +4994,38 @@ ordinary track makes it 9.
Filed as its own issue rather than fixed in #67's diff: it is a
property of the shared sheet (`components/menu-surface/`), not of the
items.
## The tap highlight is one inherited declaration (measured 2026-08-24)
`-webkit-tap-highlight-color` is an **inherited** property, and an
inherited property crosses a shadow boundary — so `html { … :
transparent }` in `index.css` reaches every shadow root in the app and
no component needs a rule of its own. Measured in the running app
(Chromium, `app-sidebar`'s `li button`, which is three shadow roots
from the document): `rgba(0, 0, 0, 0)` with the rule, and
`rgba(0, 0, 0, 0.18)` with it removed. That 0.18 grey over the bounding
rect of whatever was tapped is what #54 reported.
The same argument was already spent once and is worth not
re-deriving: `index.css`'s first rule is `*, *::before, *::after {
user-select: none }`, which for the same reason already covers the
shadow roots — #54's Findings ask for `user-select` on interactive
surfaces and it has been done since before the issue was filed.
**What the highlight was, on the surfaces that had nothing else, is the
press feedback.** Measured on a track row with the press rule removed
and the button held down: `rgba(255, 255, 255, 0.05)` — the *hover*
tint, arriving because the pointer is over the row, which is a
synthesised hover on a phone and outlives the press. With the rule:
0.12 while held, and the neighbouring row unchanged. So the press state
is part of removing the highlight rather than a separate polish item,
and the hover tints on those same surfaces moved behind
`(hover: hover) and (pointer: fine)`, which is #68's gate applied to a
tint rather than to a revealed control.
**`touch-action: manipulation` was considered and not taken.** The
Findings offer it for the 300ms tap delay; this app's viewport is
`width=device-width`, which is what removes that delay in Chrome, so
the stated benefit is not there to win. What it would change is the
gesture stack #63 tuned by measurement on the device (`pan-y` plus a
non-passive `preventDefault`), and that is not measurable from here.
+76 -4
View File
@@ -1113,9 +1113,9 @@ not the fix and cannot be: that function is the `document` listener for
`navigate`, so it is an infinite loop.
**It is a store rather than an event, because a component that mounts
after a navigation still has to know.** `bottom-nav`'s "More" drawer
after a navigation still has to know.** `bottom-nav`'s "More" sheet
creates its `<app-sidebar>` on open, and that copy had heard no
`navigate` at all — standing on Albums, the drawer opened highlighting
`navigate` at all — standing on Albums, it opened highlighting
Home. An event has no answer for a listener that was not there.
**A detail view is not a view here**, so the destination it was opened
@@ -1216,7 +1216,7 @@ and then vanishing.
than a general rule about phones.** `PHONE_COLUMN_IDS` is the precedent
for "what a phone shows is a different question", and it would apply —
except that `bottom-nav`'s "More" opens the *same* `<app-sidebar>`,
which filters, so an unfiltered bar would contradict its own drawer one
which filters, so an unfiltered bar would contradict its own sheet one
tap away. Which four tabs is still plan 016's committed subset; this
only removes from it, and "More" is never filtered because it is how
everything else stays reachable.
@@ -1631,6 +1631,51 @@ sits inside which media query — and says so; the regression it exists
for is someone hoisting a rule out of its query as a tidy-up, which
nothing on a desktop renders differently.
**The web view's own tap highlight is gone, and what replaced it is a
press state** (#54). `-webkit-tap-highlight-color` is an *inherited*
property, so one declaration on `html` in `index.css` reaches every
shadow root in the app and takes away the grey box a phone drew over
the bounding rect of whatever was tapped — measured at
`rgba(0, 0, 0, 0.18)` with the rule removed. `user-select` is the same
argument and was already done: `index.css`'s first rule is `*, *::before,
*::after { user-select: none }`, which reaches the shadow roots for the
same reason.
Three things about it are load-bearing.
**Removing the highlight removes the only touch feedback several
surfaces had**, so the press state is part of the same change rather
than a later polish item: the four lists' rows, `bottom-nav`'s tabs,
`app-sidebar`'s destinations (which are also the phone's "More" sheet)
and the shared `contextMenuStyles` menu item all take
`--yj-press-overlay` on `:active`. The cards already had one
(`transform: scale(0.97)`) and are untouched.
**A press selector carries a state class or it does nothing where it
matters.** A row is `.track-row.selected.active`, so a bare
`.track-row:active` is one class short of it and the press is invisible
on exactly the row a phone is most likely to press — the one it has
just selected. The rule is last and lists `.selected:active` /
`.active:active` beside the bare form.
**And the hover tints on those same surfaces moved behind
`(hover: hover) and (pointer: fine)`**, which is #68's gate applied to
a tint rather than to a revealed control and for the same mechanism: a
hold synthesises a hover in the WebView, so an ungated tint arrives
because a finger touched the row and stays there after it has gone —
measured, since with the press rule removed a held row reads
`rgba(255, 255, 255, 0.05)`, the hover tint, rather than nothing.
`touch-action: manipulation` was considered and declined: the 300ms
delay it is offered for is already absent on a `width=device-width`
viewport, and what it would really change is the gesture stack #63
tuned by measurement on a device this session cannot measure.
The split of tiers is `hover-affordance.test.ts`'s: `press-feedback.
test.ts` reads the parsed stylesheet, because `:active` cannot be
forced there either, and `native-touch-feel.spec.ts` *measures* — it
holds the button down on a real row of the real list, and it is the
only tier that loads `index.css` at all.
Three lists had no focused row to open a menu *from* — the queue panel
and both playlist detail views — and gained a roving tab stop through
`utils/roving-rows.ts`. **`track-list` deliberately does not use it**:
@@ -1829,7 +1874,7 @@ 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
while the sheet 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
@@ -1838,6 +1883,33 @@ 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".
**And "More" rises from the bottom, on #60's sheet rather than a
second one** (#71). It was a `wa-drawer placement="start"`: a 200px
column of a 424px screen, opening away from the thumb that asked for
it, with the rest of its 400px band empty. It is the *same element*
with `placement="bottom"` and `without-header`, which is what keeps
the change to where it comes from — `wa-drawer` renders a native
`<dialog>` and opens it with `showModal()`, so #60's containment
finding carries over with nothing new to prove, and the focus trap,
Escape, tap-outside and `wa-after-hide` all come along. Measured at
424x439: 424 wide, 373 tall (85vh, so there is an outside to tap),
48px rows.
Three things about it are load-bearing. **The sidebar is mounted
rather than re-listed as data**, which the issue offers as the
alternative: the shell's own `<app-sidebar>` is `display: none` below
600px rather than removed, so a second list drawing `nav-*` handles is
the duplication above, and it would be a second place to add the next
view to. **There is one scroller, and it is the sheet's body** — the
reported "only part of the screen scrolls under my finger" is three
nested ones (the dialog, its body, and the sidebar's own
`overflow-y: auto` host), so which box a drag moves depends on where
the finger landed; `overscroll-behavior: contain` is the other half.
And **`expanded` means the host owns the box, not just the labels**:
`app-sidebar` writes an *inline* width and caps itself at 400px, which
beats any rule the host could write, so the width, the scrolling and
the mouse-only resize handle all follow that attribute.
**There are three supported size bands, and the queue is part of the
promise.** Plan 018 (#24) wrote them down: **Phone** below 600 (bottom
nav, reflows, fits 320px exactly), **Compact** 600899 (icon sidebar),
+140
View File
@@ -0,0 +1,140 @@
import { test, expect } from '../support/fixtures.js';
/**
* The web view's own tap highlight, and what replaced it (#54).
*
* Two halves, and each is here because no other tier can see it.
*
* **The highlight is killed by one declaration on `html`**, which
* reaches the app's shadow roots because `-webkit-tap-highlight-color`
* is inherited and inheritance crosses a shadow boundary. That is a
* property of `index.css`, and `index.css` is loaded by the real app
* and by nothing else — the component tier mounts a component with no
* page stylesheet at all, which is the same reason the theme's ramps
* are invisible to it.
*
* **The press state is measured rather than read.** The component tier
* asserts the shape of the stylesheet (which rule is inside which
* query, and that the press selector carries a state class), because
* `:active` cannot be forced there. Here there is a real pointer: hold
* the button down on a real row of the real list and read what the row
* became. That is the assertion that would fail if the rule were
* hoisted, renamed, or lost to `.selected`.
*
* What neither half is, is the device. Chrome 113's WebView is where
* the grey box was reported and where a finger is; the numbers from it
* are on the PR.
*/
type Page = import('@playwright/test').Page;
/** The phone this work was measured against, in CSS pixels. */
const DEVICE = { width: 424, height: 439 };
/** The computed tap-highlight colour of a node inside a shadow root. */
const tapHighlight = (page: Page, host: string, inner: string) =>
page.evaluate(
([hostSel, innerSel]) => {
const el = document
.querySelector(hostSel!)
?.shadowRoot?.querySelector(innerSel!);
if (!el) return null;
return getComputedStyle(el).getPropertyValue(
'-webkit-tap-highlight-color',
);
},
[host, inner],
);
test.describe('the tap highlight', () => {
test('is transparent inside a shadow root, from one rule on html', async ({
app,
browserName,
}) => {
await app.getByTestId('nav-tracks').click();
await expect(app.getByTestId('main-content')).toHaveAttribute(
'data-active-view',
'tracks',
);
const row = await tapHighlight(app, 'track-list', '.track-row');
expect(row).not.toBeNull();
// The property is a WebKit extension that only iOS honours, so an
// engine is free not to report one at all. Chromium always does —
// measured at rgba(0, 0, 0, 0.18) with the rule removed, which is
// the grey box the report describes — so the assertion is not
// skippable there, and nothing this app can do makes the property
// disappear on an engine that has it.
test.skip(
row === '',
`${browserName} reports no -webkit-tap-highlight-color to read`,
);
expect(row).toBe('rgba(0, 0, 0, 0)');
});
});
test.describe('the press state that replaced it', () => {
test.beforeEach(async ({ app }) => {
await app.setViewportSize(DEVICE);
await app.getByTestId('tab-tracks').click();
await expect(app.getByTestId('main-content')).toHaveAttribute(
'data-active-view',
'tracks',
);
await expect(app.locator('track-list').first()).toBeVisible();
});
test.afterEach(async ({ app }) => {
await app.mouse.up();
await app.setViewportSize({ width: 1440, height: 900 });
});
test('shows on the row being pressed, and on that row only', async ({
app,
}) => {
const rows = await app.evaluate(() => {
const found = document
.querySelector('track-list')
?.shadowRoot?.querySelectorAll('.track-row');
if (!found || found.length < 2) return null;
const rect = found[1]!.getBoundingClientRect();
return {
x: Math.round(rect.x + rect.width / 2),
y: Math.round(rect.y + rect.height / 2),
};
});
expect(rows).not.toBeNull();
const backgrounds = () =>
app.evaluate(() => {
const found = document
.querySelector('track-list')!
.shadowRoot!.querySelectorAll('.track-row');
return {
pressed: getComputedStyle(found[1]!).backgroundColor,
neighbour: getComputedStyle(found[2]!).backgroundColor,
};
});
await app.mouse.move(rows!.x, rows!.y);
await app.mouse.down();
const held = await backgrounds();
// The press overlay, from the theme rather than from a literal in
// a component: rgba(255, 255, 255, 0.12) on both dark ramps.
expect(held.pressed).toBe('rgba(255, 255, 255, 0.12)');
expect(held.neighbour).not.toBe(held.pressed);
await app.mouse.up();
});
});
+52
View File
@@ -98,6 +98,58 @@ test.describe('the shell on a phone', () => {
).toBeVisible();
});
test('draws "More" as a sheet on the bottom edge (#71)', async ({ app }) => {
await app.getByTestId('tab-more').click();
await expect(app.getByTestId('nav-drawer').locator('app-sidebar'))
.toBeVisible();
// What the report is about is geometry, and geometry is what no
// other assertion here can see: the side drawer was a 200px column
// opening away from the thumb that asked for it, with the rest of
// its 400px band empty. Measured rather than screenshotted, since
// the failure is a number.
//
// Polled, because a sheet *arrives*: the drawer's show animation
// translates it a full height below the fold, so a measurement
// taken the moment its content is visible reports a box hanging
// 412px off the bottom of the screen. Asking for the settled
// number is the assertion; asking once is a race.
const measure = () => app.evaluate(() => {
const nav = document.querySelector('bottom-nav');
const drawer = nav?.shadowRoot?.querySelector('wa-drawer');
const dialog = drawer?.shadowRoot?.querySelector('[part~="dialog"]');
const sidebar = nav?.shadowRoot?.querySelector('app-sidebar');
const row = sidebar?.shadowRoot?.querySelector('li button');
const box = dialog?.getBoundingClientRect();
return {
left: Math.round(box?.left ?? -1),
right: Math.round(box?.right ?? -1),
bottom: Math.round(box?.bottom ?? -1),
height: Math.round(box?.height ?? -1),
row: Math.round(row?.getBoundingClientRect().height ?? -1),
viewport: [window.innerWidth, window.innerHeight],
};
});
await expect
.poll(async () => (await measure()).bottom)
.toBe(PHONE.height);
const sheet = await measure();
expect(sheet.left).toBe(0);
expect(sheet.right).toBe(sheet.viewport[0]);
// A surface covering the whole screen is a page, not a sheet --
// which is also what leaves an outside to tap on, the only pointer
// route out of it (#171 is the same question one surface over).
expect(sheet.height).toBeLessThan(sheet.viewport[1]);
// 48px rows, from #186's touch floor and #60's context sheet.
expect(sheet.row).toBeGreaterThanOrEqual(48);
});
for (const vp of [PHONE, SMALL_PHONE]) {
test(`does not scroll sideways at ${vp.width}×${vp.height}`, async ({ app }) => {
await app.setViewportSize(vp);
+20
View File
@@ -7,6 +7,26 @@
html {
height: 100%;
/* #54. The web view's own tap highlight -- the grey box a phone
draws over the bounding rect of whatever was tapped -- gone in
one declaration, because `-webkit-tap-highlight-color` is an
*inherited* property and an inherited property crosses a shadow
boundary. So this reaches every one of the app's shadow roots
without a rule in any of them; before it, exactly one component
(`library-status-indicator`) set it and the box appeared
everywhere else.
What it costs is the only touch feedback several surfaces had,
which is why the rows, the tab bar and the shared menu items
grew a `:active` state in the same change: removing the wrong
feedback and leaving none is not an improvement. The cards
already had one (`transform: scale(0.97)`).
`user-select` is the same argument one rule up and was already
done: the `*` rule at the top of this file is inherited into the
shadow roots too. */
-webkit-tap-highlight-color: transparent;
}
body {
@@ -27,15 +27,43 @@ interface Tab {
* 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 `<app-sidebar>` in a drawer. That is deliberately
* opens the existing `<app-sidebar>` in a sheet. 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.
*
* **"More" rises from the bottom, and it is the same sheet a context
* menu is** (#71). It was a `wa-drawer` sliding in from the side: a
* 200px column of a 424px screen, opening away from the thumb that
* asked for it, with three nested scrollers in it — the dialog, its
* body, and the sidebar's own `overflow-y: auto` host — which is the
* "only part of the screen scrolls under my finger" in the report.
*
* Three things about the replacement are load-bearing.
*
* **It is the same element with another `placement`, not a new
* surface.** `wa-drawer` renders a native `<dialog>` and opens it with
* `showModal()`, which is exactly what `menu-surface`'s sheet relies
* on — Chrome 37, the real top layer — so #60's containment finding
* carries over with nothing new to prove, and the focus trap, Escape,
* tap-outside and `wa-after-hide` all come along unchanged.
*
* **The body is the only scroller**, with `overscroll-behavior:
* contain`, and the sidebar is told to stop being one. Nesting them is
* what makes a drag scroll the wrong box.
*
* **The sidebar is still mounted rather than re-listed as data**,
* which the issue offers as an alternative. Its `data-testid` per
* destination is the reason: the shell's own sidebar is `display:
* none` below 600px rather than removed, so a second list drawing
* `nav-*` handles is the duplication this component already renders
* conditionally to avoid — and it would be a second place to add the
* next view to, with its own copy of #25's visibility filter.
*
* 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
* did not send (a card click, a detail view, the sheet) still has to
* move the highlight.
*/
@customElement('bottom-nav')
@@ -89,6 +117,17 @@ export class BottomNav extends LitElement {
color: var(--yj-accent, #ffd43b);
}
/* The press state (#54). This bar is the phone's primary
navigation and had no feedback of its own at all -- what a
tap produced was the web view's tap highlight, a grey box
over the whole 48px cell, which index.css has now taken
away. The .active rule above is which tab you are *on*; this
is the tab being pressed, so they are a colour and a
background rather than two colours. */
button:active {
background-color: var(--yj-press-overlay, rgba(255, 255, 255, 0.12));
}
button:focus-visible {
outline: 2px solid var(--yj-accent, #ffd43b);
outline-offset: -2px;
@@ -104,15 +143,50 @@ export class BottomNav extends LitElement {
white-space: nowrap;
}
wa-drawer::part(body) {
padding: 0;
/* The sheet. --size is the drawer's own API for the axis its
placement uses, so auto is what makes it hug its content
instead of being a fixed 25rem band; the rest is the shape
the menu-surface context sheet already has, so a phone meets
one sheet rather than two. 85vh for its reason too: a surface
covering the whole screen is a page, not a sheet. */
wa-drawer {
--size: auto;
}
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%;
wa-drawer::part(dialog) {
max-height: 85vh;
border-radius: 12px 12px 0 0;
/* The sidebar paints its own surface, so the sheet takes
that colour rather than the menus' elevated one: two
greys in one sheet is a seam across the middle of it. */
background-color: var(--yj-bg-surface, #212529);
/* One scroller, and it is the body below. The dialog's own
overflow: auto is what let the sheet scroll as well as
its content, and it is also what would square off the
corners this rule just rounded. */
overflow: hidden;
}
wa-drawer::part(body) {
padding: 0;
/* A scroll that reaches the end of this list must not
become a scroll of the page underneath it. */
overscroll-behavior: contain;
/* The sheet sits on the bottom edge, so the last
destination would otherwise be under the home indicator
on a gesture-navigation phone -- the same allowance the
bar itself makes above. */
padding-bottom: env(safe-area-inset-bottom, 0);
}
/* A sheet is dragged at with a thumb, so it says where its top
edge is. Decorative: the destinations are below it. */
.grip {
width: 36px;
height: 4px;
margin: 8px auto 4px;
border-radius: 2px;
background: var(--yj-text-tertiary, #888);
}
`];
@@ -147,7 +221,7 @@ export class BottomNav extends LitElement {
private visibilityCtrl = new ViewVisibilityController(this);
/**
* Whether the drawer has been asked for.
* Whether the sheet 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
@@ -189,15 +263,17 @@ export class BottomNav extends LitElement {
override updated() {
// Web Awesome renders its heading into its own shadow root and
// never points aria-labelledby at it, so the drawer would
// never points aria-labelledby at it, so the sheet 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.
// has the same shape, so the helper needs no change; under
// `without-header` there is no heading to point at, which is
// that helper's documented `aria-label` path.
nameDialog(this.drawer);
}
private onGlobalNavigate = () => {
// A navigation from inside the drawer is the drawer's job done.
// A navigation from inside the sheet is the sheet's job done.
// The highlight is not this listener's business any more.
this.drawerOpen = false;
};
@@ -263,12 +339,14 @@ export class BottomNav extends LitElement {
</nav>
<wa-drawer
placement="start"
placement="bottom"
without-header
label="All views"
data-testid="nav-drawer"
?open=${this.drawerOpen}
@wa-after-hide=${this.onDrawerHide}
>
<div class="grip"></div>
${this.drawerOpen
? html`<app-sidebar expanded></app-sidebar>`
: nothing}
@@ -1357,8 +1357,14 @@ export class PlaylistDetails
user-select: none;
}
.track-item:hover {
background-color: var(--yj-hover-overlay, rgba(255, 255, 255, 0.05));
/* A hover tint is for a device that hovers (#54). A hold
synthesises a hover in the WebView, so ungated this arrives
because a finger touched the row and stays after it has
gone; the press state below is what a tap gets instead. */
@media (hover: hover) and (pointer: fine) {
.track-item:hover {
background-color: var(--yj-hover-overlay, rgba(255, 255, 255, 0.05));
}
}
.track-item.selected {
@@ -1378,11 +1384,13 @@ export class PlaylistDetails
cursor: pointer;
}
.track-item.phantom:hover {
background-color: var(
--yj-hover-overlay,
rgba(255, 255, 255, 0.05)
);
@media (hover: hover) and (pointer: fine) {
.track-item.phantom:hover {
background-color: var(
--yj-hover-overlay,
rgba(255, 255, 255, 0.05)
);
}
}
.track-item.phantom.selected {
@@ -1392,6 +1400,19 @@ export class PlaylistDetails
);
}
/* The press state (#54): the feedback a tap has now that the
web view's own highlight box is gone (index.css). Last, and
carrying a class, because a selected or playing row is two
classes deep and a bare :active would lose to it. */
.track-item.selected:active,
.track-item.active:active,
.track-item:active {
background-color: var(
--yj-press-overlay,
rgba(255, 255, 255, 0.12)
);
}
.phantom-row {
grid-column: 1 / -1;
display: flex;
@@ -581,8 +581,14 @@ export class QueuePanel
contain: strict;
}
.track-item:hover {
background-color: var(--yj-hover-overlay, rgba(255, 255, 255, 0.05));
/* A hover tint is for a device that hovers (#54). A hold
synthesises a hover in the WebView, so ungated this arrives
because a finger touched the row and stays after it has
gone; the press state below is what a tap gets instead. */
@media (hover: hover) and (pointer: fine) {
.track-item:hover {
background-color: var(--yj-hover-overlay, rgba(255, 255, 255, 0.05));
}
}
.track-item.selected {
@@ -597,6 +603,19 @@ export class QueuePanel
background-color: var(--yj-selection-bg, rgba(100, 160, 255, 0.15));
}
/* The press state (#54): the feedback a tap has now that the
web view's own highlight box is gone (index.css). Last, and
carrying a class, because a selected or playing row is two
classes deep and a bare :active would lose to it. */
.track-item.selected:active,
.track-item.active:active,
.track-item:active {
background-color: var(
--yj-press-overlay,
rgba(255, 255, 255, 0.12)
);
}
.track-position {
font-size: var(--yj-text-sm);
color: var(--yj-text-tertiary, #888);
+73 -7
View File
@@ -42,6 +42,26 @@ export class AppSidebar extends LitElement {
scrollbar-width: thin;
}
/* A host that has made room owns the box, not just the labels
(#71). The bottom-nav sheet is the width of the screen and
provides the one scroll container it needs; left to itself
the sidebar is a 200px column with a second scroller inside
it, which is what a nested scroll region feels like under a
thumb -- part of the surface moves and part of it does not. */
:host([expanded]) {
max-width: none;
height: auto;
overflow: visible;
}
/* And the width is not draggable there. It is a mouse
affordance (mousedown, col-resize) sitting on the right edge
of a touch surface, where the compatibility mouse events a
tap synthesises can start a resize nobody asked for. */
:host([expanded]) .resize-handle {
display: none;
}
.resize-handle {
position: absolute;
top: 0;
@@ -95,8 +115,15 @@ export class AppSidebar extends LitElement {
text-align: center;
}
li button:hover {
background-color: var(--yj-bg-elevated, #343a40);
/* A hover tint is for a device that hovers (#54), and this
component is on a phone too: below 600px it is what
bottom-nav's "More" sheet mounts, where a hold
synthesises a hover and leaves a destination looking picked
after the finger has gone. */
@media (hover: hover) and (pointer: fine) {
li button:hover {
background-color: var(--yj-bg-elevated, #343a40);
}
}
li button:focus-visible {
@@ -108,6 +135,14 @@ export class AppSidebar extends LitElement {
background-color: var(--yj-bg-overlay, #495057);
}
/* The press state (#54), after the .active rule and at the same
specificity, so pressing the destination you are already on
still says something. It is what a tap gets now that
index.css has taken the web view's own highlight box away. */
li button:active {
background-color: var(--yj-press-overlay, rgba(255, 255, 255, 0.12));
}
li button p {
margin: 0;
white-space: nowrap;
@@ -145,6 +180,25 @@ export class AppSidebar extends LitElement {
:host(.collapsed) li button wa-icon {
font-size: var(--yj-icon-md);
}
/* Below 600px the only place this renders is the bottom-nav
sheet -- the shell's own copy is display: none there -- so
the rows are sized for the thumb that opened it: 48px, which
is #186's floor and the height every row in #60's context
sheet already has. A media query inside a shadow root is
answered by the viewport, so the component states this
itself rather than the sheet reaching in. */
@media (max-width: 599px) {
ul {
padding: 4px 8px 8px;
}
li button {
min-height: 48px;
padding: 12px 10px;
gap: 14px;
}
}
`];
/** Delay in ms before a drag-hover triggers navigation. */
@@ -173,11 +227,14 @@ export class AppSidebar extends LitElement {
/**
* Keep the labels regardless of the viewport, for a host that has
* made room for them -- `bottom-nav`'s drawer, which is the whole
* made room for them -- `bottom-nav`'s sheet, 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
* shell, and inside a sheet the shell is not what the sidebar is
* sharing space with.
*
* It says the host owns the *box*, not only the labels: the width,
* the scrolling and the resize handle all follow it (#71).
*/
@property({ type: Boolean, reflect: true })
expanded = false;
@@ -350,9 +407,18 @@ export class AppSidebar extends LitElement {
* be a media query in the stylesheet.
*/
private applyViewportWidth() {
const narrow =
!this.expanded &&
(this.narrowViewport?.matches ?? false);
// A host that made room decides how much: `bottom-nav`'s sheet
// is the whole screen wide, and the inline width below -- which
// beats any rule the host could write -- would draw the old
// 200px side drawer inside it.
if (this.expanded) {
this.style.width = '100%';
this.collapsed = false;
return;
}
const narrow = this.narrowViewport?.matches ?? false;
const width = narrow
? MIN_WIDTH
: this.userWidth;
@@ -516,8 +516,14 @@ export class SmartPlaylistDetails
user-select: none;
}
.track-item:hover {
background-color: var(--yj-hover-overlay, rgba(255, 255, 255, 0.05));
/* A hover tint is for a device that hovers (#54). A hold
synthesises a hover in the WebView, so ungated this arrives
because a finger touched the row and stays after it has
gone; the press state below is what a tap gets instead. */
@media (hover: hover) and (pointer: fine) {
.track-item:hover {
background-color: var(--yj-hover-overlay, rgba(255, 255, 255, 0.05));
}
}
.track-item.selected {
@@ -533,6 +539,19 @@ export class SmartPlaylistDetails
background-color: var(--yj-selection-bg, rgba(100, 160, 255, 0.15));
}
/* The press state (#54): the feedback a tap has now that the
web view's own highlight box is gone (index.css). Last, and
carrying a class, because a selected or playing row is two
classes deep and a bare :active would lose to it. */
.track-item.selected:active,
.track-item.active:active,
.track-item:active {
background-color: var(
--yj-press-overlay,
rgba(255, 255, 255, 0.12)
);
}
/* Phantom rows span the full grid */
.track-item.phantom {
display: grid;
@@ -1201,8 +1201,16 @@ export class TrackList
padding-left: 6px;
}
.track-row:hover {
background-color: var(--yj-hover-overlay, rgba(255, 255, 255, 0.05));
/* A hover tint is for a device that hovers (#54): a hold
synthesises a hover in the WebView, so ungated this is a
highlight that arrives because a finger touched the row and
then stays there after it has gone -- which reads as a
selection the user did not make. Same gate, and the same
mechanism, as #68's revealed controls. */
@media (hover: hover) and (pointer: fine) {
.track-row:hover {
background-color: var(--yj-hover-overlay, rgba(255, 255, 255, 0.05));
}
}
.track-row.selected {
@@ -1239,6 +1247,23 @@ export class TrackList
background-color: var(--yj-selection-bg, rgba(100, 160, 255, 0.15));
}
/* The press state (#54), and the only feedback a tap has now that
the web view's tap highlight is gone (index.css).
**Last, and as specific as the state rules above**: a row that
is selected and playing is .track-row.selected.active, so a
bare .track-row:active is one class short of it and a press
on the row a phone is most likely to press -- the one it just
selected -- would show nothing. Instant rather than
transitioned, because the only measured statement here about
transitions on a list is that two card grids removed theirs
for software-rendering repaint cost. */
.track-row.selected:active,
.track-row.active:active,
.track-row:active {
background-color: var(--yj-press-overlay, rgba(255, 255, 255, 0.12));
}
.cell {
overflow: hidden;
+15
View File
@@ -46,6 +46,17 @@ export interface ShadePalette {
border: string;
borderSubtle: string;
hoverOverlay: string;
/**
* The tint a surface takes while it is being pressed (#54).
*
* Separate from `hoverOverlay` because the two answer different
* questions and only one of them a phone can ask: a hover is a
* pointer resting somewhere, a press is a finger on the thing it
* is about to activate. It is deliberately the stronger of the
* two — a press that reads the same as a hover says nothing on a
* device where the hover is synthesised by the press itself.
*/
pressOverlay: string;
selectionBg: string;
}
@@ -95,6 +106,7 @@ export const SHADE_PALETTES: Record<BackgroundShade, ShadePalette> = {
border: '#333333',
borderSubtle: '#222222',
hoverOverlay: 'rgba(255, 255, 255, 0.05)',
pressOverlay: 'rgba(255, 255, 255, 0.12)',
selectionBg: 'rgba(100, 160, 255, 0.15)',
},
dark: {
@@ -114,6 +126,7 @@ export const SHADE_PALETTES: Record<BackgroundShade, ShadePalette> = {
border: '#444444',
borderSubtle: '#333333',
hoverOverlay: 'rgba(255, 255, 255, 0.05)',
pressOverlay: 'rgba(255, 255, 255, 0.12)',
selectionBg: 'rgba(100, 160, 255, 0.15)',
},
light: {
@@ -133,6 +146,7 @@ export const SHADE_PALETTES: Record<BackgroundShade, ShadePalette> = {
border: '#ced4da',
borderSubtle: '#dee2e6',
hoverOverlay: 'rgba(0, 0, 0, 0.05)',
pressOverlay: 'rgba(0, 0, 0, 0.12)',
selectionBg: 'rgba(100, 160, 255, 0.15)',
},
};
@@ -285,6 +299,7 @@ function deriveThemeVariables(
// Interactive overlays
'--yj-hover-overlay': palette.hoverOverlay,
'--yj-press-overlay': palette.pressOverlay,
'--yj-selection-bg': palette.selectionBg,
// Semantic *fills* — the background of a solid button or badge.
+28 -3
View File
@@ -710,10 +710,35 @@ export const contextMenuStyles = css`
font-size: 13px;
}
.context-menu-panel wa-dropdown-item:hover {
/* A hover tint is for a device that hovers (#54).
Below the query is a phone, where a hold *synthesises* a hover
in the WebView -- the same mechanism #68 gates the revealed
controls on -- so an ungated tint is a highlight that arrives
because a finger touched the row and then stays on it after the
finger has gone. Which is indistinguishable from the press
state below, and outlives it. */
@media (hover: hover) and (pointer: fine) {
.context-menu-panel wa-dropdown-item:hover {
background-color: var(
--yj-hover-overlay,
rgba(255, 255, 255, 0.1)
);
}
}
/* And a press state is for every device, because it is the one
piece of feedback a tap has now that the web view's own
highlight box is gone (index.css). Stronger than the hover tint
on purpose, and instant rather than transitioned: the only
measured statement this repo has about transitions on these
surfaces is the two card grids that removed theirs because
software rendering repaints per frame, and the phone is not
something this session can measure. */
.context-menu-panel wa-dropdown-item:active {
background-color: var(
--yj-hover-overlay,
rgba(255, 255, 255, 0.1)
--yj-press-overlay,
rgba(255, 255, 255, 0.12)
);
}
+115
View File
@@ -193,4 +193,119 @@ describe('bottom-nav', () => {
expect(shadow<HTMLElement>(el, 'app-sidebar')?.hasAttribute('expanded'))
.toBe(true);
});
it('draws "More" as a sheet rising from the bottom', async () => {
const el = await fixture<Nav>('bottom-nav');
const drawer = shadow<HTMLElement>(el, 'wa-drawer');
// #71. A side drawer is a desktop shape: it opened away from the
// thumb that asked for it and drew a 200px column of a 424px
// screen. `placement` is the whole of the change to *where* it
// comes from, and `without-header` is what makes it the same sheet
// `menu-surface` draws rather than a second pattern with a title
// bar and a close button.
expect(drawer?.getAttribute('placement')).toBe('bottom');
expect(drawer?.hasAttribute('without-header')).toBe(true);
// Named all the same: `nameDialog`'s documented aria-label path,
// since without-header renders no heading to point at.
expect(drawer?.getAttribute('label')).toBe('All views');
});
it('leaves exactly one scroll container, and it is the sheet body', async () => {
const el = await fixture<Nav>('bottom-nav');
const drawer = shadow<HTMLElement & { open: boolean }>(el, 'wa-drawer');
if (!drawer) throw new Error('no drawer');
const shown = once(drawer, 'wa-after-show');
shadow<HTMLButtonElement>(el, '[data-testid="tab-more"]')?.click();
await shown;
await update(el, {});
// The report is "only part of the screen scrolls under my finger",
// and the cause is three boxes that each scroll: the dialog, its
// body, and the sidebar's own overflow-y host. Which one a drag
// moves depends on where the finger landed.
const dialog = drawer.shadowRoot?.querySelector('[part~="dialog"]');
const body = drawer.shadowRoot?.querySelector('[part~="body"]');
const sidebar = shadow<HTMLElement>(el, 'app-sidebar');
if (!dialog || !body || !sidebar) throw new Error('no sheet');
expect(getComputedStyle(dialog).overflowY).toBe('hidden');
expect(getComputedStyle(body).overflowY).toBe('auto');
expect(getComputedStyle(sidebar).overflowY).toBe('visible');
// And the one that does scroll keeps it to itself, or reaching the
// end of the destinations scrolls the page behind the sheet.
expect(getComputedStyle(body).overscrollBehaviorY).toBe('contain');
});
it('gives the sheet the whole width, which the sidebar does not take', async () => {
const el = await fixture<Nav>('bottom-nav');
shadow<HTMLButtonElement>(el, '[data-testid="tab-more"]')?.click();
await update(el, {});
const sidebar = shadow<HTMLElement>(el, 'app-sidebar');
if (!sidebar) throw new Error('no sidebar');
// `app-sidebar` writes an *inline* width and caps itself at 400px,
// which beats any rule this host could write — so "the host owns
// the box" has to be part of what `expanded` means, or the sheet
// draws the old 200px column inside a full-width surface.
expect(sidebar.style.width).toBe('100%');
expect(getComputedStyle(sidebar).maxWidth).toBe('none');
});
it('sizes the sheet rows for a thumb, below the phone breakpoint', async () => {
const el = await fixture<Nav>('bottom-nav');
shadow<HTMLButtonElement>(el, '[data-testid="tab-more"]')?.click();
await update(el, {});
const sidebar = shadow<HTMLElement>(el, 'app-sidebar');
const sheets = sidebar?.shadowRoot?.adoptedStyleSheets ?? [];
const phoneRules: string[] = [];
for (const sheet of sheets) {
for (const rule of Array.from(sheet.cssRules)) {
if (!(rule instanceof CSSMediaRule)) continue;
if (!/max-width:\s*599px/.test(rule.conditionText)) continue;
for (const inner of Array.from(rule.cssRules)) {
phoneRules.push(inner.cssText);
}
}
}
// Asserted against the parsed stylesheet, like
// `hover-affordance.test.ts` and for the same reason: this tier's
// iframe is not 599px wide, so the rule cannot be *rendered* here —
// but the regression worth catching is someone moving it out of the
// query, which nothing on a desktop draws differently.
expect(phoneRules.length).toBeGreaterThan(0);
expect(phoneRules.some((r) => /min-height:\s*48px/.test(r))).toBe(true);
});
it('takes the resize handle out of the sheet', async () => {
const el = await fixture<Nav>('bottom-nav');
shadow<HTMLButtonElement>(el, '[data-testid="tab-more"]')?.click();
await update(el, {});
const handle = shadow<HTMLElement>(el, 'app-sidebar')
?.shadowRoot?.querySelector('.resize-handle');
if (!handle) throw new Error('no resize handle');
// A col-resize strip on the right edge of a touch surface: the
// compatibility mouse events a tap synthesises reach its
// `mousedown`, so it can start a resize nobody asked for.
expect(getComputedStyle(handle).display).toBe('none');
});
});
@@ -0,0 +1,191 @@
/**
* What a tap looks like now that the web view's own highlight is gone
* (#54).
*
* `index.css` sets `-webkit-tap-highlight-color: transparent` on
* `html`, which — the property being inherited — reaches every shadow
* root in the app. That takes away the grey box a phone drew over the
* bounding rect of whatever was tapped, and with it the only touch
* feedback the rows, the tab bar, the sidebar's destinations and the
* shared menu items had. So the press states below are not decoration:
* without them this change trades wrong feedback for none.
*
* **Asserted against the parsed stylesheet**, on `hover-affordance`'s
* precedent and with the same limitation stated out loud: CDP's
* `Emulation.setEmulatedMedia` does not reach this tier's iframe, so
* there is no way here to render a component as a phone would, and
* `:active` cannot be forced from a test either. What the browser will
* answer is the shape it built from the `css` literal — which rule sits
* inside which media query, and what the press selector actually is.
*
* Two regressions are worth catching that way, and both are silent on a
* desktop:
*
* - someone hoisting a hover tint back out of its query as a tidy-up,
* which on a phone is a highlight that arrives because a finger
* touched the row and stays after it has gone;
* - someone simplifying the press selector to a bare `:active`, which
* is one class short of `.selected` / `.active` and so does nothing
* on the row a phone is most likely to press — the one it has just
* selected.
*
* The pixels are the Android tier's, and the tap highlight itself is
* `e2e/specs/native-touch-feel.spec.ts`, since only the real app loads
* `index.css` at all.
*/
import { describe, expect, it } from 'vitest';
import '@components/track-list/track-list';
import '@components/queue-panel/queue-panel';
import '@components/playlist-details/playlist-details';
import '@components/smart-playlist-details/smart-playlist-details';
import '@components/bottom-nav/bottom-nav';
import '@components/sidebar/app-sidebar';
import { fixture } from '@test/support/render';
/** Every rule in the element's own adopted stylesheets, flattened. */
function rulesOf(host: Element): { text: string; condition: string | null }[] {
const sheets = host.shadowRoot?.adoptedStyleSheets ?? [];
const out: { text: string; condition: string | null }[] = [];
for (const sheet of sheets) {
for (const rule of Array.from(sheet.cssRules)) {
if (rule instanceof CSSMediaRule) {
for (const inner of Array.from(rule.cssRules)) {
out.push({ text: inner.cssText, condition: rule.conditionText });
}
continue;
}
out.push({ text: rule.cssText, condition: null });
}
}
return out;
}
/** The four lists, their row selector, and the tag that draws them. */
const LISTS: Array<[string, string]> = [
['track-list', '.track-row'],
['queue-panel', '.track-item'],
['playlist-details', '.track-item'],
['smart-playlist-details', '.track-item'],
];
describe('a row says it is being pressed', () => {
for (const [tag, row] of LISTS) {
it(`${tag} draws a press state that survives its state classes`, async () => {
const el = await fixture(tag, {});
const rules = rulesOf(el);
// Worth nothing if it read no rules at all — the first assertion
// icon-language.test.ts makes, for the same reason.
expect(rules.length).toBeGreaterThan(0);
const press = rules.filter(
(r) => r.text.includes(`${row}:active`) && r.text.includes('background-color'),
);
expect(press.length).toBeGreaterThan(0);
for (const rule of press) {
// A press is not a hover: it is the one thing a touch device
// can say, so it must not sit behind a pointer query.
expect(rule.condition).toBeNull();
expect(rule.text).toContain('--yj-press-overlay');
}
// The load-bearing half: the selector carries a state class, or
// it loses to `.selected` / `.selected.active` and the press is
// invisible on a selected or playing row.
expect(press.some((r) => r.text.includes(`${row}.selected:active`))).toBe(true);
});
it(`${tag} keeps its hover tint for devices that hover`, async () => {
const el = await fixture(tag, {});
const rules = rulesOf(el);
expect(rules.length).toBeGreaterThan(0);
const hover = rules.filter(
(r) =>
r.text.includes(`${row}:hover`) &&
r.text.includes('--yj-hover-overlay'),
);
expect(hover.length).toBeGreaterThan(0);
for (const rule of hover) {
expect(rule.condition).toMatch(/hover:\s*hover/);
expect(rule.condition).toMatch(/pointer:\s*fine/);
}
});
}
});
describe('the two navigations say they are being pressed', () => {
it('the phone tab bar, which had no state of its own at all', async () => {
const el = await fixture('bottom-nav', {});
const rules = rulesOf(el);
expect(rules.length).toBeGreaterThan(0);
const press = rules.filter((r) => r.text.startsWith('button:active'));
expect(press.length).toBe(1);
expect(press[0]!.condition).toBeNull();
expect(press[0]!.text).toContain('--yj-press-overlay');
});
it("the sidebar, which is also the phone's More sheet", async () => {
const el = await fixture('app-sidebar', {});
const rules = rulesOf(el);
expect(rules.length).toBeGreaterThan(0);
const press = rules.filter((r) => r.text.startsWith('li button:active'));
expect(press.length).toBe(1);
expect(press[0]!.condition).toBeNull();
expect(press[0]!.text).toContain('--yj-press-overlay');
// Its hover tint is a destination looking picked, if it is left to
// a synthesised hover inside the More sheet.
const hover = rules.filter((r) => r.text.startsWith('li button:hover'));
expect(hover.length).toBeGreaterThan(0);
for (const rule of hover) {
expect(rule.condition).toMatch(/hover:\s*hover/);
}
});
});
describe('the shared context menu', () => {
// One stylesheet, fourteen menus — the same reason the sheet's row
// height lives there rather than in each host.
it('presses its items, in the one place every menu includes', async () => {
const el = await fixture('queue-panel', {});
const rules = rulesOf(el);
const press = rules.filter((r) =>
r.text.startsWith('.context-menu-panel wa-dropdown-item:active'),
);
expect(press.length).toBe(1);
expect(press[0]!.condition).toBeNull();
expect(press[0]!.text).toContain('--yj-press-overlay');
const hover = rules.filter((r) =>
r.text.startsWith('.context-menu-panel wa-dropdown-item:hover'),
);
expect(hover.length).toBeGreaterThan(0);
for (const rule of hover) {
expect(rule.condition).toMatch(/hover:\s*hover/);
expect(rule.condition).toMatch(/pointer:\s*fine/);
}
});
});