Skip to content

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; ‹ Back returns 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 uses overflow-hidden/offsets that otherwise clip a nested position: fixed).
  • z-scale: panel z-40, modal z-50, mobile backdrop z-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 ‹ Back control 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-kicker as "(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 by src/__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 title attribute (so the full name stays reachable). See ShowTile.vue.

This is recorded because I broke the rule before scoping it: #1584 added truncate to 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 ‹ Back stack; 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's aria-controls naming its panel. Library, Browse, Home discovery.
  • re-parameterises one region → pattern="radio": role="radiogroup"/radio, aria-checked, and no aria-controls at 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 — a role="tab" whose aria-controls names 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:

  • EpisodeCard is 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.
  • EpisodeTile stacks: 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.
  • EpisodeRow is the COMPACT row — a small thumbnail, title, and show kicker linking to the player, top-aligned, with a #trailing slot 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 full EpisodeCard's summary column would be noise.
  • EntityEpisodeList is the "discussed in N episodes" LIST that every entity surface renders — topic, storyline, person, org. It owns two things: the EpisodeRow stack, 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.

  • ShowRow is EpisodeCard'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 #actions slot.

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-fav heart, 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 FavoriteButton in a controlled variant and announced "Save to favorites", while writing an insight HIGHLIGHT through the capture store. services/types.ts already carried the comment "Saveable favorite kinds. insight is 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. AddToCollectionButton drew M6 3v18l6-4 6 4V3z, and CaptureMoment in 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 is hidden 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 carries collections.addTo as 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.

  • showDownload promotes 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 ⋯ remove in 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-fav heart); 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 by id (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), and PUT /favorites gets a 422 on kind=insight so 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 with showModal(), 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 close event 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; the Following tab's key is shows). Highlights, Queue and Recent are not tabs — Highlights is an h2 section 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; Collections became a first-class tab under RFC-119; Shows returned as Following. None of those code changes amended this spec, and the only written record was a comment in LibraryView.test.ts asserting 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 full EpisodeCard as 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: EpisodeRow as the heading (40px artwork, title, show name), the fold control in its #trailing slot, 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. Sparkline is the single shared mini-chart (currentColor) for both surfaces.
  • TrendMomentum is 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, wrapping Sparkline): a badge — an emerald "↑ Rising · N× vs avg" pill — on detail surfaces (topic card via EntitySignals, the storyline page), and a direction-coloured rail "↑ N×" on the Home storylines rail, matching MomentumRail. Storyline velocity is joined by thc: 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), ShowTile artwork overlay (overlay), action rows (icon), entity cards (ec, testid ec-follow), storyline pages (storyline, testid storyline-follow), discovery list rows (discovery, testid discovery-follow), and trending spark chips (trend-spark, testid trend-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 is FavoriteButton.
  • 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:

  1. 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.
  2. 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.
  3. 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.
  4. A refusal reverts, a lost request queues — the outbox rule every per-user write already had.
  5. 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() with webkitplaybacktargetavailabilitychanged; Chromium has the Remote Playback API (remote.prompt() / remote.watchAvailability()). RouteButton feature-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 local BackgroundAudio plugin exists and no-ops on iOS.
  • Filesystem.downloadFile resolves a different set of directories. It is served by the plugin's LEGACY Android implementation, whose getDirectory() has no case for LIBRARY_NO_CLOUD — it returns null and FileOutputStream(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 into Directory.Data, which on Android is the SAME filesDir the modern implementation maps LIBRARY_NO_CLOUD to, so every other call still reads the bytes back at the same path. iOS is unchanged: LibraryNoCloud is how podcast audio is kept out of an iCloud backup, which is not an Android concern.
  • aria-haspopup costs 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-child Button with 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 seconds and Mark this moment are icon-only with aria-label and all named, because they open no popup; Playback speed has aria-haspopup and IS named, because its pill renders readable text beside the icon. The fix is the sr-only span 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 Teleport to <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 EpisodeCard on 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 / Following toggle 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: ‹ Back row → kicker → title.
  • [ ] Modal a11y: dialog/aria-modal, focus trap, restore focus, ESC + backdrop + control.
  • [ ] No per-page restyle of a shared affordance.