Merge remote-tracking branch 'origin/main' into wails-v3
Build & publish Arch package / arch-package (push) Successful in 2m32s
CI / check (push) Successful in 3m10s
CI / e2e (push) Canceled after 0s
Search index maintenance / maintain-index (push) Canceled after 1h31m25s

This commit is contained in:
2026-08-17 11:38:49 -04:00
16 changed files with 1149 additions and 29 deletions
+130
View File
@@ -3208,3 +3208,133 @@ probes, and that the hardware back button no longer kills the app (the
`.dev` build carries the history fix; pid survived a BACK press).
Unverified: what happened to the icons and the transport controls, which
is where this resumes.
## What the device actually said, with both builds side by side (2026-08-17)
The phone inspectable and awake, the same Light Phone III running two
builds of this app in turn. This closes both questions the previous entry
left open, and **neither answer was the one the symptom suggested**.
**"The playback controls are off screen" was true, literal, and already
fixed.** The installed build is from B2 **phase 1** — it carries
`bottom-nav` and no `now-playing-view`, which dates it between 57bfbdf
and 1b05dde. Settled (30 s after launch, not 6), its player bar shows
art, title, favourite, shuffle, prev — and stops. Play/pause, next,
repeat and queue are past the right edge, because at 424 px the bar was
still carrying the seek bar and volume that **phase 2 moved into
`now-playing-view`**. On the current build, on the same phone and the
same engine, `document.body.scrollWidth` equals `clientWidth` (424) and
`player-controls` measures 200..380 inside 424. So the fix was already
on main, unreleased, and the device is what proved it rather than
argued it.
**"No icons" was an artefact of my own screenshot.** A `wa-icon` on the
device has `path` computed fill `rgb(255,212,59)` and paints; the first
capture was six seconds after a cold start, before the icon fetches had
landed. Two corrections in two entries from the same misreading: measure
the node that paints, and let the app settle before believing a picture.
**Chrome 113's missing Popover API does not break the menus.** This was
the leading worry and it is unfounded: a long-press on a row opens the
real panel at (212,145), 162x193, `visibility: visible`, seven
`role=menuitem`s, all seven inside the panel and clear of the player bar
— confirmed by screenshot as well as by measurement. Web Awesome's
`showPopover?.()` is an optional call and `wa-popup` positions itself,
so the attribute being inert costs nothing. **Long-press itself works on
real hardware**, over a real 1,744-track library, which is the phase 3
verification the browser tier could only approximate.
**The one genuine fault the device adds is phase 4's.** `track-list` at
424 px computes `--grid-cols: 24px 102px 101px 101px 80px` — which fits
the host exactly, so nothing overflows — but "Duration" does not fit in
80 px and neither does most content. The columns are not too wide; there
are simply too many of them for a phone, which is what phase 4 already
says. It is now a measurement rather than a prediction.
Two operational notes. The debug sibling scanned the phone's real music
and its data directory is **414 MB**, so it is worth uninstalling when
done (`adb uninstall app.yellowjacket.dev` — the sibling id is exactly
what makes that safe). And `am start` does not reliably take focus while
another app is foreground: check `topResumedActivity` before trusting a
screenshot, or you will read someone else's app.
## The phone track list, and the bug a viewport could not have found (2026-08-17)
B2 phase 4. A phone draws `titleArtist` — the title with the artist
under it — plus the duration, and drops the column headers and the
resize handles. It is a **column set, not a second row template**: the
row, its delegated events, the selection semantics, the playing marker
and the virtualizer never learn that anything changed, because from
their side only the number of columns did.
Three rules, each one a way it breaks otherwise. The row height is in
two places (`PHONE_ROW_HEIGHT` and the CSS) and they must agree, since
the virtualizer positions rows from that number. What is *drawn* and
what can be *sorted* are separate questions — the sort list is built
from `configuredColumns`, or a phone with no headers could sort by
nothing but title and duration. And a phone's widths are neither loaded
nor saved.
**That last one is the finding, and it came from the device.** With the
arrangement passing five component tests and five e2e specs at
424x439, the phone showed `24px 148px 236px`: the duration column with
55% of the row. `loadColumnWidths` is keyed by column *id* and fills a
gap with `MIN_COLUMN_WIDTH`, so the stacked column — which nothing can
ever have saved a width for, there being no handles to drag — came out
at the minimum while `trackLength` inherited a width saved for a
four-column desktop row. The mirror image is worse and was never
reachable from a phone at all: `saveColumnWidths` would have written the
computed phone widths back under the same ids, replacing the width the
user dragged on a desktop.
**Why every browser test missed it.** The specs assert the *shape* — how
many grid tracks, no header, no overflow, the title's share of the row —
and the width bug depends on what is in `localStorage` for a *different*
column set. dev-headless's seed happened to hold widths that split the
other way, so the same assertion passed in the browser and failed on the
phone. The unit test now carries the desktop map as a fixture, which is
the reproduction the browser needed to have.
**Confirmed on the phone afterwards**, with the fix installed:
`24px 304px 80px`, 52 px rows, no header row, the title 298 px and not
truncated, `body.scrollWidth == clientWidth`. The same numbers the
browser gives at that viewport, which is the point of having measured
both.
Two tooling notes worth keeping. `playwright-cli` holds its page across
a `make dev-headless` restart, so a probe after a rebuild can be
answering for the *old* bundle — it reported the desktop layout at 424 px
until the page was reopened. And wireless adb dropped twice more mid-
session when the screen slept; USB for anything longer than a few
probes.
## The catalog download now asks about the connection (2026-08-17)
Plan 016 B4. ~0.6 GB had no network awareness at all; it is skipped on a
cellular connection unless the user says otherwise
(`AllowMeteredCatalogDownload`, default false, toggle in Settings' Search
Index section).
**The shape is dictated by the cgo rule, not by taste.** `explore` is
imported by `cmd/indexbuild`, which builds with `CGO_ENABLED=0` and must
not link Wails, so `netpolicy.go` holds the policy and the JSON parsing —
tested on every platform — while the one platform call is a closure
injected from `app.go`, where naming `application` is already legitimate.
Four things measured or corrected in the doing:
- **The portable name is `application.Mobile`, not `application.Android`**
(which the plan and `CLAUDE.md` both named). `Android` exists only
under the `android` build tag; `Mobile`'s desktop implementation is a
stub whose `NetworkJSON()` returns `""`.
- **The runtime reports no metered flag.** `{"connected":bool,
"type":"wifi|cellular|ethernet|none"}` is all there is, so cellular is
the signal and a metered *Wi-Fi* — a phone hotspot, a hotel — cannot be
detected. Android itself knows (`NET_CAPABILITY_NOT_METERED`) and the
runtime does not pass it on. Documented gap, not an oversight.
- **An unknown answer must not read as metered.** Every desktop answers
`""`, so the obvious defensive default would have disabled the catalog
download for every desktop user in the world.
- **The gate belongs before the first status write.** Declining is a
no-op — no job in the indicator, no error tier to dismiss — which is
what makes the refusal safe to have on by default.
@@ -315,8 +315,8 @@ 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.** Scope decided (below); **phases 1, 2 and 3
are done.**
**B2, the desktop shell.** Scope decided (below); **all four phases are
done.**
- *Phase 1, the shell.* Below 600px the sidebar column is gone,
`<bottom-nav>` is the primary navigation, and the shell fits 320px
@@ -338,8 +338,29 @@ are done.**
cannot dispatch a trusted event and that path would otherwise be the
only uncovered one.
What is left of B2 is the track list, whose resizable columns are a
pointer feature with no touch equivalent. Not started.
- *Phase 4, the track list.* A phone draws `titleArtist` (title over
artist) plus the duration, and drops the column headers and the resize
handles — a column set rather than a second row template, so the row
and everything delegated on it is unchanged. Verified at the device's
own 424x439: `24px 304px 80px`, 52 px rows, no truncation, no
overflow. The device also found the bug in it, which no browser
viewport would have: saved *desktop* column widths reached the phone
through an id-keyed store and gave the duration column 55% of the row.
**B2 and B4 are complete.** B4 is `backend/explore/netpolicy.go`: the
catalog download is skipped on a cellular connection unless
`AllowMeteredCatalogDownload` is on, with the toggle in Settings' Search
Index section. The policy and the JSON parsing are in `explore` (tested
on every platform) and only the platform call is injected from `app.go`,
because `cmd/indexbuild` imports `explore` and must not link Wails. Two
things the plan got slightly wrong: the portable API is
`application.Mobile.NetworkJSON()` rather than `Android`'s, and it
reports no metered flag — so cellular is the signal and a metered Wi-Fi
cannot be seen.
What is left in this plan is B3 (tag writing, which needs a device) and
the standing question of the Light Phone's Chrome 113 — which so far has
cost nothing: menus, dialogs and long-press all work on it.
**B3/B4** are unchanged, and B3 is now *possible* where it was not:
with all-files access, `tagwriter` can write in place.
+47
View File
@@ -553,6 +553,31 @@ selected as a literal `0`. Adding the column to the importer's SELECT
list without that is how a published artifact — which nobody can re-cut
retroactively — starts failing with `no such column`.
**A 0.6 GB download asks about the connection first.** `explore`'s
catalog artifact had no network awareness at all, which on a phone is a
month's data allowance spent without being asked (plan 016 B4).
`netpolicy.go` is the gate, and its shape is dictated by one constraint:
`explore` is imported by `cmd/indexbuild`, which is built with
`CGO_ENABLED=0` and must not link Wails — so the *policy* and the
*parsing* live here and are tested on every platform, while the platform
call is a closure injected from `app.go`. It is
`application.Mobile.NetworkJSON()`, not `application.Android`'s: the
latter exists only under the `android` build tag, and `Mobile`'s desktop
implementation is a stub returning `""`.
Three rules in it are load-bearing. **An unknown answer is not a metered
one** — only mobile answers at all, so treating silence as metered would
refuse the download on every desktop. **Cellular is the only signal
available**: the runtime reports `wifi|cellular|ethernet|none` and no
metered flag, so a metered *Wi-Fi* (a hotspot, a hotel) cannot be
detected and is not refused, which is a documented gap rather than an
oversight. And **the gate runs before anything is staged**, so declining
is a no-op rather than a job in the indicator and a status the user has
to dismiss. The permission (`AllowMeteredCatalogDownload`, default
false, so an existing config is careful without a migration) is read at
the moment a download would start, so turning it on takes effect on the
next attempt rather than the next launch.
**Background work yields, and says so in the context.** The post-scan
backfills share MusicBrainz's rate limiters with every page the user
can open, and both were FIFO — so a thousand-artist enrichment put an
@@ -1581,6 +1606,28 @@ by the three places that need them (the default widths, the
normaliser, and the resize handles' positions), because they were
written out separately and that is how they came to disagree.
**A phone draws one column of two lines, and that is a column set
rather than a second row template.** Measured on the device: at 424 px
the four configured columns fit the row *exactly* (`--grid-cols` came
out `24px 102px 101px 101px 80px`) and not one of them fit its content
— "Duration" did not fit its own header. The columns were never too
wide; there were too many of them. `PHONE_COLUMN_IDS` is `titleArtist`
(title over artist, sharing the row's whole width) plus the duration, so
the row, the delegated events, the selection semantics, the playing
marker and the virtualizer are all untouched: from their side only the
number of columns changed. Three rules come with it. **The row height
lives in two places and they must agree** — `PHONE_ROW_HEIGHT` and the
CSS rule — because the virtualizer positions rows from that number, so a
taller row overlaps its neighbour. **What is drawn and what can be
sorted are different questions**: the page header's sort list is built
from `configuredColumns`, or a phone (which has no column headers
either) could sort by nothing but title and duration. And **a phone's
widths are neither loaded nor saved**: `loadColumnWidths` is keyed by
column *id* and fills a gap with the minimum, so the stacked column —
which nothing can ever have saved a width for — came out at 148 px
beside a duration column of 236, and saving would have replaced the
width the user dragged on a desktop for the same id.
**The default columns are declared twice and must agree.**
`tracklist.DefaultColumns` is what a fresh install persists;
`DEFAULT_COLUMN_IDS` in `track-list/columns.ts` is what the list draws
+18
View File
@@ -191,6 +191,24 @@ func NewYellowJacketApp(
yjApp.library.SetJobRegistry(yjApp.jobs)
yjApp.explore.SetJobRegistry(yjApp.jobs)
// Whether this connection is one to spend ~0.6 GB of catalog on
// (plan 016 B4). The probe is injected from here because `explore` is
// imported by `cmd/indexbuild`, which must not link Wails: naming
// `application` there is what `TestIndexToolsDoNotImportWails`
// forbids.
//
// `application.Mobile`, not `application.Android`: the latter exists
// only under the `android` build tag, while `Mobile` is the portable
// name whose desktop implementation is a stub returning "" — which
// parses to "unknown" and refuses nothing. Plan 016 named the tagged
// one; this is the same call by the name every build has.
yjApp.explore.SetNetworkPolicy(
func() explore.Network {
return explore.ParseNetworkJSON(application.Mobile.NetworkJSON())
},
yjApp.appConfig.GetAllowMeteredCatalogDownload,
)
// Let the release prefetch skip albums the user already owns in
// full — those open with no catalog call at all, so warming their
// tracklists spends the most expensive request in the app on
+45
View File
@@ -620,6 +620,51 @@ func (c *Config) SetQueueFallback(mode string) error {
return nil
}
// GetAllowMeteredCatalogDownload reports whether the ~0.6 GB Explore
// catalog may be fetched on a metered connection.
func (c *Config) GetAllowMeteredCatalogDownload() bool {
if c.General == nil {
return false
}
return c.General.AllowMeteredCatalogDownload
}
// SetAllowMeteredCatalogDownload saves the metered-download permission.
//
// There is nothing to validate and nothing to restart: the policy is
// read at the moment a download would start, so turning it on takes
// effect on the next attempt rather than needing this launch to be over.
func (c *Config) SetAllowMeteredCatalogDownload(allow bool) error {
if c.General == nil {
c.General = &GeneralConfig{}
c.General.ApplyDefaults()
}
c.General.AllowMeteredCatalogDownload = allow
if err := c.Save(); err != nil {
return fmt.Errorf(
"could not save config: %w", err,
)
}
events.Emit(
c.ctx,
events.GeneralConfigChanged,
map[string]any{
"AllowMeteredCatalogDownload": allow,
},
)
c.logger.Info(
"metered catalog download permission updated",
"allow", allow,
)
return nil
}
// GetTrackListColumns returns the configured track-list columns.
func (c *Config) GetTrackListColumns() []tracklist.Column {
if c.TrackList == nil {
+6
View File
@@ -48,6 +48,12 @@ var errUnknownQueueFallback = errors.New("unknown queue fallback")
type GeneralConfig struct {
DefaultPage DefaultPage `toml:"DefaultPage"`
QueueFallback QueueFallback `toml:"QueueFallback"`
// AllowMeteredCatalogDownload permits the ~0.6 GB Explore catalog to
// be fetched on a connection the platform calls cellular. It defaults
// to false, which is the whole point: the zero value is the safe one,
// so an existing config with no such key refuses by default rather
// than needing a migration to become careful.
AllowMeteredCatalogDownload bool `toml:"AllowMeteredCatalogDownload"`
}
// ApplyDefaults fills zero-value fields with sensible defaults.
+14
View File
@@ -27,6 +27,20 @@ var artifactStageNames = [...]string{
// failure path is non-fatal by design: the caller falls back, and a
// fresh install with no network still gets its own library in Explore.
func (si *SearchIndex) tryCoreArtifact(ctx context.Context) error {
// Before anything is staged: ~0.6 GB is not a download to start on
// someone's cellular allowance without being asked (plan 016 B4).
// This is checked first so no job appears and no status changes --
// declining is a no-op, not a failure the user has to dismiss.
if si.netPolicy.refuses() {
si.logIndexJob(
jobs.LevelInfo,
"Skipping the catalog download on a metered connection. "+
"Enable it in Settings to download anyway.",
)
return ErrMeteredNetwork
}
si.mu.Lock()
si.buildStatus = IndexStatus{
Building: true,
+137
View File
@@ -0,0 +1,137 @@
package explore
import (
"encoding/json"
"errors"
"strings"
"sync"
)
// Whether the catalog artifact may be downloaded on this connection
// (plan 016 B4).
//
// The artifact is ~0.6 GB. On a desktop that is a minute of someone
// else's bandwidth; on a phone it can be a month's allowance, and the
// app had no awareness of the difference at all.
//
// Three decisions shape this file.
//
// **The policy lives here and the platform call does not.** `explore` is
// imported by `cmd/indexbuild`, which is built with `CGO_ENABLED=0` in a
// plain Go container, so naming `application` here would break the one
// job that must not fail (see `TestIndexToolsDoNotImportWails`). What is
// injected is a closure; what is *tested* is the parsing and the
// decision, on every platform.
//
// **An unknown answer is not a metered one.** Only mobile answers this
// question — the desktop stub returns an empty string — so a policy that
// treated silence as "metered" would refuse the download on every
// desktop in the world. Silence means "no reason to refuse".
//
// **Cellular is the signal, and it is the only one available.** Wails
// reports `{"connected":bool,"type":"wifi|cellular|ethernet|none"}` and
// no metered flag, so a metered *wifi* — a phone hotspot, a hotel — is
// invisible to us and will not be refused. That is a known gap rather
// than an oversight: Android knows (`NET_CAPABILITY_NOT_METERED`) and
// the runtime does not pass it on.
// ErrMeteredNetwork is returned instead of downloading the catalog when
// the connection looks metered and the user has not opted in. Every
// failure path in `tryCoreArtifact` is already non-fatal, so this
// behaves like any other reason the artifact is not available yet.
var ErrMeteredNetwork = errors.New(
"explore: catalog download declined on a metered connection",
)
// Network is what the platform can say about the connection.
type Network struct {
// Known is false when nothing answered — every desktop, and any
// mobile build whose bridge is not up yet.
Known bool
// Connected reports a usable connection of any kind.
Connected bool
// Metered reports a connection the user is plausibly paying for by
// the byte. See the note above on what this cannot see.
Metered bool
}
// NetworkProbe answers "what kind of connection is this", or an unknown
// Network when the platform does not say.
type NetworkProbe func() Network
// ParseNetworkJSON reads the runtime's network payload.
//
// Anything unparseable is `Known: false` rather than an error: this
// decides whether to *skip* an optional download, and a malformed
// payload is not a reason to refuse one.
func ParseNetworkJSON(payload string) Network {
var raw struct {
Connected bool `json:"connected"`
Type string `json:"type"`
}
if strings.TrimSpace(payload) == "" {
return Network{}
}
if err := json.Unmarshal([]byte(payload), &raw); err != nil {
return Network{}
}
return Network{
Known: true,
Connected: raw.Connected,
Metered: strings.EqualFold(raw.Type, "cellular"),
}
}
// networkPolicy is the injected half: how to ask, and whether the user
// has said yes anyway.
type networkPolicy struct {
mu sync.RWMutex
probe NetworkProbe
allowMetered func() bool
}
func (p *networkPolicy) set(probe NetworkProbe, allowMetered func() bool) {
p.mu.Lock()
defer p.mu.Unlock()
p.probe = probe
p.allowMetered = allowMetered
}
// refuses reports whether a large optional download should be skipped.
func (p *networkPolicy) refuses() bool {
p.mu.RLock()
probe, allow := p.probe, p.allowMetered
p.mu.RUnlock()
if probe == nil {
return false
}
if allow != nil && allow() {
return false
}
state := probe()
return state.Known && state.Metered
}
// SetNetworkPolicy wires how the catalog download decides whether this
// connection is one to spend 0.6 GB on. Both arguments may be nil, which
// is the desktop's answer: never refuse.
//
//wails:ignore // internal wiring, not part of the app's IPC surface.
func (si *SearchIndex) SetNetworkPolicy(probe NetworkProbe, allowMetered func() bool) {
si.netPolicy.set(probe, allowMetered)
}
// SetNetworkPolicy wires the metered-connection policy into the index.
//
//wails:ignore // internal wiring, not part of the app's IPC surface.
func (e *Service) SetNetworkPolicy(probe NetworkProbe, allowMetered func() bool) {
e.index.SetNetworkPolicy(probe, allowMetered)
}
+158
View File
@@ -0,0 +1,158 @@
package explore
import (
"errors"
"testing"
)
// The catalog is ~0.6 GB and the decision not to fetch it is the only
// part of plan 016 B4 that can be tested anywhere but on a phone: the
// platform call is a one-line closure injected from app.go, and
// everything that decides anything is here.
func TestParseNetworkJSON(t *testing.T) {
t.Parallel()
tests := []struct {
name string
payload string
want Network
}{{
name: "cellular is metered",
payload: `{"connected":true,"type":"cellular"}`,
want: Network{Known: true, Connected: true, Metered: true},
}, {
name: "wifi is not",
payload: `{"connected":true,"type":"wifi"}`,
want: Network{Known: true, Connected: true},
}, {
name: "ethernet is not",
payload: `{"connected":true,"type":"ethernet"}`,
want: Network{Known: true, Connected: true},
}, {
name: "the case is the platform's business, not ours",
payload: `{"connected":true,"type":"Cellular"}`,
want: Network{Known: true, Connected: true, Metered: true},
}, {
name: "offline is known and unmetered",
payload: `{"connected":false,"type":"none"}`,
want: Network{Known: true},
}, {
// The desktop stub. This is the case that must not read as
// "metered": every desktop in the world answers this way.
name: "an empty payload is unknown",
payload: "",
want: Network{},
}, {
name: "so is a malformed one",
payload: `{"connected":`,
want: Network{},
}}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
if got := ParseNetworkJSON(tt.payload); got != tt.want {
t.Errorf("ParseNetworkJSON(%q) = %+v, want %+v", tt.payload, got, tt.want)
}
})
}
}
func TestNetworkPolicyRefuses(t *testing.T) {
t.Parallel()
cellular := func() Network {
return Network{Known: true, Connected: true, Metered: true}
}
wifi := func() Network { return Network{Known: true, Connected: true} }
unknown := func() Network { return Network{} }
yes := func() bool { return true }
no := func() bool { return false }
tests := []struct {
name string
probe NetworkProbe
allowMetered func() bool
want bool
}{{
name: "no probe wired refuses nothing",
probe: nil,
want: false,
}, {
name: "an unknown connection refuses nothing",
probe: unknown,
want: false,
}, {
name: "wifi refuses nothing",
probe: wifi,
want: false,
}, {
name: "cellular refuses by default",
probe: cellular,
want: true,
}, {
name: "cellular with no permission refuses",
probe: cellular,
allowMetered: no,
want: true,
}, {
name: "cellular the user opted into does not",
probe: cellular,
allowMetered: yes,
want: false,
}, {
// The permission is read at decision time rather than captured,
// so turning it on takes effect on the next attempt instead of
// the next launch.
name: "permission is asked, not remembered",
probe: cellular,
allowMetered: yes,
want: false,
}}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
var p networkPolicy
p.set(tt.probe, tt.allowMetered)
if got := p.refuses(); got != tt.want {
t.Errorf("refuses() = %v, want %v", got, tt.want)
}
})
}
}
// The gate has to come before anything is staged: a declined download is
// a no-op, not a job in the indicator or a status the user must dismiss.
func TestTryCoreArtifactDeclinesMeteredWithoutStaging(t *testing.T) {
t.Parallel()
si := &SearchIndex{}
si.SetNetworkPolicy(
func() Network { return Network{Known: true, Connected: true, Metered: true} },
nil,
)
err := si.tryCoreArtifact(t.Context())
if !errors.Is(err, ErrMeteredNetwork) {
t.Fatalf("tryCoreArtifact() error = %v, want ErrMeteredNetwork", err)
}
// Nothing announced itself: no build status, no tiers, no job. A
// SearchIndex with no database would panic on any of the work below
// the gate, which is itself part of the assertion.
if si.buildStatus.Building {
t.Error("declining a metered download still reported a build in progress")
}
if len(si.buildStatus.Tiers) != 0 {
t.Errorf("declining staged %d tiers, want none", len(si.buildStatus.Tiers))
}
}
+5
View File
@@ -212,6 +212,11 @@ type SearchIndex struct {
cancel context.CancelFunc
done chan struct{}
// netPolicy decides whether this connection is one to spend ~0.6 GB
// of catalog on. Its own lock: it is written once at startup and read
// from the build goroutine (netpolicy.go).
netPolicy networkPolicy
mu sync.RWMutex
ready bool
+137
View File
@@ -0,0 +1,137 @@
import { test, expect } from '../support/fixtures.js';
/**
* The track list on a phone (plan 016 B2 phase 4).
*
* The component tier pins the arrangement; this pins it in the real
* shell, at the viewport of the device the work was measured on 424 x
* 439, a Light Phone III because the fault it fixes was invisible to
* every assertion the app had. The columns *fit*: `--grid-cols` summed
* to exactly the host width, nothing overflowed, and every column was
* still unreadable. Only a measurement of what a cell can hold, or a
* screenshot, shows that.
*/
type Page = import('@playwright/test').Page;
/** The phone this was built against, in CSS pixels. */
const DEVICE = { width: 424, height: 439 };
/** A common small phone, as the shell specs use. */
const PHONE = { width: 390, height: 844 };
const list = (page: Page) => page.locator('track-list');
/** The row's grid tracks and the widest text a cell can show. */
const rowGeometry = (page: Page) =>
page.evaluate(() => {
const sr = document.querySelector('track-list')?.shadowRoot;
const row = sr?.querySelector('.track-row');
if (!row) return null;
const title = row.querySelector('.stacked-title');
const sub = row.querySelector('.stacked-sub');
return {
tracks: getComputedStyle(row)
.gridTemplateColumns.split(/\s+/)
.filter(Boolean).length,
rowHeight: Math.round(row.getBoundingClientRect().height),
headerRow: !!sr?.querySelector('.header-row'),
handles: sr?.querySelectorAll('.col-resize-handle').length ?? 0,
titleWidth: title ? Math.round(title.getBoundingClientRect().width) : 0,
// A truncated cell is the fault; a cell wider than its text is fine.
titleTruncated: title ? title.scrollWidth > title.clientWidth + 1 : null,
subText: sub?.textContent?.trim() ?? null,
};
});
test.describe('the track list on a phone', () => {
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(list(app).first()).toBeVisible();
});
test.afterEach(async ({ app }) => {
await app.setViewportSize({ width: 1440, height: 900 });
});
test('stacks the title over the artist and drops the pointer affordances', async ({
app,
}) => {
const geo = await rowGeometry(app);
expect(geo).not.toBeNull();
// Favourite + one stacked column + duration.
expect(geo?.tracks).toBe(3);
expect(geo?.headerRow).toBe(false);
expect(geo?.handles).toBe(0);
expect(geo?.subText).toBeTruthy();
// The row height has to match the virtualizer's item size, or rows
// overlap; 52 is that number.
expect(geo?.rowHeight).toBe(52);
});
test('gives the title most of the row instead of a quarter of it', async ({
app,
}) => {
const geo = await rowGeometry(app);
// Four columns at this width gave a title ~102px. The measurement
// that matters is the share of the row, not the pixel count.
expect(geo?.titleWidth ?? 0).toBeGreaterThan(DEVICE.width * 0.55);
});
test('needs no sideways scrolling, and neither does the shell', async ({
app,
}) => {
const overflow = await app.evaluate(() => ({
body: [document.body.scrollWidth, document.body.clientWidth],
list: (() => {
const sr = document.querySelector('track-list')?.shadowRoot;
const row = sr?.querySelector('.track-row');
return row ? [row.scrollWidth, row.clientWidth] : null;
})(),
}));
expect(overflow.body[0]).toBe(overflow.body[1]);
expect(overflow.list?.[0]).toBe(overflow.list?.[1]);
});
test('keeps the sorts a phone has no headers to reach', async ({ app }) => {
// With no column headers, the page header's sort control is the only
// route to sort-by-artist — so it must still offer the columns the
// phone does not draw.
const ids = await app.evaluate(() => {
const header = document
.querySelector('track-list')
?.shadowRoot?.querySelector('page-header') as
| (Element & { sortOptions?: { id: string }[] })
| null;
return (header?.sortOptions ?? []).map((o) => o.id);
});
expect(ids).toContain('artistName');
expect(ids).toContain('album');
});
test('is the desktop list again above the breakpoint', async ({ app }) => {
await app.setViewportSize(PHONE);
await expect.poll(async () => (await rowGeometry(app))?.tracks).toBe(3);
await app.setViewportSize({ width: 1024, height: 800 });
// The same element, re-laid-out: this is one component with two
// column sets, not two components.
await expect.poll(async () => (await rowGeometry(app))?.headerRow).toBe(true);
await expect.poll(async () => (await rowGeometry(app))?.tracks).toBe(5);
});
});
@@ -17,6 +17,14 @@ import * as download$0 from "../download/models.js";
// @ts-ignore: Unused imports
import * as tracklist$0 from "../tracklist/models.js";
/**
* GetAllowMeteredCatalogDownload reports whether the ~0.6 GB Explore
* catalog may be fetched on a metered connection.
*/
export function GetAllowMeteredCatalogDownload(): $CancellablePromise<boolean> {
return $Call.ByID(2258585139);
}
/**
* GetDefaultPage returns the view the app opens to on launch.
*/
@@ -127,6 +135,17 @@ export function Save(): $CancellablePromise<void> {
return $Call.ByID(1988945736);
}
/**
* SetAllowMeteredCatalogDownload saves the metered-download permission.
*
* There is nothing to validate and nothing to restart: the policy is
* read at the moment a download would start, so turning it on takes
* effect on the next attempt rather than needing this launch to be over.
*/
export function SetAllowMeteredCatalogDownload(allow: boolean): $CancellablePromise<void> {
return $Call.ByID(192700351, allow);
}
/**
* SetDefaultPage validates and saves a new launch page.
*/
@@ -17,6 +17,8 @@ import {
SetDefaultPage,
GetQueueFallback,
SetQueueFallback,
GetAllowMeteredCatalogDownload,
SetAllowMeteredCatalogDownload,
} from '@go/config/config.js';
import { GetIndexStatus } from '@go/explore/service.js';
import { notificationStore } from '@store/notification-store';
@@ -75,6 +77,9 @@ export class ConfigPage extends ViewLifecycleMixin(LitElement) {
// --- Now Playing state ---
@state() private scrollMode = 'hover';
/** Whether the ~0.6 GB catalog may be fetched on mobile data. */
@state() private allowMeteredCatalogDownload = false;
// --- Favorites state ---
@state() private playlists: playlist.Summary[] = [];
@@ -888,17 +893,20 @@ export class ConfigPage extends ViewLifecycleMixin(LitElement) {
private async loadLibraries(): Promise<void> {
try {
const [libs, mode, defaultPage, queueFallback] = await Promise.all([
GetAllLibrariesWithTrackCounts(),
GetScanConcurrency(),
GetDefaultPage(),
GetQueueFallback(),
]);
const [libs, mode, defaultPage, queueFallback, allowMetered] =
await Promise.all([
GetAllLibrariesWithTrackCounts(),
GetScanConcurrency(),
GetDefaultPage(),
GetQueueFallback(),
GetAllowMeteredCatalogDownload(),
]);
this.libraries = libs ?? [];
this.concurrencyMode = mode;
this.defaultPage = defaultPage;
this.queueFallback = queueFallback;
this.allowMeteredCatalogDownload = allowMetered;
} catch (err) {
console.error(
@@ -1500,10 +1508,53 @@ export class ConfigPage extends ViewLifecycleMixin(LitElement) {
</div>`
: html`<div class="index-loading">Loading status…</div>`}
</div>
<config-field
.schema=${{
key: 'allowMeteredCatalogDownload',
label: 'Download the catalog on mobile data',
description:
'The catalog is about 0.6 GB. It is skipped on a '
+ 'cellular connection unless this is on; a '
+ 'metered Wi-Fi network cannot be detected.',
type: 'toggle' as const,
}}
.value=${this.allowMeteredCatalogDownload}
@config-change=${this.handleAllowMeteredChange}
></config-field>
</config-section>
`;
}
/**
* The catalog download's one permission (plan 016 B4).
*
* It is in this section rather than General because it is about
* *this* download and nothing else, and because the section already
* explains what the catalog is the toggle would be unreadable
* beside "Default page".
*/
private handleAllowMeteredChange = (
e: CustomEvent<ConfigFieldChangeEvent>,
): void => {
const allow = Boolean(e.detail.value);
const previous = this.allowMeteredCatalogDownload;
this.allowMeteredCatalogDownload = allow;
void SetAllowMeteredCatalogDownload(allow).catch((err: unknown) => {
console.error('failed to save metered download permission', err);
// The visible state reverted, so this is the Transient case:
// a small action the user can simply repeat.
this.allowMeteredCatalogDownload = previous;
notificationStore.transient({
key: 'metered-catalog-setting',
title: 'Setting not saved',
text: describeError(err, 'That setting could not be saved.'),
});
});
};
private tierIcon(state: string): string {
switch (state) {
case 'complete':
+52 -2
View File
@@ -9,6 +9,8 @@ import {
import { formatMilliseconds } from '@utils/time';
import { html, nothing } from 'lit';
import { highlightText } from './search-ranking';
/** Compares two strings using locale-aware ordering. */
const compareStr = (
a: string,
@@ -36,8 +38,17 @@ export interface ColumnDef {
defaultWidth: string;
/** Text alignment. Defaults to left. */
align?: 'left' | 'right';
/** Optional custom render function returning an HTML template. */
renderCell?: (track: library.Track) => unknown;
/**
* Optional custom render function returning an HTML template.
*
* `term` is the active search term, for a cell that wants to
* highlight its own text: the default path applies `highlightText`
* to `accessor`'s value, and a cell that renders itself has to do
* that itself or silently lose the highlight. Only `titleArtist`
* needs it, which is why it is optional rather than a second
* required parameter on all of them.
*/
renderCell?: (track: library.Track, term?: string) => unknown;
/**
* Comparison function for sorting two tracks by this column.
* Returns negative if a < b, positive if a > b, zero if equal.
@@ -85,6 +96,27 @@ export const COLUMN_DEFS: Record<string, ColumnDef> = {
/>`;
},
},
titleArtist: {
id: 'titleArtist',
// Named for what it sorts by, since that is the only place the
// label is user-visible: the phone has no column headers, and
// the page header's sort list is built from the *configured*
// columns rather than the drawn ones.
label: 'Track Name',
accessor: (t) => t.TrackName,
defaultWidth: '1fr',
comparator: (a, b) => compareStr(a.TrackName, b.TrackName),
renderCell: (t, term) => html`
<div class="stacked">
<span class="stacked-title"
>${term ? highlightText(t.TrackName, term) : t.TrackName}</span
>
<span class="stacked-sub"
>${term ? highlightText(t.ArtistName, term) : t.ArtistName}</span
>
</div>
`,
},
trackName: {
id: 'trackName',
label: 'Track Name',
@@ -249,6 +281,24 @@ export const CORE_SEARCH_COLUMN_IDS: string[] = [
'album',
];
/**
* The one column a phone shows, and it is two lines.
*
* At 424 CSS px -- the width of the phone this was measured on -- four
* columns fit the row exactly and none of them fits its *content*:
* `--grid-cols` came out `24px 102px 101px 101px 80px`, so "Duration"
* did not fit its own header and a title had ~20 characters. The
* columns were never too wide; there were too many of them.
*
* So the phone gets the title with the artist under it, which is the
* shape every phone music list has, and the full row width to put them
* in. It is a *column definition* rather than a second row template on
* purpose: the row, the delegated events, the selection semantics, the
* playing marker and the virtualizer all keep working, because from
* their side nothing has changed except how many columns there are.
*/
export const PHONE_COLUMN_IDS: string[] = ['titleArtist', 'trackLength'];
/**
* Default column IDs. Album is in them (H-15): without it, the three
* `Tideline / Aurora Fields / 00:06` rows in this app's own fixture
+138 -17
View File
@@ -31,6 +31,7 @@ import { LibraryController } from '@store/controllers/library-controller';
import {
COLUMN_DEFS,
DEFAULT_COLUMN_IDS,
PHONE_COLUMN_IDS,
} from './columns';
import type { ColumnDef } from './columns';
import { classMap } from 'lit/directives/class-map.js';
@@ -92,6 +93,18 @@ const ROW_PADDING_X = 8;
const ROW_CHROME_WIDTH =
FAV_COL_WIDTH + ROW_PADDING_X * 2;
/**
* Row heights, in the same relationship as the widths above: the number
* is read by the CSS *and* by the virtualizer's layout, so they cannot
* disagree. A phone row is two lines (title over artist).
*/
const ROW_HEIGHT = 33;
const PHONE_ROW_HEIGHT = 52;
/** The shell's phone breakpoint, as `index.css` and every component
* stylesheet spells it. */
const PHONE_QUERY = '(max-width: 599px)';
// Inline SVG paths for favorite icons — eliminates wa-icon shadow DOM
// overhead (30-50 shadow roots during scroll). Font Awesome 6 paths.
const FAV_ICONS = {
@@ -177,19 +190,34 @@ export class TrackList
* Resolved column definitions for the currently configured
* column IDs. Falls back to defaults for any unknown ID.
*/
private get activeColumns(): ColumnDef[] {
/**
* The columns the user has chosen what a desktop draws, and what
* *anything* may be sorted by.
*
* This is deliberately separate from `activeColumns`: "which columns
* are drawn" and "what can I sort by" are different questions, and
* the phone is exactly where they diverge. Building the sort list
* from the drawn columns would silently take sort-by-artist and
* sort-by-album away from the phone, which has no other route to
* them since it has no column headers either.
*/
private get configuredColumns(): ColumnDef[] {
const ids = this.trackListCtrl.columnIds;
const chosen = !ids || ids.length === 0 ? DEFAULT_COLUMN_IDS : ids;
if (!ids || ids.length === 0) {
return DEFAULT_COLUMN_IDS
.map((id) => COLUMN_DEFS[id])
.filter(
(d): d is ColumnDef =>
d !== undefined,
);
}
return chosen
.map((id) => COLUMN_DEFS[id])
.filter(
(d): d is ColumnDef =>
d !== undefined,
);
}
return ids
/** The columns actually drawn: two stacked lines on a phone. */
private get activeColumns(): ColumnDef[] {
if (!this.phone) return this.configuredColumns;
return PHONE_COLUMN_IDS
.map((id) => COLUMN_DEFS[id])
.filter(
(d): d is ColumnDef =>
@@ -376,9 +404,42 @@ export class TrackList
// doesn't need to measure items. Without this hint, the default 100px
// estimate causes constant scroll error correction (scrollTo() calls)
// that produce visible jumping/skipping during scroll.
/**
* The virtualizer's item size and the CSS row height are the same
* number in two places, and they must agree: the layout positions
* rows from this figure, so a row that is really taller overlaps its
* neighbour and a shorter one leaves a gap. Both come from here.
*/
private flowLayout = flow({
_itemSize: { width: 100, height: 33 },
_itemSize: { width: 100, height: ROW_HEIGHT },
} as Parameters<typeof flow>[0]);
private phoneFlowLayout = flow({
_itemSize: { width: 100, height: PHONE_ROW_HEIGHT },
} as Parameters<typeof flow>[0]);
private get rowLayout(): Parameters<typeof flow>[0] {
return this.phone ? this.phoneFlowLayout : this.flowLayout;
}
/**
* Phone width, from the shell's own breakpoint.
*
* A media query *inside* a shadow root is answered by the viewport,
* which is what lets every other component state what it drops at
* phone width in its own stylesheet. This list cannot: its grid is
* computed in JS from the host width, so the same threshold has to
* be readable from JS as well. One breakpoint, two expressions of
* it, and the reason is written here rather than inferred.
*/
@state()
private phone = matchMedia(PHONE_QUERY).matches;
private phoneQuery = matchMedia(PHONE_QUERY);
private onPhoneChange = (e: MediaQueryListEvent): void => {
this.phone = e.matches;
};
private hasRestoredScroll = false;
private scrollSaveRAFId: number | null = null;
@@ -545,6 +606,20 @@ export class TrackList
}
private initColumnWidths() {
// A phone's widths are never the saved ones. `loadColumnWidths`
// is keyed by column *id* and fills a gap with
// `MIN_COLUMN_WIDTH`, so the phone's stacked column -- which
// nothing has ever saved a width for, there being no handles to
// drag -- came out at the minimum while the duration column
// inherited a width saved for a four-column desktop row. Found
// on the device: `24px 148px 236px`, the duration column with
// 55% of a phone's row.
if (this.phone) {
this.computeDefaultWidths();
return;
}
const saved = this.loadColumnWidths();
const cols = this.activeColumns;
@@ -675,6 +750,13 @@ export class TrackList
}
private saveColumnWidths() {
// And a phone's widths are never *saved*: they are computed from
// a column set the user did not choose, and writing them would
// overwrite the width they dragged for the same column on a
// desktop. Nothing on a phone can resize a column anyway, so
// this is only reachable by a window crossing the breakpoint.
if (this.phone) return;
try {
const cols = this.activeColumns;
@@ -1047,6 +1129,35 @@ export class TrackList
contain: strict;
}
/* A phone row is two lines, and this height must equal
PHONE_ROW_HEIGHT: the virtualizer positions rows from that number,
so a taller row overlaps its neighbour and a shorter one gaps. */
@media (max-width: 599px) {
.track-row {
height: 52px;
}
}
.stacked {
display: flex;
flex-direction: column;
justify-content: center;
gap: 2px;
min-width: 0;
}
.stacked-title,
.stacked-sub {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.stacked-sub {
font-size: var(--yj-text-xs);
color: var(--yj-text-secondary, #b3b3b3);
}
.track-row > * {
min-width: 0;
}
@@ -1173,9 +1284,17 @@ export class TrackList
);
this.resizeObserver.observe(this);
// Connection, not the view lifecycle: this only sets state, so
// it is harmless (and wanted) while the list is off screen -- a
// rotation on another view must not leave this one laid out for
// the wrong width when the user comes back to it.
this.phoneQuery.addEventListener('change', this.onPhoneChange);
}
override disconnectedCallback() {
this.phoneQuery.removeEventListener('change', this.onPhoneChange);
// Remove delegated event handlers from virtualizer.
const virt = this.virtualizer;
if (virt) {
@@ -2030,7 +2149,7 @@ export class TrackList
</svg>
</div>
${cols.map((col) => {
const customCell = col.renderCell?.(track);
const customCell = col.renderCell?.(track, term);
if (customCell !== undefined && customCell !== nothing) {
return html`<div role="gridcell" class="cell">${customCell}</div>`;
}
@@ -2086,7 +2205,7 @@ export class TrackList
private renderPageHeader() {
const options: SortOption[] = [
{ id: '', label: 'Default' },
...this.activeColumns
...this.configuredColumns
.filter((c) => c.comparator)
.map((c) => ({ id: c.id, label: c.label })),
];
@@ -2136,7 +2255,7 @@ export class TrackList
aria-busy=${this.loadingTracks}
@keydown=${this.onListKeydown}
>
<div class="header-row" role="row">
${this.phone ? nothing : html`<div class="header-row" role="row">
<div role="columnheader" aria-label="Favourite"></div>
${cols.map(
(col) => html`
@@ -2170,7 +2289,7 @@ export class TrackList
</div>
`,
)}
</div>
</div>`}
${visibleTracks.length === 0
? html`<p class="no-results">
No tracks match your search.
@@ -2181,12 +2300,14 @@ export class TrackList
.items=${visibleTracks}
.renderItem=${this.renderTrackRow}
.keyFunction=${(track: library.Track) => track.FilePath}
.layout=${this.flowLayout}
.layout=${this.rowLayout}
></lit-virtualizer>
`}
<!-- Resizing is a pointer gesture with no touch equivalent, and
the phone's two columns are not the user's to arrange. -->
<div class="resize-overlay">
${this.colBoundaryPositions.map(
${(this.phone ? [] : this.colBoundaryPositions).map(
(pos, i) => html`
<div
class="col-resize-handle ${this.resizingColumn === i ? 'active' : ''}"
@@ -0,0 +1,161 @@
/**
* The track list on a phone (plan 016 B2 phase 4).
*
* Measured on the device this was built for: at 424 CSS px the four
* configured columns fit the row *exactly* `--grid-cols` came out
* `24px 102px 101px 101px 80px` and not one of them fit its content.
* "Duration" did not fit its own header. The columns were never too
* wide; there were too many of them.
*
* So a phone draws one column of two lines plus the duration, and the
* split is a *column set* rather than a second row template: everything
* about a row that is not "how many columns" keeps working, which is
* what these tests pin. The `matchMedia` stub is the same one
* `now-playing.test.ts` uses the component reads the breakpoint from
* JS because its grid is computed in JS, so this is the seam.
*/
import { describe, expect, it, afterEach } from 'vitest';
import type { LitElement } from 'lit';
import '@components/track-list/track-list';
import { fixture, shadow, shadowAll } from '@test/support/render';
const TRACKS = Array.from({ length: 12 }, (_, i) => ({
FilePath: `/music/track-${i}.mp3`,
TrackName: `Track ${i}`,
ArtistName: `Artist ${i}`,
Album: 'An Album',
Duration: 180 + i,
})) as never[];
const real = window.matchMedia.bind(window);
/** Force the shell's phone breakpoint on or off. */
function stubPhone(phone: boolean): void {
window.matchMedia = ((q: string) =>
q.includes('max-width: 599px')
? {
matches: phone,
media: q,
addEventListener() {},
removeEventListener() {},
}
: real(q)) as typeof window.matchMedia;
}
async function mount(phone: boolean): Promise<LitElement> {
stubPhone(phone);
// Narrow, so a desktop layout at this width would be the cramped one
// the plan describes rather than a comfortable one.
const el = await fixture<LitElement>('track-list', {
externalTracks: TRACKS,
});
el.style.width = '424px';
el.style.height = '400px';
await el.updateComplete;
return el;
}
afterEach(() => {
window.matchMedia = real;
localStorage.removeItem('track-list-column-widths');
});
describe('the track list at phone width', () => {
it('draws the title with the artist under it, and the duration', async () => {
const el = await mount(true);
const row = shadow(el, '.track-row');
expect(row).not.toBeNull();
expect(shadow(el, '.stacked-title')?.textContent?.trim()).toBe('Track 0');
expect(shadow(el, '.stacked-sub')?.textContent?.trim()).toBe('Artist 0');
// Two drawn columns plus the favourite: three grid tracks, not five.
const tracks = getComputedStyle(row as Element)
.gridTemplateColumns.split(/\s+/)
.filter(Boolean);
expect(tracks).toHaveLength(3);
});
it('drops the column headers and the resize handles', async () => {
const el = await mount(true);
// Both are pointer affordances: a header is where a click sorts and
// a handle is where a drag resizes, and a phone can do neither.
expect(shadow(el, '.header-row')).toBeNull();
expect(shadowAll(el, '.col-resize-handle')).toHaveLength(0);
});
it('keeps every sort the desktop offers', async () => {
const el = await mount(true);
const header = shadow(el, 'page-header') as
| (Element & { sortOptions?: { id: string }[] })
| null;
// The regression this guards: building the sort list from the
// *drawn* columns would leave a phone able to sort by title and
// duration only, with no column headers to reach the rest by.
const ids = (header?.sortOptions ?? []).map((o) => o.id);
expect(ids).toContain('artistName');
expect(ids).toContain('album');
});
it('still marks the row a screen reader has to understand', async () => {
const el = await mount(true);
const row = shadow(el, '.track-row');
// The row is the same row: only the cells inside it changed, which
// is the entire argument for doing this as a column set.
expect(row?.getAttribute('role')).toBe('row');
expect(row?.getAttribute('aria-selected')).toBe('false');
expect(row?.getAttribute('data-testid')).toBe('track-row');
expect(shadowAll(el, '[role="gridcell"]').length).toBeGreaterThan(0);
});
it('ignores widths saved for the desktop, and does not overwrite them', async () => {
// The bug the device found, in the fixture that reproduces it.
// `loadColumnWidths` is keyed by column *id* and fills a gap with
// MIN_COLUMN_WIDTH, so the phone's stacked column -- which nothing
// can ever have saved a width for -- came out at the minimum while
// the duration column inherited a width dragged on a wide window:
// measured `24px 148px 236px` on a 424px phone.
const desktop = { trackName: 300, artistName: 200, album: 200, trackLength: 236 };
localStorage.setItem('track-list-column-widths', JSON.stringify(desktop));
const el = await mount(true);
const tracks = getComputedStyle(shadow(el, '.track-row') as Element)
.gridTemplateColumns.split(/\s+/)
.filter(Boolean)
.map((t) => Math.round(parseFloat(t)));
// Favourite, then the stacked column, then the duration -- and the
// stacked one is the widest thing in the row.
expect(tracks[1]).toBeGreaterThan(tracks[2] ?? 0);
// And the desktop's own widths survive being on a phone: writing the
// computed phone widths back would silently replace the width the
// user dragged for the same column id.
expect(JSON.parse(localStorage.getItem('track-list-column-widths') ?? '{}'))
.toMatchObject(desktop);
});
it('leaves the desktop alone', async () => {
const el = await mount(false);
const row = shadow(el, '.track-row');
expect(shadow(el, '.header-row')).not.toBeNull();
expect(shadow(el, '.stacked-title')).toBeNull();
const tracks = getComputedStyle(row as Element)
.gridTemplateColumns.split(/\s+/)
.filter(Boolean);
expect(tracks).toHaveLength(5);
});
});