Compare commits

..
Author SHA1 Message Date
logan cfec242e33 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 2m32s
CI / e2e (pull_request) Successful in 9m58s
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-24 03:51:12 -04:00
15 changed files with 596 additions and 175 deletions
+30 -41
View File
@@ -4913,48 +4913,37 @@ disabled — it gates on a directory chosen *in the wizard*, and the
existing-library check runs once, on mount. A reload clears it. Nothing
is broken; it cost twenty minutes of believing a tap had been swallowed.
## The sheet's scroll fade, and where a scrim may not go (measured 2026-08-23, headless)
## The tap highlight is one inherited declaration (measured 2026-08-24)
#207's answer. The affordance is two background layers on
`wa-dialog::part(body)` and the conditionality is
`background-attachment`, not a scroll listener: a cover of the sheet's
own colour painted at the end of the *content* (`local`) over a shadow
pinned to the box (`scroll`), so the cover scrolls up and hides the
shadow exactly when there is nothing more to see.
`-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.
Measured at 424x360 (which is where a menu overflows on `main`, since
`main` does not yet carry #67's eighth item — at 424x439 the track
list's seven items are `scrollHeight` 364 against `clientHeight` 364,
fitting exactly). Pixel at x=300, dark ramp, `bgElevated` `#343a40`:
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.
| y | before | more below | at the end of the list |
|---|---|---|---|
| 330 | 52,58,64 | 50,56,62 | 52,58,64 |
| 340 | 52,58,64 | 43,48,53 | 52,58,64 |
| 350 | 52,58,64 | 33,37,40 | 52,58,64 |
| 359 | 52,58,64 | 22,24,27 | 52,58,64 |
**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.
Three things worth keeping.
**A menu that fits draws nothing**, which is the same measurement: at
424x439 the sheet is flat 52,58,64 to its bottom edge, because with no
overflow the `local` layer's positioning area *is* the padding box and
the cover lands on top of the shadow.
**A scrim over a menu row is that row's text surface**, so the 4.5:1
rule reaches it and this is why the curve is steep rather than linear.
A row is 48px with its label centred; 32px of scrim already down to a
quarter strength at 14px puts about 0.06 at the label. Checked on the
light ramp (`bgElevated` `#e9ecef`, text `#212529`) by overriding the
two custom properties on `:root`: background at the label 205,207,210,
which is **9.9:1**. The first draft — a linear 48px at 0.8 — put ~0.375
on that label, 5.0:1, passing but visibly greyed. The bottom few pixels
go to ~2.4:1 in either draft and are deliberately below where any
label of a *fully visible* row sits; a label that lands there belongs
to the half-cut row, which is the thing being signalled.
**A dark scrim on a dark surface reads far worse in a shrunk screenshot
than on screen.** The first two probes (24px/0.45, then 32px/0.75) were
measurably present — 52,58,64 down to 30,33,37 — and invisible in the
inline preview. Crop the bottom 70px and scale it up before judging;
the pixel values are the honest answer either way.
**`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.
+46 -20
View File
@@ -1402,7 +1402,7 @@ descendants. On the reference device the main panel spans 0-318 of a
items cut off, with no way to reach them. `showModal()` is Chrome 37
and uses the real top layer, so a dialog is immune by construction.
Seven things about it are load-bearing.
Six things about it are load-bearing.
**"Dialogs are fine" needed checking, because every other dialog in
this app is mounted in `index.html`** — outside `.main-panel` — so it
@@ -1431,25 +1431,6 @@ doing nothing, which reads as the gesture breaking. `menu-dismiss` is
that signal; the three surfaces that do not use `ContextMenuController`
bind it themselves.
**A sheet that scrolls says so, and `background-attachment` is what
asks whether it does** (#207). The sheet is capped at 85vh — a surface
covering the whole screen is a page, not a sheet — so a long menu's
body scrolls, and for three phases it scrolled *silently*: measured at
424x439, eight items ended at y=470 with the fold at 439, and where the
cut lands on a row boundary the sheet ends in a clean edge that reads
as the end of the list. The fade is two background layers on
`wa-dialog::part(body)` — a shadow pinned to the box (`scroll`) under a
cover of the sheet's own colour painted at the end of the *content*
(`local`), which scrolls up over the shadow exactly when there is
nothing more to see. So it is absent on a menu that fits, present the
moment one does not, and gone again at the end of the list, with no
scroll listener and nothing reaching into `wa-dialog`'s shadow root for
the scroller. **The curve is steep because the rows under it stay
live**: a scrim over a menu item is that item's text surface, and the
4.5:1 rule applies to it — 32px already down to a quarter strength at
14px spends its weight below the last legible label, measured at 9.9:1
on the light ramp, whose `bgElevated` is `#e9ecef`.
**The playlist submenu is a sheet too, and it had to be.** It is a
`placement="right-start"` flyout, and making the menu full-width moved
its anchor — measured at x 182 to 0, entirely off-screen, so "Add to
@@ -1611,6 +1592,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**:
+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();
});
});
+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 {
@@ -89,6 +89,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;
@@ -155,55 +155,10 @@ export class MenuSurface extends LitElement {
bottom was at y=452 on a 439px screen -- the one row a
destructive action is most likely to be. The cap has to stay
(a sheet covering the whole screen is a page, not a sheet),
so the body is what gives.
**And a body that scrolls says so** (#207). Scrolling was the
whole of the fix above, which left the last item reachable
and nothing on screen admitting it was there -- measured at
424x439, eight items ending at y=470 with the fold at 439,
and worse when the cut lands on a row boundary, where the
sheet ends in a clean edge that reads as the end of the list.
Two layers, and the *order* is what asks the question: a
shadow pinned to the bottom of the box (attachment scroll),
and over it a cover of the sheet's own colour painted at the
end of the *content* (attachment local), which therefore
scrolls up over the shadow and hides it exactly when there is
nothing more to see. So the affordance is absent on a menu
that fits, present the moment one does not, and gone again at
the end of the list -- with no scroll listener, no
measurement, and nothing reaching into wa-dialog's shadow
root for the scroller. background-attachment is Chrome 4;
the reference device is Chrome 113.
**The curve is steep because the rows under it stay live.**
A scrim over a menu item is that item's text surface, and
this app's rule is that text clears 4.5:1 on every surface it
can sit on -- which the light ramp, whose bgElevated is
#e9ecef, is what makes non-theoretical. A row is 48px with
its label centred, so 32px of scrim that is already down to
a quarter strength at 14px reaches y-centre at about 0.06 and
spends its weight on the strip below the last legible label.
Measured on the dark ramp at x=300, flat 52,58,64 throughout
before: 50,56,62 at y=330, 33,37,40 at y=350 and 22,24,27 at
the bottom edge, and flat again at the end of the list. The
light ramp puts 9.9:1 on the last label. */
so the body is what gives. */
wa-dialog::part(body) {
padding: 0;
overflow-y: auto;
background:
linear-gradient(
var(--yj-bg-elevated, #343a40),
var(--yj-bg-elevated, #343a40)
)
bottom / 100% 32px no-repeat local,
linear-gradient(
to top,
rgba(0, 0, 0, 0.6) 0%,
rgba(0, 0, 0, 0.25) 45%,
rgba(0, 0, 0, 0) 100%
)
bottom / 100% 32px no-repeat scroll;
}
/* A sheet is dragged at with a thumb, so it says where its top
@@ -1333,8 +1333,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 {
@@ -1354,11 +1360,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 {
@@ -1368,6 +1376,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;
@@ -579,8 +579,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 {
@@ -595,6 +601,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);
+17 -2
View File
@@ -95,8 +95,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 +115,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;
@@ -514,8 +514,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 {
@@ -531,6 +537,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;
@@ -1199,8 +1199,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 {
@@ -1237,6 +1245,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)
);
}
@@ -206,56 +206,6 @@ describe('menu-surface', () => {
expect(dismissed, 'no menu-dismiss reached the document').toBe(1);
});
/**
* The scroll affordance (#207), and this is the mechanism again
* rather than the symptom.
*
* The sheet's body has scrolled since #60 and said nothing about
* it: measured at 424x439, eight items ended at y=470 with the
* fold at 439, and where the cut lands on a row boundary the sheet
* ends in a clean edge that reads as the end of the list.
*
* What makes the fade *conditional* — absent on a menu that fits,
* present the moment one does not, gone again at the end of the
* list — is `background-attachment`, not a scroll listener: a cover
* of the sheet's own colour is painted at the end of the content
* and attached `local`, over a shadow pinned to the box and
* attached `scroll`. So the pair of attachments *is* the feature,
* and it is what this asserts. The rendered result was measured in
* the harness (dark ramp 52,58,64 flat before; 52,57,63 at the last
* label and 22,24,27 at the bottom edge with more below; flat again
* at the end of the list) and is on the PR.
*/
it('paints the fade only while there is more below', async () => {
const el = await surfaceWithPanel();
const wrapper = el.shadowRoot?.querySelector('wa-dialog');
await (wrapper as HTMLElement & { updateComplete: Promise<unknown> })
.updateComplete;
const body = wrapper?.shadowRoot?.querySelector('[part~="body"]');
expect(body, 'no body part to scroll').not.toBeNull();
const style = getComputedStyle(body as Element);
expect(style.overflowY, 'the body is what gives, not the cap').toBe(
'auto',
);
// The cover scrolls with the content; the shadow does not. Either
// one alone is a fade that is always there or never there.
expect(
style.backgroundAttachment,
'the cover must be local and the shadow must not',
).toBe('local, scroll');
// Both sit at the bottom, or the cover hides nothing.
expect(style.backgroundPosition).toBe('50% 100%, 50% 100%');
expect(style.backgroundSize).toBe('100% 32px, 100% 32px');
});
/**
* A dialog with no accessible name is what `utils/name-dialog.ts`
* exists for; here the name is already written on the panel, so no
@@ -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/);
}
});
});