UXS-014: Interaction patterns (consumer)¶
- Status: Active (foundational — applies to every consumer surface)
- PRD:
docs/prd/PRD-043-knowledge-layer.md(and all consumer PRDs) - RFC:
docs/rfc/RFC-102-knowledge-clusters-entity-cards.md - Inherits: UXS-011 (Editorial Bold tokens,
--lp-*), UXS-012 (Home), UXS-013 (knowledge).
This UXS is the shared contract for how the consumer app behaves and is styled across surfaces. It exists because the operator demanded strict consistency: define patterns and styles once, apply them app-wide; never restyle the same affordance per page. New UXS docs and components conform to this spec — they do not re-invent navigation, layering, or saving.
Surfaces — the four kinds and when to use each¶
| Surface | What | Examples |
|---|---|---|
| Page | A route; URL-addressable destination | Home, Search, Catalog, Player, Library |
| Panel | Persistent, in-layout region; not modal | Episode notes panel beside the Player |
| Modal | One dimmed backdrop; teleported to <body> |
Interests picker, entity card from Search |
| Sheet | The mobile form of a panel/modal (bottom, drag-handle) | Insights on mobile |
Core rule — never stack two dimmed layers¶
- Drilling deeper inside a panel replaces the panel's content in place with a
‹ Back(a back-stack), never a new overlay. Example: tapping a person/topic chip in Insights swaps the panel to the entity card;‹ Backreturns to the insight list. - A modal opens only from a page-level surface, never on top of a panel/sheet. So the entity card is in-panel from Insights but a modal from Search (a page).
- At most one backdrop on screen. If you would dim a second layer, use replace-in-place instead.
Entry-point → surface map¶
| You tap … | … here | Result |
|---|---|---|
| Person/topic chip | Insights panel | Replace-in-panel (entity card, ‹ Back) |
| Entity match | Search page | Modal entity card |
| "Set interests" | Home page | Modal picker |
| A "see all" link | any | Navigate to a page |
Layering mechanics¶
- Every overlay
Teleports to<body>so it covers the viewport and escapes any clipped or transformed ancestor (a panel sheet usesoverflow-hidden/offsets that otherwise clip a nestedposition: fixed). - z-scale: panel
z-40, modalz-50, mobile backdropz-30.
Headers & navigation¶
- Header order is
‹ Back(own row) → kicker → title. The entity card header mirrors the episode-detail masthead exactly — back never crammed beside the kicker/name. - Navigation reads differently from content labels. A
‹ Backcontrol is muted (.lp-nav), never accented; navigation is not a thing you act on. The two must contrast.
Shared style classes — define once, use everywhere¶
Recurring affordances are single classes in web/learning-player/src/style.css, not per-element Tailwind
hand-rolled on each page. Adding a one-off class="text-muted …" for one of these is a regression.
| Class | Role |
|---|---|
.lp-kicker |
Editorial eyebrow / content label (mono, muted, uppercase) — e.g. a show name. The instrument voice: measured metadata, never interactive. (#2013) |
.lp-section |
Section/region heading (calm display heading) — never the kicker, so a section title can't be mistaken for a show name |
.lp-speaker |
Speaker attribution in transcript / quotes (muted, normal-case) — distinct from the kicker |
.lp-nav |
Back / navigation control (muted; distinct from content) |
.lp-fav |
Favorite (heart) toggle; .lp-fav--on = saved. The only toggle that spends the accent on hover/press. |
When a new recurring treatment appears, add one class and reuse it — do not copy styles between pages.
Amended #2013 — The kicker is NOT accent. The table previously read
.lp-kickeras "(accent, uppercase)". The prior design shipped with the kicker accented on 55 call sites across 25 components, so orange became the app's label colour, and when everything is accented, nothing is. A blind design critic's finding ("the accent has lost its meaning") was confirmed by measured analysis (158 accent usages, 30 decorative). The kicker is a label you cannot tap — it carries metadata about content (show name, duration, timestamp) using the instrument voice (mono, letterspaced caps, muted), the same treatment every measured value carries. This is enforced bysrc/__checks__/accent-discipline.test.ts. The accent now means "you can act on this" only — it is spent on focus rings, exclusive-choice toggles in their selected state, and toggle affordances in hover/pressed states. See UXS-011 decision #2013.
Show names never truncate — where the layout has room. In a full-width row, a list item, or a header, a podcast/show name wraps to the next line rather than ellipsising. (Episode titles may still clamp; show names do not.)
Scoped #1604. The rule as originally written was unqualified, and it is incompatible with uniform grid rows: in a fixed-width tile, a name that wraps freely makes the row as tall as its longest member, which is the exact defect #1584 was filed to fix. Something has to bound the label.
So the rule now holds where width is elastic, and in fixed-width grid or rail tiles a show name may clamp — but only with a reserved height (so rows stay uniform) and a
titleattribute (so the full name stays reachable). SeeShowTile.vue.This is recorded because I broke the rule before scoping it: #1584 added
truncateto Recommended's show kicker to stop it wrapping and undoing the reserved height, resolving the conflict silently in the code. That is the behaviour the drift audit exists to prevent, so the conflict is written down here instead.
Drill-in navigation¶
- Drilling deeper is replace-in-place with a
‹ Backstack; closing returns to the prior view in the same surface (no layer added or removed). - The shared body (e.g.
EntityCardBody) is rendered inline in a panel and wrapped in the modal from a page — one component, two presentations (variant), so they cannot drift.
Dismissal & accessibility (every modal)¶
- Dismiss via ESC, backdrop tap, and an explicit control — all three.
role="dialog"+aria-modal, a focus trap, initial focus, and restore focus on close. In-panel replacements move focus to the new heading instead of trapping.- The explicit control is
CloseIcon— one shared inline-SVG ✕, used by every sheet, panel and modal (entity card, storyline, queue, interests picker, knowledge panel, player). It is drawn, not typed: it used to be the character✕(U+2715), which is absent from the iOS UI font and rendered as a tofu box on device, so every dismiss control read as "?" (2026-09-16). Adding emoji/symbol faces to the font stacks did not fix it. The rule this sets: an icon that carries meaning is an SVG, never a codepoint — the back chevron‹(U+2039) stays a character only because it is verified to render. Same conclusion as the profile edit badge and the streak mark.
Sharing (#2036)¶
One Share affordance (ShareMenu), a menu of three modes, never a single action:
- Share card — an editorial PNG of the entity (quote-led, mono + one accent, square, the app's
own type), rendered client-side (
entityShareCard) and shared via Web Share → download. The card is the "short, beautiful overview"; it carries transcript-derived text + KG metadata only, never audio (bridge-only). - Share link — the entity's canonical URL (Web Share → clipboard copy). It unfurls as the card
via a server-rendered
og:image(below), so a pasted link previews as the card even with no Share menu involved. - Share text — the caption fallback (name + stat line + wordmark).
Closes on ESC / outside-click. Wired on the entity card (topic/person/org), the episode (PlayerView), the show (PodcastView) and the storyline (StorylineView).
Server OG-image (link unfurl). GET /og/{kind}/{id}.png (routes/app_og.py) renders the same
card server-side with Pillow (server/og/), and server/spa.py (SpaStaticFiles) injects
og:image/og:title/twitter:* into each entity document's head. The route is unauthenticated
(unfurl bots carry no session) and lives outside /api/app; the .png suffix lets the edge's
static rule reach the backend without the coming-soon gate. Kinds: topic, person, organization,
episode, show, storyline. The server card layouts (full-bleed episode background, framed square,
guest gallery, KPI trend tile) are richer than the client canvas card — kept in step by eye; the
SSOT is docs/uxs/UXS-017-share-cards.md.
Tab strips and option groups (#1594 item 7)¶
Tabs.vue — the only tab strip. Seven hand-written ones preceded it and none was complete; the two
rules every copy missed are the two that are invisible unless you are already navigating by keyboard.
Which pattern. The question is not what it looks like, it is what it controls:
- switches between distinct panels →
pattern="tabs":role="tablist"/tab/tabpanel,aria-selected, and each tab'saria-controlsnaming its panel. Library, Browse, Home discovery. - re-parameterises one region →
pattern="radio":role="radiogroup"/radio,aria-checked, and noaria-controlsat all. Search scope, entity-card corpus scope, trend window, Your Week layout. These were all marked up as tablists, and none of them had a panel to point at — arole="tab"whosearia-controlsnames nothing is a dangling promise, worse than the missing linkage it would have replaced.
Roving tabindex (both patterns). Exactly one option is in the page tab order; the arrows move between them and selection follows focus. Plain buttons are usable — you can Tab to each one — so nothing looks broken; it just costs a five-tab strip five Tab presses instead of one, and the arrow keys do nothing. Wraps at both ends; Home/End jump to the extremes.
Tab ↔ panel ids come from tabId()/panelAttrs() in components/tabs.ts, so both ends of the
pair are generated from one prefix and cannot silently disagree. A panel carries tabindex="0": one
holding no focusable element of its own is a dead end for a keyboard user.
Three visual variants (underline, segment, pill) are kept on purpose — an underline is a
page-level section switcher, a pill a compact in-card control. One component, not one appearance.
.lp-segment-option is styled from the ARIA state, and matches BOTH aria-selected='true' and
aria-checked='true'. Keying it off one attribute is how the Your Week preference kept working and
quietly stopped looking selected when it became the radiogroup it should always have been.
src/__checks__/tabs-single-implementation.test.ts fails the build on an eighth hand-rolled strip.
Folding a long panel (CollapsibleSection)¶
The Knowledge Panel's spine — Summary, Key points, Topics & People, Insights, More like this — is long: ~8 key points of ~200 characters, and up to 36 insight rows. Folding is how you reach the part you came for without scrolling past everything else.
- Native
<details>. Keyboard operation, the disclosure role and the expanded-state announcement come from the element. Rebuilding those with a div and a ref is where a11y bugs live. - Open by default, always. Collapsing by default hides the substance behind a tap nobody asked for; the panel's job is to show it. Folding is an escape hatch, not the resting state.
- The count rides in the header —
Insights · 8. A folded section must still say what it holds, or folding costs you the knowledge that it exists. - The Summary never folds. It is the reason the panel was opened, and it is one paragraph: folding it saves nothing and hides the one thing everyone wants.
- State is per USER, not per episode (
lp.kp.<key>). "Don't show me related episodes" is a preference about the panel; keying it per episode would ask the same question on every episode. - Storage failure falls back to OPEN. A preference we cannot persist is not a reason to hide content.
Cards vs tiles — match the shape to the container¶
Two components, and the choice is not stylistic:
EpisodeCardis a horizontal ROW: artwork column, text column beside it. Correct in a vertical list, where the row is as wide as the page — Podcast, Queue, the Queue panel's recently-played.EpisodeTilestacks: artwork on top at full slot width, then actions, then a full-width title clamped to three lines. Correct in a horizontal RAIL, where each slot is narrow.EpisodeRowis the COMPACT row — a small thumbnail, title, and show kicker linking to the player, top-aligned, with a#trailingslot for a row action. It is the one idiom for the dense episode lists inside a card or sheet (the entity card, the storyline sheet, the Knowledge Panel's "More like this"), where the fullEpisodeCard's summary column would be noise.EntityEpisodeListis the "discussed in N episodes" LIST that every entity surface renders — topic, storyline, person, org. It owns two things: theEpisodeRowstack, and the cap. Ten rows, then a full-width control that adds ten more, re-collapsing when the entity changes because these surfaces drill in place.
The cap is the point. The four surfaces each rendered their own uncapped list, so a topic with sixty episodes emitted sixty rows and pushed the conversation arc, the perspectives and the notes somewhere no reader reaches (operator 2026-09-19). The four had also drifted: three stated "newest first" under the heading and the storyline did not. A shared component is how that stops recurring — the next entity surface inherits the behaviour instead of re-deciding it.
The HEADING stays with the caller, because each words its own ("Discussed in N episodes", "In N episodes", and the person card switches between two depending on whether it is showing host episodes). The list owns the rows, the cap, and the paging.
The summary window — full lines only, and always a way to the rest¶
EpisodeCard's summary is clipped by a WINDOW whose height comes from the artwork column, not by a
fixed line count: a fixed line-clamp-4 stopped the text short of the artwork's bottom and left dead
space beside the picture. Two rules govern what that window may do.
It may not cut a line in half. The artwork column has no reason to be a whole multiple of the
prose's line-height, so overflow: hidden sliced the last line through the middle of the glyphs and
left a strip of half-letters above "Read more" (operator 2026-09-23). The card measures the window,
divides by the computed line-height, and clamps the prose to the number of lines that actually FIT —
so the text ends on a real line, with an ellipsis, and the cut reads as deliberate rather than as a
rendering fault. The clamp is applied to the prose, which is absolutely positioned inside the window,
so it changes what is drawn and never a height — it cannot feed back into the observer that set it.
"Read more" appears wherever text is actually cut — including COMPACT cards. It was withheld from compact outright, so the queue's recently-played list showed prose visibly truncated with no way to reach the rest — the same complaint that put the toggle on the full card in the first place. The toggle is offered when the prose is measurably clipped, never merely because a summary exists; and expanding RELEASES the clamp, or "Read more" would open onto text still cut at the same line.
ShowRowisEpisodeCard's shape with a show's content — 128px artwork with the episode count and the surface's controls beneath it, the name and description filling the right. It is the one show row: Discover → Shows (list view) and Library → Saved both render it, differing only through its#actionsslot.
A show and an episode are the same kind of thing to a reader — cover art, a name, a line about it, something to open — so a list of shows must not read as a different species from the list of episodes one tab across. It did: Discover used a 44px thumbnail with a title and a count, Library used a bare line of text, and neither resembled the episode rows beside them (operator 2026-09-17). Two representations of one object, both unlike the thing they sat next to.
The browsable lists share one compact control, ToolbarMenu (operator 2026-09-14): a small
trigger that opens a vertical option menu with the active choice ticked, replacing native selects and
the old two-button grid⇄list toggle. The grid⇄list switch is now one such circle showing the ACTIVE
view (grid is artwork-first, list is title-first and denser) — tap to reveal both and switch; sort is
a ↑↓ circle; the filter facet is a chip showing its current value. Collapsing sort + view into little
circles is what buys the search its width back. Each carries the 44px lp-tap hit box.
Putting a row card in a rail slot is the failure this rule exists for. "More like this" did exactly that: the text column got ~100px of a 224px slot, one real title wrapped to eight lines, the slot grew to ~800px tall, and the action row — positioned against the card's top-right — floated over the artwork. Nothing errored; it just looked broken and wasted most of the vertical space.
A narrow slot drops things, and says so. No summary: at 176px a truncated fragment is the shape
of a summary rather than one, and the title earns the space. Actions are the shared minimum row
(EpisodeActions — favourite · queue · ⋯; see "Item actions" below), not a per-tile subset:
three 32px targets sit at a non-overlapping gap-[12px] pitch. Download and add-to-collection are in
the ⋯ overflow, not inline. (This supersedes the earlier "two actions, not four" tile rule, which
predated the shared action row.)
Actions go below the artwork in a tile. ShowTile overlays a single follow button deliberately
and that works for one; two icons over episode art is crowding.
Item actions — the standard set, overflow, and per-surface context¶
The minimum action set was hand-rolled per surface, so rails carried only favourite+queue, Home's What's-new / Recommended were missing favourite and download, and add-to-queue lived only on the player. This section is the single contract; components conform, they do not re-decide per page.
Save ≠ Follow — two different actions.
- Favorite = save to Library. ONE affordance, the
.lp-favheart, everywhere an item can be saved (episode, and any saveable entity). Never a pill, never a second glyph. All saves land in Library › Saved. - Follow = subscribe to a show (or interest token). The follow pill (
+ Follow/✓ Following), rendered/behaving identically wherever it appears. It is not a save; the two are never merged and the episode heart is never swapped for a follow pill.
One glyph per concept (operator 2026-09-27)¶
Three save-ish marks exist, and each belongs to exactly one destination. A glyph that appears in two of these rows is a bug, not a style choice.
| Glyph | Concept | Scope | Store |
|---|---|---|---|
heart .lp-fav |
favourite | a WHOLE object — episode, show, topic, person | favorites |
bookmark HighlightToggle |
highlight / capture | a FRAGMENT — a transcript span, an insight, a timestamp | capture → /api/app/highlights |
2×2 board grid AddToCollectionButton |
file into a named board | anything, including a highlight | collections |
This had to be written down because the app had broken it in both directions at once, and the operator found it from the outside — "we can favourite insights and bookmark parts of transcript, feels inconsistent":
- The heart meant two things. The Knowledge panel's insight save rendered
FavoriteButtonin acontrolledvariant and announced "Save to favorites", while writing an insight HIGHLIGHT through the capture store.services/types.tsalready carried the comment "Saveable favorite kinds.insightis NOT one — an insight is a capture" (#1593 banned it). So the data layer was right and the interface said the opposite, out loud, to a screen reader. The identical action one panel away — saving a transcript line — drew a bookmark. - The bookmark meant two things.
AddToCollectionButtondrewM6 3v18l6-4 6 4V3z, andCaptureMomentin the player transport draws the same shape for mark-a-moment. The operator read the transport's capture control as a stray add-to-collection button and asked for it to be deleted as a duplicate. It is not one: on a phone it is the ONLY way to mark a moment, because the masthead's copy ishidden lg:inline-flex. A glyph collision came within one instruction of removing a feature.
The remedies are structural, not cosmetic. FavoriteButton's controlled variant is deleted
rather than left unused — while a parent could own the state, the heart could be reattached to a
non-favourite store again, which is exactly how this happened. The insight save and the transcript
line now render from one component. And collections took a new glyph, because it was the one
borrowing rather than the one being borrowed from.
Choosing the collections mark — the rule the process produced. A folder was drawn first and rejected on sight: it is the filesystem's metaphor, and the product calls these Boards (RFC-119: "pinboards"). Wrong idea before it was a wrong drawing. Candidates were then rendered at 16px — the size that actually ships in a card row — sat beside the heart and the bookmark, because the only question that matters is whether a mark is instantly not the other two. Two findings worth keeping:
- The
+was the cost, not the shape. Every "add" glyph tested got busier for it, and it buys nothing: the pill variant already reads "+ Collection" in words and the icon variant carriescollections.addToas its accessible name. Same conclusion the folded-corner-plus-plus glyph reached on 2026-09-13 — reached twice now, which is why the guard asserts its absence. - Legibility at the shipping size beats the better metaphor. Offset stacked cards say "a set kept together" more precisely and were the first recommendation; but their meaning is the overlap, and at 16px the overlap mushes into a thick square. Four separated cells keep air between the strokes. Rendered and compared before choosing rather than argued.
Grid-means-app-launcher is a convention imported from other software, not a collision here — checked against the compass (Browse) and the 4-bar (Library) at 16px in the muted state they share.
"Can I favourite an insight / collect a transcript line?" — favouriting a fragment stays banned
(#1593): a favourite is about a whole object. Collecting one already works, in two honest steps —
highlight it, then add the highlight to a board (kind: highlight is a first-class collection
item). Do not add a second control to a fragment to shortcut that; add it to the board from the
highlight.
The shared minimum row (EpisodeActions). Every episode surface shows favourite · queue inline
plus a ⋯ overflow carrying download · add-to-collection, via the one component. Two inline + ⋯ is
120px and fits the artwork-width card column in one row; four inline (176px of 44px targets) wrapped
to a second row, and shrinking below the 44px floor (#1594) is barred, so the fix is to collapse not
shrink (operator 2026-09-13). Download self-hides on web (DownloadButton is native-only), so on web
the ⋯ carries add-to-collection alone. The top-level count is a uniform three (favourite/queue/⋯)
across web and native — parity, not a per-surface omission.
Overflow (⋯) where space is tight — one component, OverflowMenu (teleported, role="menu",
keyboard-roaming, Escape/outside-click dismiss). Primary actions sit inline; anything that does not fit is
pulled into a ⋯ menu — one extra tap, never a dropped capability. Secondary/detail actions live
there by default (add-to-collection, add-note, share, mark-as-played). Roomy surfaces (detail rows,
the player) may inline more before overflowing; dense tiles/rails inline the primaries only and
overflow the rest.
Per-surface context — a surface never shows the "add-to-X" action for the X it already is. That action inverts to a remove or drops. Everything below follows from that one principle plus the density rule.
| Surface | Favorite | Queue | Download | ⋯ overflow |
|---|---|---|---|---|
| Home rails / Browse / Search / detail episode-lists | add | add | native | add-to-collection, add-note, share |
| Library › Saved | see OPEN-1 | add | native | remove |
| Queue › Up next | add | remove (inverted — you are in the queue) | INLINE, native | add-to-collection, reorder ↑/↓ |
| Queue › Recently played | add | in the ⋯ |
native (in the ⋯) |
queue, download, add-to-collection |
| Downloaded list | add | add | downloaded → delete state | … |
| Collection detail | add | add | native | add-to-collection for this collection omitted |
| Player (current episode) | add | n/a (it is playing) → mark-as-played | native | add-to-collection inline (roomy) |
Two per-surface levers on the row, and they are the same lever (operator 2026-09-23). Both are applications of the density rule above, not exceptions to it — the row stays at most three wide, and what occupies the slots changes with the question the surface is answering.
showDownloadpromotes download out of the⋯(Up next). There, "is this on the device?" IS the question — you are looking at what you are about to play, often right before losing signal — and it was two taps and a menu away, so the answer was invisible. Native-only by construction, so on web this adds nothing.
The glyph says what the control IS; the colour says whether it is ON. Downloaded keeps the download arrow and takes the accent border and stroke — the same language the queue toggle uses one slot to its left. It first swapped to a bare check, and that was wrong: a tick scanned down a list reads as "selected" or "done" rather than "on your device", and the control changed identity between its two states so it no longer matched its neighbour (operator 2026-09-23).
It sits LAST, after the surface's own controls. The artwork-width column wraps at three, so
ending row one on the ⋯ and row two on download makes the pair read as a grid — ♡ ⧉ ⋯ above
↑ ↓ ⬇ — instead of a row that overflowed. Placed before the ⋯ it pushed the overflow onto the
second line, leaving the first row ending on a control that is not the "more" affordance, which is
where the eye goes looking for it.
- hideQueue demotes the queue toggle into the ⋯ (Recently played). That list exists to FIND
and resume something you heard, not to re-queue it, so the queue toggle is the wrong primary
action there. Demoted, never deleted — it is still the only way to queue something you just
finished. It also buys back a slot the compact card badly needed: an 80px column holds two 32px
targets, so three wrapped the ⋯ onto its own line under the artwork.
Both mirror hideFavorite exactly (Library › Saved), and the rule they share is worth stating
plainly: a control that is wrong as a PRIMARY action on a surface moves into the ⋯; it never
disappears. Dropping it would remove a capability; leaving it inline would spend a slot saying
something the surface already says.
Recently played states WHEN, with the clock. Each row carries the last-played stamp under the artwork as date and time of day. The list was already ordered by that timestamp and then refused to show it, so two sittings with the same show were indistinguishable. This is deliberately unlike a publish date, which is rendered as a day alone — a feed's date IS a day, and adding 00:00 to it would invent precision the data never had.
OPEN-1 — RESOLVED (RFC-121): Library favorite = keep the heart, inverted. On Library the heart shows saved-state truth and is one-tap unfavorite — not dropped. This matches invert-don't-drop (Queue→remove, Downloaded→delete) and satisfies "no add on Library". The redundant
⋯ removein that row is dropped; the confirm-on-authored rule (below) makes one-tap unfavorite safe on noted items.OPEN-2 — RESOLVED (RFC-121): one "Saved" concept over two identity classes. "Favorite" and "Highlight" become one user-facing concept (the
.lp-favheart); the word "Highlight" leaves the UI. But Saved is not one record shape — it is one concept over class A singletons keyed(kind, ref)(episode/show/topic/person/storyline, toggleable) and class B captures keyed byid(insight/moment/span — today's highlight, kept). A moment cannot live in(kind, ref), so a favorite-with-a-moment IS a class-B record. #1593 is preserved, not broken: the insight heart routes to the existing capture/highlights write path (re-skin, not re-plumb), andPUT /favoritesgets a 422 onkind=insightso the banned second write-path cannot return. Notes/colour become optional extras on any save. Full model, phased plan, and migration (read-layer only, no on-disk migration):docs/rfc/RFC-121-unified-saved-model.md.
Notes (NoteComposer)¶
One reusable composer for a free-text note on any target — episode, highlight, insight, and the
entity kinds (show / topic / person / storyline). It lists the target's existing notes with their
timestamp, adds/removes them through the capture store (auth-gated like every per-user write), and
offers voice dictation via the built-in Web Speech API — but only when the device-scoped
voice-input setting is ON (default OFF) and the platform exposes SpeechRecognition. Placed at
the foot of the Knowledge Panel (episode notes), on the topic/person card and the show page, and
listed alongside collections in the Library Collections tab.
Storyline page (StorylineView)¶
A storyline (topics discussed together — co-occurrence) is a full page (/storyline/:id,
keyed by the anchor topic id), not a sheet: same detail template as the topic/person page — back on
its own row, title + follow-storyline on one row, then the member topics (ordered), top episodes,
the people involved — as Top voices, the topic card's own avatar grid (TopVoices, UXS-013),
not chips — and notes. All three pages share ONE page gutter (16px, px-4): the topic and
person pages get theirs from the card they host (EntityCardBody pads its own header and body), so
their page wrapper adds none — adding one too gave them a doubled 32px gutter until 2026-09-30.
There is no storyline endpoint; the anchor topic's card carries it, so the route param is the
anchor topic id. See UXS-013 §Vocabulary — the backend calls this a
"theme cluster", which is the opposite of what a reader means by theme.
Insight type marks (#2004 item 8)¶
InsightTypeMark.vue — how one insight is told from another in a list that can hold 36 of them.
Shape first, colour second. Four SVG marks at one fixed size — diamond (claim), ring
(observation), triangle (recommendation), square (question) — so all four carry the same optical
weight. Text glyphs did not: ◆ and ? are punctuation and read as typography, at whatever weight
the font gives them. A type outside the closed vocabulary gets a neutral dot, never nothing; an
empty mark column on the one already-unusual row is worse than an unlabelled one.
The set must stay legible in greyscale — colour is the second channel and never the only one.
KnowledgePanel.test.ts asserts shape-distinctness separately from colour for that reason.
Colour rides on the mark, never the label. --lp-insight-* alias --lp-topic,
--lp-grounded, --lp-warning, --lp-person, so every visual direction adapts them for free
rather than needing four hand-tuned hues each. The label stays mono + muted (.lp-kicker), and
none of this spends --lp-accent, which means "you can act on this". A direction that collapses
two of those base tokens makes two marks share a hue — survivable precisely because shape carries
the distinction.
A symbol nobody can decode is decoration. Each mark carries a title describing what the type
MEANS ("Claim — something the speaker asserts as true"), so on a pointer device the meaning is one
hover away. The visible type word carries it everywhere else, which is why the mark itself is
aria-hidden: a screen reader should hear "claim", not "diamond claim".
No second constant mark may precede it. A green "grounded" dot used to, on every grounded row —
and it rendered on the same condition as that row's ▶ mm:ss button, so it distinguished nothing
while diluting the mark beside it. That is the failure this pattern exists to prevent, and a test
asserts the type mark is the first element in the row.
Played state (PlayedBadge) — operator 2026-09-23¶
"Have I heard this?" is ONE question and gets ONE answer. It used to have two, and the app only
ever read one of them. completed was the list you built by hand from the player's ⋯ → Mark as
played; finished was what the player recorded for itself when an episode ran out. Nothing joined
them, so an episode listened to the END was played nowhere: it stayed in Home's Jump back in,
it carried no marker in the queue or recently-played, and the catalogue's Played filter returned
"no episodes match" for a listener who had finished plenty.
The join is server-side. GET /api/app/completed returns the union of both records. Doing it in
each view instead — every surface asking two questions and combining them — is exactly how the two
drifted apart in the first place, and it would have been true only on the device where the finish
happened. Server-side, it is also true RETROACTIVELY: episodes finished before the fix read as
played without anyone replaying them.
Un-playing retracts the finish, not just the mark. Otherwise the toggle goes one way: tap Mark as unplayed on something you actually heard, and the finish record puts it straight back. "I did not finish this" is what the listener is asserting, so the finish history is part of what they are retracting — a recap that still counted it would be reporting something they explicitly denied. The resume POSITION survives: not-finished is not never-started.
The badge is a labelled check, never a bare tick. A tick alone has to be learned, it carries nothing to a screen reader, and this app already spends a bare check on "reviewed" for Revisit cards — two meanings, one glyph. The word costs about thirty pixels.
Muted, not accent. Played is settled history, not something to act on; --lp-accent stays
reserved for the live and the actionable. A column of bright ticks down a finished list would
out-shout the episodes the listener still has to get to.
Absence is the unplayed state. There is no "Unplayed" badge. It would render on the common case, on every row, to say nothing.
It shows in COMPACT cards too, unlike the date/duration row it sits under. Compact is what the queue's recently-played list renders, and a list of things you have heard is the one place the marker is load-bearing rather than incidental.
Destructive confirmation (#1594)¶
ConfirmDialog.vue — the one pattern in front of a delete that cannot be undone.
When it applies. A delete gets a confirmation when it destroys something the user authored or curated and the app cannot restore it exactly: delete a collection, delete a highlight, delete a note. All three fail the restore test for the same reason — the create endpoints mint a new id, so an "undo" would produce a different object wearing the same name, with every reference to the original still broken.
When it does not. Removing an item from a collection is a membership row; the item itself survives and re-adding it is two taps from the same screen. It gets no dialog. Confirmations spent on cheap, reversible actions are how people learn to dismiss them without reading — which costs exactly the three above.
Prefer undo where an exact restore IS possible. Confirmation is the fallback for when it is not. Do not add a dialog to an action you could simply reverse.
Mechanics.
- A native
<dialog>opened withshowModal(), so the browser supplies the top layer, focus trap, Escape and inert background (same reasoning as the Knowledge Panel, S9). - Initial focus is Cancel, never the destructive button. A dialog that opens with Delete focused turns "tap, tap" into a deletion: a step without a decision, which is worse than no dialog because the user now believes they are protected.
- The confirm button carries the verb ("Delete collection"), never a bare "OK".
- The dialog's own
closeevent maps back to a cancel. The browser closes on Escape without telling the parent, and a parent that keeps its pending id shows no confirmation on the next delete — a failure that appears one action after its cause.
Player hero (artwork zone)¶
The Player masthead is a hero: a fixed-aspect artwork carrying overlays, so layout height is
constant regardless of content length. 5:4 on phones, 1:1 from lg (2026-09-30): at a
full-width square the hero took ~360pt of an iPhone's ~600pt usable height, and the transport's
scrubber and timestamps fell under the tab bar even after the gaps around them were tightened; 5:4
gives back ~70pt and puts the whole transport on screen. Desktop has the height and keeps the square.
- One way into the episode's notes (2026-09-30): the labelled ✦ Episode notes pill at the
top of the artwork, beside the per-episode reach chip (listeners · opens + a tiny
opens-over-time
Sparkline, withheld below the k-anonymity floor). There is NO summary control on the hero any more. The Summary pill opened a modal holding exactly the prose the panel's Summary section shows, so it was a second entry to a subset of the same thing. The summary lives in the panel, directly under the people in the room. - Live intelligence (Zone D, "Insight now / Next · in 0:06") owns the BOTTOM band of the artwork, so it never competes with the actions at the top.
- The Grounded chip sits up by the date/meta line, not floating over the image.
Saved & Library¶
- Per-user collections live in one "Library" hub (page) with tabs Following · Saved ·
Collections · Revisit (
LibraryView.vue:36; theFollowingtab's key isshows). Highlights, Queue and Recent are not tabs — Highlights is anh2section inside Saved; the player auto-resumes from the saved position, so recent/played episodes need no separate "resume at" affordance and no dedicated tab.
Saved's empty state (#1962). All three Saved sections — Episodes, Insights, Highlights — are
conditional; none renders a heading over nothing. When all three are empty the tab shows one
empty state naming what it holds ("Episodes you favourite, insights you keep, and moments you mark
all live here"), a muted ghost card showing the shape of a future entry, and the single action a
person can take about being empty: Find something to listen to → (to catalog).
Highlights used to be the only unconditional section, so a fresh account met a lone Highlights
heading standing in for a third of the tab and read the tab as redundant. The heading placement is
unchanged — it is still an h2 inside Saved, per the paragraph above; only its gating is.
Amended #1599 (source of truth:
LibraryView.test.ts:82-89). This spec said Saved · Knowledge · Queue · Recent, then briefly Saved · Highlights · Collections · Revisit · Queue · Recent — neither matched the code. The shipped tab set is the four above. History:b3cd95d1(2026-06-28) added a Knowledge tab and the original wording together;a9705819(#1141, 2026-07-05) deliberately removed the tab and merged insights back into Saved;Collectionsbecame a first-class tab under RFC-119;Showsreturned as Following. None of those code changes amended this spec, and the only written record was a comment inLibraryView.test.tsasserting the absence of Knowledge/Queue/Recent — so CI defended the undocumented state against the documented one.The code is kept (it shipped, stuck, and is test-defended); the spec is corrected to match. See
1604 — a decision recorded only in a test comment is a decision that gets lost.¶
(The Following tab covers followed shows, topics, people and storylines; Home's "Your shows"
deep-links here via ?tab=shows. This is #1585 — now built, not pending.)
- One card, every surface. Catalog, Saved, Queue and Recent all showcase an episode through the
shared EpisodeCard (Queue keeps a slim ↑/↓ reorder rail beside it; the card's own queue toggle
removes). Hydrated EpisodeDetails are adapted via summaryFromDetail so they never drift.
- Saved holds favorited episodes, plus a legacy, read-only Insights section. The backend
favorites bucket is polymorphic (AppFavoritesResponse: episodes, insights) and both still
render, but since #1593 nothing writes insight favourites any more: an insight had BOTH a
bookmark (→ Highlights) and a heart (→ Saved › Insights) — same text, two destinations, two places
to look for it later. Highlights is the single destination for insights; it carries colours,
notes and export. Existing saves stay readable and removable, and that section disappears on its
own as each user clears theirs. Do not add a new write path.
- Saving is the shared .lp-fav heart on episodes (episode cards, the player masthead). It is
no longer on insights.
- Colour + filter bar (RFC-121 ph. 3–4, #2042). Every saved item — highlights (class B) and now
favourited episodes + entities (class A) — can carry an optional colour, set through the one
shared SavedColorControl: a single current-colour dot (an empty ring when unset) that opens a
44px-swatch popover on tap, replacing the old always-on 5-swatch strip so a card stays quiet. The
Saved tab is topped by SavedFilterBar, lifted out of the Highlights list so one bar governs
every section: type chips (which saved kinds show — none selected = all, and a chip renders only
for a kind that has items, per the #1962 presence rule), a collapsed colour filter (only
colours in use), and a sort — Yours (default, the manual drag order the server persists) or
A–Z, the one sort model shared with
Following. Colour is a filter, not a sort; per-episode grouping of highlights is structural and
unaffected (sort only orders the groups). When the active filters empty every section while the
account is not empty, the tab says so rather than showing a blank that reads as a bug.
- Scale to 100+ (#2042 follow-up). Both Following and Saved cap each per-type section to
the top N and expand in place via ShowAllToggle ("Show all (N) / Show less"), so the hub stays
one scannable screen no matter how much is followed or saved. A type-to-filter search box (the
SavedFilterBar search input, reused on Following) filters every section by label; a non-empty
query lifts every cap so a match is never hidden. Following reuses the same bar minus colour, with a
recent / A–Z sort; each section heading carries its count.
- Grouping by episode: two weights, one idea (2026-09-17, revised 2026-09-18). Where a surface
groups by episode, the group is headed by the real episode — never a bare line of text — and
its rows collapse, starting expanded, because folding is an affordance for a long page rather
than a default that hides what the reader came for. Collapse is v-show, so anything expanded
inside a group (Search's folded transcript clusters) survives a fold and re-open.
Which weight depends on what the group IS:
- Results →
EpisodeGroupCard: the fullEpisodeCardas a bordered block, count under the artwork (#aside), "Hide / Show {noun}" beneath. Search uses this — the episode is the result, and something you act on. - Your own captures → the flat idiom:
EpisodeRowas the heading (40px artwork, title, show name), the fold control in its#trailingslot, and the capture cards as a plain list. Library → Saved and Library → Revisit use this.
Revisit moved from the first to the second on 2026-09-18 (operator): it is the same captures as
Saved, surfaced because they are due rather than because you went looking, so it reads the same
way and only the framing differs — a reflection prompt per card, "Mark reviewed" in place of the
edit controls. Its previous form nested a bordered container, a toggle row, and a bordered box per
moment: three frames to say "these four moments are from this episode", where a heading and a list
say it with one. Cards keep their colour stripe across both surfaces, so a moment filed under
amber is still amber when it comes back to you. Revisit's heading also carries when the episode
was listened to ("Listened {date}", from the listener's playback positions) and the due count —
one muted line under the row. The listened line is omitted, not guessed, with no playback history.
- One kind-filter strip, everywhere (2026-09-17). Filtering a list by the KIND of thing in it is
one pattern, so it is one component: TypeFilterBar — a multi-select chip strip led by an
explicit All chip (so clearing is one tap), where no selection means all, and a chip renders
only for a kind that actually has items (the #1962 presence rule). Three surfaces use it: Saved
(which saved kinds show), Search (which result kinds show), and Boards → Your notes, where
the chips are the entity a note is attached to (Highlight / Insight / Episode / Show / Topic /
Person / Storyline) and each note row carries the matching KIND · DATE label, so a chip and the
rows it governs name the same thing. A strip with a single kind to offer is not drawn — one choice
is decoration. The section it filters is gated on having notes, never on the filtered result: a
filter that empties its own list must not delete the control that clears it, so an empty result
says so and the strip stays.
- Favorites / queue / interests / playback are per-user files (no DB). Interests are viewable +
editable on the Profile page (header → user icon).
- Following an interest is a one-tap toggle on a person/topic entity card (Follow /
Following), in addition to the Home cluster picker. The interest list is a mixed token set —
clusters (tc:), topics (topic:) and people (person:) — and re-ranks "Recommended for you"
by how many followed tokens an episode matches (flag-gated personalized discovery, PRD-043).
Listening analytics¶
Listening stats are computed from per-user files — no LLM, no DB — and surface in two places:
- Profile "Your listening" (own data): single scores — day streak, episodes, shows, hours — plus
an opens-over-time
Sparkline. Derived from the user's playback + listen log. - Player per-episode reach (cross-user, anonymous): distinct listeners, total opens, an
opens-over-time
Sparkline, and the grounded-insight count. Aggregated by scanning every user's listen log; counts only, never identities. Public (no auth). - The listen-events log (
<data_dir>/users/<id>/listen_events.jsonl, append-only) is the only per-listen history we keep — playback stays last-position-only. The player appends one "open" event on mount.Sparklineis the single shared mini-chart (currentColor) for both surfaces. TrendMomentumis the single shared velocity presentation (BT.4/F4.2), so a topic and a storyline read momentum identically everywhere it appears. Two variants over the same data (velocity + optional weekly series, wrappingSparkline): a badge — an emerald "↑ Rising · N× vs avg" pill — on detail surfaces (topic card viaEntitySignals, the storyline page), and a direction-coloured rail "↑ N×" on the Home storylines rail, matchingMomentumRail. Storyline velocity is joined bythc:id from/trending?kind=storyline(a Σ-of-members aggregate); a storyline outside the trending set simply shows no badge.
Header navigation¶
The header uses icon links with hover/focus tooltips (NavIconLink) — Browse (compass),
Library (book-spines), Profile (user) — never bare emoji; one shared component, labelled by
tooltip. Lists use the shared ListToolbar — a wide search plus compact ToolbarMenu controls
(filter · sort · view) on one row, not stock inputs — identical on Browse › Episodes and Browse › Shows.
Shared action components (governed here)¶
Cross-surface action components this document governs, named so the surface-map guard can tie each rendered piece to its design home:
FavoriteButton— the one heart save toggle (.lp-fav), the single "save" affordance used on every surface (see "Saving"); visible signed-out (#1590), routing a tap to sign-in.AddToCollectionButton— the compact "pin into a collection" control with inline create-new-collection (RFC-119); a detail-surface action, never part of the minimum row.FollowButton— the ONE follow pill/glyph (+ Follow/✓ Following) for every surface where something can be followed: show-page header (inline),ShowTileartwork overlay (overlay), action rows (icon), entity cards (ec, testidec-follow), storyline pages (storyline, testidstoryline-follow), discovery list rows (discovery, testiddiscovery-follow), and trending spark chips (trend-spark, testidtrend-spark-follow). Each variant keeps a static testid in the component source so the surface-map guard can extract it. Save ≠ Follow — this is the pill; the heart isFavoriteButton.FollowedInterests— the Library section listing followed topics, people and storylines grouped by type, each unfollowable inline (the "following" pattern applied to non-show entities).
Every on/off control behaves the same way (2026-09-30)¶
Follow (topics, people, storylines), the heart, follow-show, played, queue, and save-insight / save-line all follow ONE contract:
- It flips on the tap. No control waits for the server before it changes; the heart used to, and on a slow connection it read as a dead button.
- Writes reach the server one at a time, in the order the user tapped (the shared
services/serialWrites). A quick on-then-off sends ON, then OFF, never both at once. - Only the newest tap's answer counts. The response to a superseded tap is not adopted, so it cannot re-light a control the user has since turned off. The newest answer is the server's whole list and settles everything before it.
- A refusal reverts, a lost request queues — the outbox rule every per-user write already had.
- A list that was already loading cannot undo a tap. A fetch that started before the tap
returns the state from before it; the store refetches once the writes settle instead of adopting
it (
writes.fresh). Found in a trace (2026-10-01): the initial GET landed after the follow's POST, reset the button, and the "unfollow" tap sent a second follow.
Before this, five of the six ended a fast tap-tap in the wrong state, and save-insight tried to
delete a highlight by the id the phone had made up before the server named it. Guarded by
src/stores/tapTap.test.ts (per store, and each fails on the pre-fix code) and
src/__checks__/toggle-consistency.test.ts (any store toggle must write through the serializer).
Post-episode recap¶
When an episode finishes, the player must not just stop. EpisodeRecapPanel (#2038 / RFC-122)
replaces the transport in place on the Player page — same footprint, not a full-screen takeover
and not a global sheet over the persistent mini-player — the moment the episode crosses the finish
line (the player store's justFinished, set on the ended event or past the 95% threshold, so
skipping the outro still counts). It is a reinforcement surface, "we took notes for you":
- Kicker + title — "You just finished" over the episode title, with the "we took notes for you" reassurance.
- Key points — the episode's summary bullets (the prose summary is the fallback lede when there are none), the gist to consolidate.
- Signature quote — the single strongest attributed quote (the emotional anchor); attribution shows only when the graph can name the speaker — an unnamed voice gets the line with no byline, never an invented one (#1978).
- Top insights — the salience-ranked insights (capped server-side).
- Key topics + storylines — key-topic chips (into the topic card) and the storyline threads the episode belongs to (into the storyline), the threads to pull on next.
- Continue / dismiss — with a queued next the footer is an end-card countdown: the next title gets its own full-width line (readable, never truncated to a few letters) over a depleting progress bar, with "Play next" (skip the wait) and "Stay" (cancel) on the row below; on zero it auto-continues. With nothing queued it is "Back to player". The header close also dismisses; navigating to a new episode clears the recap so it never bleeds across episodes.
The panel is deliberately kept short enough to sit on a phone with no internal scroll — it must
never become a scrollable box inside a card. Discovery ("more like this") is NOT repeated here: the
related-episodes rail already lives on the page, and duplicating it made the end-card too tall. One
recap model (key points + quote + insights + topics + storylines) is assembled once server-side
(GET /api/app/episodes/{slug}/recap) so the same shape can feed the daily digest email (#2039)
without drifting. Bridge-only: transcript-derived text + KG metadata + artwork, never audio.
The queue has one meaning, wherever you reach it (RecentlyPlayedList, 2026-09-23)¶
The queue gained a front door. /queue had no nav entry at all: the only ways in were the
player's queue button and Home's resume hero — and that hero renders only while something is IN
PROGRESS. Finish everything you were listening to and the queue you had been filling became
unreachable without first starting an episode you did not want to play. Which is also, exactly, the
state you are in on a plane wanting the thing you queued. The masthead now carries it at every
width, badged with the queue's own length, so the control answers "is there anything in there"
without being opened.
And the mini-player gave one up. Its queue button is gone: two routes to one place, on a screen already showing the other, in the most space-constrained strip in the app. Its slots went to favourite and add-to-collection for the playing episode — the shared components, so grey-when-off / filled-when-on is inherited rather than re-decided.
Both halves travel together. Recently played was a section inside QueuePanel. With the
masthead pointing at the /queue PAGE, leaving it there would have meant the queue showed Up next
alone from the header and both sections from the player — the same thing meaning two different
things depending on how you arrived. Extracted into one component used by both. The rule this is an
instance of: when a surface gains a second entry point, the surface does not get to differ by
entry point.
And then the second entry point went too (operator 2026-09-27). The full player's opener was
removed for the same reason the mini-player's was, which left QueuePanel with no way in at all, so
it was deleted; /queue is the one surface. Worth noting what the extraction bought: because both
halves had already been made to travel together, deleting the panel cost nothing but the panel. Had
Recently played still lived inside it, removing a button would have removed a feature.
What replaced it is an action, not a route. The player's title row now carries the shared
QueueButton — add/remove THIS episode, marked when it is already queued. The general form:
a control on an item's own surface should act on that item. A button that only navigates
somewhere the global nav already reaches is spending a slot to duplicate the masthead, and it has no
state to show while it does it.
The mini-player line is a compact ROW, not a sentence. Show as kicker, episode below — the shape Podcast, Queue and the entity lists already use. One truncated line ended in an ellipsis having said nothing about whose show it was, and the bar is often the only thing on screen that knows what is playing.
Output routing (RouteButton, operator 2026-09-23)¶
We own the button; the platform owns the list. Tapping opens the SYSTEM device sheet — AirPlay on iOS, the Cast/output picker on Android — which is where This iPhone, the Bluetooth speaker and the MacBook are listed.
An in-app device list is not a future improvement, it is impossible. Neither platform exposes an API for a page — or even a native app — to enumerate AirPlay / Cast / Bluetooth audio targets. Spotify's in-app list works because those are Spotify Connect devices: their own protocol, their own servers, their own registry. Its AirPlay row still hands off to the system sheet, exactly as this does. Stated plainly because "finish this by listing the devices" is the obvious next thought and the data does not exist on this side of the boundary.
Two APIs, chosen by FEATURE not by platform. iOS/WKWebView has
webkitShowPlaybackTargetPicker() with webkitplaybacktargetavailabilitychanged; Chromium has the
Remote Playback API (remote.prompt() / remote.watchAvailability()). MDN marks the latter "limited
availability" and does not say whether the Android System WebView carries it as opposed to Chrome —
so a platform check would be a guess where a capability check is a fact. Capacitor already sets
allowsAirPlayForMediaPlayback = true, so no native change was needed.
Absent when there is nowhere to send audio. The control renders only once the platform reports a route available. A speaker icon that opens an empty sheet offers a capability the room cannot provide. On a platform carrying neither API it never appears — and Android users still have the system output switcher on the media notification, which MediaSession already populates.
The active state earns the accent more than most. Audio leaving the phone is the one player state you cannot see by looking at the screen; a listener who does not know where the sound went concludes the app is broken. Same "this is on" language as the queue and download toggles.
On BOTH players. The mini-player is where you NOTICE the audio went astray; the full player is where you go to do something about it (operator: "we need such a control somewhere else, not only when the player is small").
Offline, signed out — "On this device" (OfflineDownloadsView, operator 2026-09-23)¶
The gap. Every route is behind the login-first guard (RFC-120), and signing in requires a network. So a listener whose session had lapsed, on a plane, could not reach the episodes they had already downloaded — from the one device holding them, at the one moment they mattered. In the operator's words: "if I'm not logged in, offline mode is useless for playing things I anyway downloaded already."
Two answers, and the first one carries most of the weight. A returning user stays SIGNED IN
offline — auth.hydrateFromDevice() repaints the cached identity before any network call and a
transport failure never clears it (only a real 401 does). That already covers the common case, and
it is the better answer because nothing about the account changes. /offline is the fallback for
when it is not enough: a genuine sign-out, a cleared snapshot, a session too old to trust.
What it is. The last signed-in account's download registry, rendered read-only: artwork, title, show, and play. No delete, no queue, no favourite, no library, no account information, and no API call — the absence of the API is the point, not a degradation.
The gates ARE the privacy boundary. Downloads are namespaced per account precisely so a shared phone cannot show one person's listening history to the next (#1905), and this route deliberately reads ACROSS that namespace. That exposure is real and was accepted knowingly, so it is bounded by construction rather than by intent: the view renders only when offline AND signed out, redirects Home when signed in (the full Library is already there) and to the landing when online (where you can actually sign in). Online it would be a back-door; offline it is the only door. Its unit tests cover all four gates for that reason — they are not smoke tests, they are the boundary.
The route is public, which buys reachability, not openness. A non-public route bounces to the
landing, which is exactly where this listener already is and exactly where they cannot get past. The
view does the real gating.
Entered from the landing. Offline and signed out, both landing CTAs are dead ends, so the link
to this page sits there (landing-offline-downloads) and is hidden while online — where signing in
is the better answer and this page has nothing to offer.
Offline, signed IN — what must survive with no network¶
One rule, stated once because it kept being applied per-surface: a list built from a per-account API call needs a cache, or it renders empty at the exact moment it is most wanted.
The queue panel had this half-right, which is worse than having it wrong: Up next came from the
queue store, which caches and flags stale, while Recently played was built straight from
GET /playback with no cache at all. Opening the panel offline showed a working queue above an
empty history, so the panel looked half-broken on the one trip it was opened for (operator
2026-09-23). It now paints the cached copy first and revalidates.
The corollary is easy to get wrong in the other direction: an empty answer from a FAILED request
is not an empty list. Both the request and the per-item hydration fall back to the cached copy
rather than overwriting it with [] — discarding the only copy at the moment it is the only copy.
What the NATIVE app brings that the web cannot (operator 2026-09-24)¶
The app ships as one Vue codebase to three places: the web player, an iOS Capacitor shell, and an
Android one. Most surfaces are identical by design. This section is the list of places where they
are NOT, because that boundary kept being rediscovered expensively — three separate investigations
into "the page never rendered its action row" ended at a control that is v-if="native" and was
never going to be there.
The rule: a capability is native-only when the WEB PLATFORM cannot honour the product promise, not when native is merely nicer. Each entry below names the promise it is protecting.
| Capability | Native-only because | Web behaviour |
|---|---|---|
Downloads (DownloadButton, DownloadedList, downloads/downloadScheduler) |
Audio is BRIDGED, never rehosted, and the service worker deliberately does not cache it. There is no web mechanism that stores an episode for a flight without breaking that rule. | The control self-hides. Not disabled — an affordance that cannot work is worse than an absent one. |
"On this device" (/offline, OfflineDownloadsView) |
It lists the download registry, which only exists on a device. | Route resolves, list is empty by construction. |
Device settings (DeviceSettings, network policy) |
Governs Wi-Fi-vs-cellular for downloads; meaningless without downloads. | Hidden. |
Share as an image card (ShareMenu, useShareCard, entityShareCard) |
The native share sheet takes a FILE; the Web Share API's file support is uneven and silently degrades. | Falls back to link/text sharing. |
Push (usePushSubscription) |
APNs/FCM registration is a shell capability. | Web push where the browser supports it; otherwise absent. |
App update prompt (useAppUpdate) |
The shell knows about a downloaded binary; a web page knows about a service worker. | The PWA update toast instead — a different mechanism for the same intent. |
Session auth (stores/auth, services/native) |
The shell authenticates with a BEARER TOKEN; the web uses the session cookie. Same accounts, different credential. | Cookie session. |
| Deep links | closelistening:// is an OS registration. |
Ordinary URLs. |
| Background audio | AVAudioSession / an Android foreground service; a backgrounded WebView gets suspended. |
Playback stops with the tab. |
Output routing (RouteButton) |
Opens the platform's AirPlay/Cast sheet. See below — the button is not native-only, the useful RESULT is. | Renders only if the browser exposes a remote-playback API; usually absent. |
iOS versus Android¶
Same Vue code, and four things genuinely differ. All are platform mechanics rather than product decisions — the product is meant to behave identically on both. The last two were found on 2026-09-24 by the Android device tier's first run against the app (#2139), and neither was a curiosity: one had broken a whole feature on Android, the other had made four controls unusable with a screen reader.
- Output routing is two APIs. iOS/WKWebView has
webkitShowPlaybackTargetPicker()withwebkitplaybacktargetavailabilitychanged; Chromium has the Remote Playback API (remote.prompt()/remote.watchAvailability()).RouteButtonfeature-detects rather than branching on platform, because MDN marks Remote Playback "limited availability" and does not say whether the Android System WebView carries it as opposed to Chrome. On a platform with neither, the control does not render — and Android still has the system output switcher on the media notification, which MediaSession already populates. - Background audio is two mechanisms. iOS uses
AVAudioSession+UIBackgroundModes; Android needs a foreground service, which is why a localBackgroundAudioplugin exists and no-ops on iOS. Filesystem.downloadFileresolves a different set of directories. It is served by the plugin's LEGACY Android implementation, whosegetDirectory()has no case forLIBRARY_NO_CLOUD— it returns null andFileOutputStream(null)throws. Every download on Android failed with "Error downloading file: null" and recorded itself as retryable, so the user saw "Download failed — tap to retry" for ever. Downloads now fetch intoDirectory.Data, which on Android is the SAMEfilesDirthe modern implementation mapsLIBRARY_NO_CLOUDto, so every other call still reads the bytes back at the same path. iOS is unchanged:LibraryNoCloudis how podcast audio is kept out of an iCloud backup, which is not an Android concern.aria-haspopupcosts a control its accessible NAME on Android when nothing inside it is readable. Measured on Android System WebView 150: the overflow ⋯, Notifications, Share and Add to collection each arrived as a zero-childButtonwith an EMPTY contentDescription — the label string appeared nowhere in the accessibility tree, so TalkBack announced only "Button". Neither condition alone does it:Play,Skip back 15 secondsandMark this momentare icon-only witharia-labeland all named, because they open no popup;Playback speedhasaria-haspopupand IS named, because its pill renders readable text beside the icon. The fix is thesr-onlyspan this codebase already uses for the masthead profile link — the same remedy as the WebKit defect below, which the two engines express differently (WebKit drops the element; Chromium keeps it and strips the name). Not a difference — a SETTLED decision, recorded so it is not reopened as one. Using the OS picker is the answer on both platforms (operator 2026-09-24: "I'm okay with the generic AirPlay way to trigger the iOS native dialog"). Neither platform lets a page — or a native app — enumerate AirPlay, Cast or Bluetooth targets, so an in-app device list would require our own device protocol, as Spotify Connect is. It is listed here only because "build the device list for Android" is the obvious next thought and there is nothing to build.
Testing consequence — and the trap that has already cost days¶
The browser tier cannot see any of the above. A Playwright spec against a native-only control
asserts an empty page and PASSES, which is worse than failing: it certifies the opposite of the
truth. Anything in the table above belongs in a device tier (make test-ios, make test-android)
or in a unit test with isNative mocked — never in e2e/*.spec.ts.
And a DOM-driven device test would not have helped either. Espresso.onWebView() runs WebDriver
atoms against the DOM, so on the aria-haspopup defect above it would have found every control and
passed — the same blind spot Playwright has, at device-tier cost. The Android tier uses UI Automator
precisely because it reads the accessibility tree, which is the surface TalkBack consumes. Choosing
the runner that can see the bug is not a detail; it is the reason the tier is worth running.
And XCUITest cannot see what the DOM says it should. It reads the accessibility tree, where
WebKit maps a <button> to a Button only if it is plain: aria-haspopup makes it a PopUpButton,
aria-pressed a toggle, role="menuitem" a MenuItem. On the player page app.buttons returned
twelve controls and NONE of favourite, add-to-collection, share or ⋯ — all four on screen, all four
carrying ARIA state. The markup is correct; the query was wrong. Device-tier selectors therefore go
through Journey.control(app, label:), which matches by accessible label across element types.
Conformance checklist¶
- [ ] No second backdrop; drilling inside a panel is replace-in-place with
‹ Back. - [ ] Overlays
Teleportto<body>; correct z-scale. - [ ] Back/nav uses
.lp-nav; content labels use.lp-kicker; section headings use.lp-section; speakers use.lp-speaker; saving uses.lp-fav. - [ ] Episodes showcase through the shared
EpisodeCardon every collection surface. - [ ] Library tabs are Following · Saved · Collections · Revisit (
LibraryView.vue:36); saved insights render as an Insights section inside Saved, not under a separate Knowledge tab (there is none). Highlights/Queue/Recent are not tabs. - [ ] Following an interest is the one-tap
Follow/Followingtoggle on a person/topic entity card (mixed-token interests:tc:/topic:/person:), alongside the Home cluster picker. - [ ] The Player hero summary is hover/focus-revealed (slide-up + fade over the legibility gradient), always shown on touch; never clamped.
- [ ] Listening analytics surface through the single shared
Sparkline— Profile "Your listening" (own data) and the Player per-episode reach cluster (cross-user, anonymous, public). - [ ] Header order:
‹ Backrow → kicker → title. - [ ] Modal a11y: dialog/aria-modal, focus trap, restore focus, ESC + backdrop + control.
- [ ] No per-page restyle of a shared affordance.