Skip to content

refactor: Proposal for refactor in SvelteKit with Storybook#142

Draft
Hazer wants to merge 92 commits into
LizardByte:masterfrom
Hazer:poc/svelte-rewrite
Draft

refactor: Proposal for refactor in SvelteKit with Storybook#142
Hazer wants to merge 92 commits into
LizardByte:masterfrom
Hazer:poc/svelte-rewrite

Conversation

@Hazer

@Hazer Hazer commented Jun 22, 2026

Copy link
Copy Markdown

Description

A complete rewrite of the Koko web client from vanilla TypeScript to Svelte 5 (runes) + SvelteKit, along with server-side improvements. This is a draft PR for early review — seeking architecture and approach feedback before finalizing.

Why Svelte 5 + SvelteKit?

The vanilla client is imperative: hand-written string-template HTML, manual addEventListener for every interaction (1395 lines in eventBindings.ts), a custom DOM diffing layer (domPatcher.ts, 290 lines), and a single mutable AppState object that triggers a full re-render on every change. This works but creates maintenance friction: adding a feature means wiring new event listeners, managing DOM updates manually, and ensuring the diff patcher handles the new markup.

Svelte 5 with runes was chosen because it's the closest step from vanilla while gaining reactive state management:

  • Compiled, not interpreted. Svelte compiles to vanilla JS at build time — there's no virtual DOM runtime overhead. The output is vanilla DOM manipulation, same as what domPatcher.ts does by hand, but generated correctly by the compiler. This makes it nearly as lightweight as the current vanilla approach, unlike React/Vue which ship a runtime.
  • Runes make state explicit. $state, $derived, $effect are compile-time annotations that tell Svelte what to track. No proxy wrapping, no hook rules, no mental model gap. A $state field is just a variable the compiler knows to make reactive. This is conceptually similar to how the vanilla code mutates state and calls render() — but automatic.
  • SvelteKit provides routing + build. The vanilla client has a hand-rolled router (routes.ts + hash-based navigation). SvelteKit provides file-based routing, code-splitting, and the adapter-static output (SPA mode) the Rust server already serves. The build output is static HTML/JS/CSS — same as what the server serves today.
  • The learning curve is minimal. Svelte templates look like HTML. Stores look like classes with $state fields. There's no JSX, no hooks, no provider tree. A contributor who knows HTML/CSS/JS can read a .svelte file and understand it immediately.

Where it's close to vanilla: the build output is vanilla DOM manipulation. The bundle includes the Svelte runtime (~10KB gzipped) + SvelteKit client (~20KB). The vanilla client's bundle is smaller, but the difference is marginal compared to the maintenance benefit.

What changed

New: crates/client-web-svelte/ — the full rewrite. The vanilla client (crates/client-web/) is untouched.

Server: crates/server/ — transcode streaming now sends X-Content-Duration header + ffmpeg -t flag so fragmented-MP4 streams carry a proper total duration (fixes the growing-duration bug during transcoding).

Architecture: component structure

The vanilla client has ~15 flat render functions. The rewrite organizes into a structured component taxonomy:

src/lib/components/
├── Leaf UI: Button, Icon, CardSurface, MediaCard,
│   PersonCard, MediaExtraCard, CollapsibleText, UserAvatar
├── Section*: page-section members of the item-detail page
│   SectionHero, SectionSupport, SectionPeople,
│   SectionExtras, SectionChildren, SectionBreadcrumbs
├── Sub-components: HeroActions, FactList, SupportFileInfo,
│   SupportMetadata, MetadataSearchPanel
├── Page fragments: BrowseListing (+ Hero/Grid), HomeContent,
│   HomeFeature, HomeNavbar, Rail, Shelf, PersonHero
├── Settings: GeneralForm, UserManagement, LibrarySettings,
│   ProviderSettings, ScheduledTasks, MetadataDashboard,
│   SystemActivities, LogViewer, SettingsShell
└── Player: MediaPlayer, PlayerControls, AudioTrackMenu,
    TrailerPlayer, ThemeSongPlayer, YouTubeIframe, ControlsHelp

Why this matters for maintenance: Section* components are direct children of the item-detail page — their name tells you where they render. Leaf components (Button, Icon, CardSurface) are reusable building blocks. A contributor looking for "the hero section" finds SectionHero.svelte immediately, instead of grepping through itemPersonView.ts for the right render function. Splitting SectionSupport into SupportFileInfo + SupportMetadata means each panel can be tested and modified independently — the two panels share no state.

The naming convention scales: Section* = a <section> in the page stack, unprefixed = a sub-component or leaf, BrowseListing = a routed page (not a Section). Phase 5 Settings added 9 new components following the same convention with zero ambiguity about where each belongs.

Architecture: reactive stores

The vanilla client's AppState is a single mutable object. Any change triggers render(), which re-renders the entire DOM tree (mitigated by domPatcher.ts). The rewrite splits this into 8 domain stores, each a class with $state fields:

stores/
├── auth.svelte.ts     — bootstrap, login, user CRUD
├── catalog.svelte.ts  — home shelves, library items, search
├── item.svelte.ts     — item detail, metadata, playback decision
├── libraries.svelte.ts — library list, scan, refresh
├── settings.svelte.ts  — settings CRUD, providers
├── activities.svelte.ts — activities, logs, polling
├── playback.svelte.ts  — session, player state, seek
└── ui.svelte.ts       — error banner, help modal

Why this improves maintainability: a bug in metadata search only requires reading item.svelte.ts + MetadataSearchPanel.svelte — not the entire eventBindings.ts (1395 lines) to find which listener handles the form submit. Each store is independently testable. State changes are surgical — updating catalog.searchQuery only re-renders the search results, not the entire page. The vanilla domPatcher.ts diff layer (290 lines) is eliminated entirely — the Svelte compiler generates the same surgical updates automatically.

Architecture: the player

The vanilla playbackController.ts (1385 lines) is imperative DOM: querySelector for every control, addEventListener for every interaction, classList.toggle for state changes, manual render() calls to recreate the <video> element on audio-track switches. The rewrite replaces this with:

  • playback.svelte.ts store (~250 lines) — session lifecycle, player element state, seek logic, progress reporting
  • 8 player components (~400 lines total) — MediaPlayer, PlayerControls, AudioTrackMenu, TrailerPlayer, ThemeSongPlayer, YouTubeIframe, ControlsHelp, PlayerOverlay

Concrete improvement: when the user switches audio tracks, the vanilla code does state.activeAudioStreamIndex = X; render(false) — a full DOM re-render to recreate the <video> element with a new src. The rewrite changes playback.activeAudioStreamIndex → the derived streamUrl updates → Svelte patches the src attribute → the browser reloads the stream. No DOM re-render, no manual render call, ~50 lines of logic vs ~200.

Player UI improvements

  • Gradient fallback for media cards without artwork — the vanilla .media-card-art lacks overflow: hidden, so gradient fallbacks bleed past the 18px border radius. Fixed with overflow: hidden (scoped).
  • Hover behavior — vanilla's global button:hover lifts the entire card (including text below the poster) with a blue shadow. Fixed: only the poster tile lifts, not the text block.
  • Button spinner centering — vanilla's button.is-busy::after has position: absolute but no centering, so the spinner renders off-center to the right. Fixed with inset: 0; margin: auto (scoped to Button.svelte).
  • Slider full-range movement — native <input type="range"> thumbs can't reach true 0/max (the thumb width creates dead zones). Fixed with custom WebKit/Firefox thumb CSS so the progress bar and volume slider fill edge-to-edge.
  • Duration pinning during transcoding — the <video> element's reported duration grows as fragmented-MP4 fragments arrive. Fixed by pinning from item metadata (matching vanilla's intended behavior) + server-side X-Content-Duration header + ffmpeg -t flag.

Gamepad + remote navigation

The rewrite includes a full gamepad navigation system for couch/TV use:

Isolated gamepad package (src/lib/gamepad/) — framework-agnostic, designed to be extractable as a standalone library:

  • Layout detection — auto-detects the connected controller's button/stick mapping. Supports: standard W3C mapping (Xbox, DualSense, Switch Pro, Steam Deck), and custom layouts for non-standard controllers like the 8BitDo Pro 3 (D-pad on hat axis 9 with neutral value 3.286, Nintendo-style A/B swap, right stick on axes [2,5]).
  • Hat-axis parser — pure function that maps 8-position hat values to cardinal directions, handling both standard (neutral=0) and non-standard (neutral=3.286) controllers. 15 unit tests.
  • Stick filtering — radial deadzone, hysteresis, direction detection. 14 unit tests.
  • rAF polling engine — reads gamepads every frame, normalizes via detected layout, emits a GamepadInput object. Layout results are cached per gamepad index.

Spatial navigation — direction-aware focus management. Unlike the vanilla client's linear focus cycling (±1 in DOM order, where D-pad up/down/left/right all do the same thing), this finds the nearest focusable element in the requested direction using geometric distance.

Declarative navigation regions — each major UI area (sidebar, navbar, content, settings) registers itself as a region with entry points + internal navigation. When a region can't handle a direction (edge reached), the engine transitions to the adjacent region using declared entry points. This makes navigation predictable: "left from shelf" always goes to the Home button, not whichever sidebar element is closest.

Controls help modal — press the Select/Back button on a gamepad (or ? on keyboard) to see a context-aware controls reference showing what each button does in the current mode (browse vs player).

Analog stick debounce — the stick takes only the dominant direction (up/down/left/right), fires once, discards input for 600ms, then repeats at 350ms intervals. The raw magnitude is never used for UI navigation. This prevents the "one tap moves 10 elements" problem.

Current status: functional but still being tuned. Known issues: shelf scroll-up visibility needs refinement, item-detail and settings pages don't have regions wired yet (fallback to spatial search), sometimes the sidebar item nav is not working as expected.

Control mapping:

Input Browse Player
D-pad / Left stick Spatial focus navigation Seek (escalating)
Right stick Volume (up/down)
A button Activate / click Play / pause
B button Back (history) Close player
L / R bumper Switch tabs
Select Controls help Controls help

Mock API

The vanilla client already had a mock API layer for development without a backend. The rewrite ports and extends this — the same mock endpoints exist, with additions for the new features (playback sessions, settings CRUD, scheduled tasks, dashboard items, metadata search/link). The mock layer is gated by VITE_USE_MOCK_API and fully powers the Storybook stories. The key difference: the rewrite removed the dev-mode silent fallback that would flip to mock mode on any API failure (including auth errors) — this was masking real connection issues during development.

Storybook

98 stories across 30 groups with dark theme, preset dropdown (selectable fixture bundles), and store-driven disclaimers. The vanilla client has no Storybook. Each component can be previewed in isolation with realistic mock data — a MediaCard with artwork, a player controls bar in playing/paused state, a settings library card with scan/refresh actions, etc.

Screenshot

TODO — will add screenshots before marking ready for review.

Issues Fixed or Closed

  • Fixes player duration expanding during transcoding (duration pinned from item metadata + server-side X-Content-Duration header + ffmpeg -t)

Roadmap Issues

N/A

Type of Change

  • feat: New feature (non-breaking change which adds functionality)
  • fix: Bug fix (non-breaking change which fixes an issue)
  • docs: Documentation only changes
  • style: Changes that do not affect the meaning of the code (white-space, formatting, missing semicolons, etc.)
  • refactor: Code change that neither fixes a bug nor adds a feature
  • perf: Code change that improves performance
  • test: Adding missing tests or correcting existing tests
  • build: Changes that affect the build system or external dependencies
  • ci: Changes to CI configuration files and scripts
  • chore: Other changes that don't modify src or test files
  • revert: Reverts a previous commit
  • BREAKING CHANGE: Introduces a breaking change (can be combined with any type above)

Checklist

  • Code follows the style guidelines of this project
  • Code has been self-reviewed
  • Code has been commented, particularly in hard-to-understand areas
  • Code docstring/documentation-blocks for new or existing methods/components have been added or updated
  • Unit tests have been added or updated for any new or modified functionality

AI Usage

  • None: No AI tools were used in creating this PR
  • Light: AI provided minor assistance (formatting, simple suggestions)
  • Moderate: AI helped with code generation or debugging specific parts
  • Heavy: AI generated most or all of the code changes

Hazer added 30 commits June 22, 2026 13:18
Proof-of-concept Svelte 5 port of the Settings -> Logs view, plus a
PROPOSAL.md analyzing a full client-web rewrite and an optional Tauri
desktop shell.

The PoC (crates/client-web-svelte/) validates three claims that de-risk
the larger proposal:
  - SvelteKit + adapter-static builds a static dist/ (132 KB) the Rust
    server could serve, with SPA fallback.
  - Routing parity for /settings/logs via file-based routes.
  - The data layer (api.ts + mockApi.ts) ports verbatim; the
    VITE_USE_MOCK_API toggle and dev:mock workflow carry over unchanged.

What it deliberately does NOT cover: playbackController.ts, youtube.ts,
home shelf virtualization, item/person views, and app.ts's auto-refresh
polling -- those are the real work of a full migration, with
playbackController.ts being the headline risk per PROPOSAL.md.

Key finding from research: Koko's server already builds on tao +
tray-icon + keyring -- the exact crates Tauri is made of -- so a Tauri
desktop shell is lower-risk than typical. Recommended architecture keeps
the HTTP-served SPA for remote clients and adds Tauri as an optional
desktop shell loading the same frontend.

Verified: npm run check (0 errors), npm run build (dist/ written),
npm run dev:mock (serves at 127.0.0.1:4173 with mock data).
Extends the PoC from a single logs view into a representative slice of
the catalog/auth/settings experience, to better de-risk the rewrite
proposal.

New views (file-based routing replaces routes.ts regex parsing):
  - Home (/): shelves of MediaCards + library tabs + collections
  - Item detail (/items/[id]): hero/backdrop, metadata, seasons grid
  - Login (/login): form -> auth store, replaces renderLoginScreen
  - Settings hub (/settings/*): sub-nav + Libraries list (real mock data)
    + General/Dashboard stubs

Reusable component layer (replaces repeated string-template helpers
in homeView.ts/ui.ts):
  - MediaCard: poster card with hover preview + resume bar
  - Shelf: horizontal scroll rail with reactive arrow buttons
  - Tag, Spinner, Icon (inline SVG, no lucide runtime dep)

Expanded data layer (src/lib/api.ts): ports the catalog/auth types and
mock data subset (libraries, items, home, bootstrap, login) from
../client-web/src/api.ts + mockApi.ts verbatim. Adds an auth store
(auth.svelte.ts) using Svelte 5 class runes, replacing the vanilla
client's bootstrap+token pattern.

Layout (+layout.svelte): app shell + auth guard via $effect goto,
replaces startApp() and renderPageNavbar().

Verified: npm run check (0 errors), npm run build (dist/ written),
npm run dev:mock (all 6 routes serve HTTP 200 with mock data wired).
…elpers, primitives, split stores

Rewrites the half-baked PoC foundation with full-fidelity building blocks
based on line-accurate research of the vanilla client.

State management — split per-domain Svelte 5 rune stores (no monolith):
auth, libraries, catalog, item, settings, activities, ui. Selectors will
become $derived expressions over these.

Styles — diet shared app.css (~430 lines, down from vanilla's 3038) keeping
tokens, globals, and cross-component utilities; per-component rules move into
their components. Faithful invariants: dark-only, no scroll-snap, no custom
scrollbars, --muted intentionally undefined.

Mock data — verbatim vanilla seed (Matrix / Game of Thrones): items 101/201/
202/203/103/104, 3 libraries, 4 providers, itemMetadata (101 literal, 201 via
metadataMatchesWithSecondaries), Matrix collection, admin user, playbackProgress
seeds (1:101, 1:103) so continue-watching works. Reproduces all 4 behavioral
quirks: dev-mode silent fallback, read-time refresh-progress recompute,
applyMockPlaybackProgress overlay only with mock-token active, setTimeout
pending->fresh flips. Fixes the vanilla MetadataSearchResult duplicate-decl
bug (declared once, winning shape).

API — full type surface + all fetch fns + URL builders. getWebClientProfile
codec probe ported.

Helpers — pure functions only (formatDuration/Timestamp/FileSize/BitRate,
formatChildCount/humanizeItemType/libraryStatusLabel/selectedLibraryIcon,
playbackProgressPercent/resumablePlaybackPositionMs). String-rendering
helpers become Svelte components.

Primitives — <Icon> via @lucide/svelte (Svelte 5 native, not the Svelte 4
lucide-svelte), <Button>, <UserAvatar>, <CollapsibleText>.

Verified: npm run check (0 errors/warnings), npm run build (dist/ written).
The sidebar-rail layout (not a top navbar — that was the PoC's mistake):
220px library-rail + main-shell grid, collapsing to 88px on item/person
pages. Rail holds brand block, Home + per-library nav buttons with refresh
rings driven by metadata_refresh_pending, user card, Settings, Sign out.

Root layout wires auth bootstrap (auth.init on mount), auth gating via
$effect (redirect to /login when requiresLogin, redirect home when already
logged in on /login), the page backdrop (.page-backdrop with --page-backdrop-
image var), and the global error panel (ui.error).

Auth screens ported faithfully:
  - AuthShell: brand mark via /static/Koko.svg, error panel from ui store
  - LoginScreen: bind:value form -> auth.login, exact vanilla copy
  - WelcomeScreen: create-first-admin flow with profile-image upload
    validation (MIME + 2MB) matching readProfileImageUpload

Rail refresh ring reproduces the vanilla conic-gradient + ::after punch-hole.
Koko.svg brand asset copied to /static.

Verified: npm run check (0 errors), npm run build (dist/ written),
npm run dev:mock (/, /login serve HTTP 200, mock data + Koko.svg wired).
Home page ports renderHomePage + renderHomeTabContent faithfully:

  - <MediaCard>: all variants — episode 16:9 layout, missing-item amber
    border, watched checkmark, progress donut (--watch-progress conic
    gradient), metadata pending/unmatched badges, artwork via
    artwork_item_id ?? id, secondary meta (library · type). Mock-mode
    gradient fallbacks since mock artwork URLs are placeholders.
  - <Shelf>: scroll buttons with is-scroll-hidden, lazy expansion
    (HOME_SHELF_CHUNK_SIZE initial, appends on near-end scroll, matches
    data-lazy-rendered-count), native CSS grid auto-flow (no scroll-snap).
  - <HomeFeature>: both hero variants (collection + item) with the
    --home-feature-image right-mask ::before + left scrim ::after +
    isolation:isolate. Computed preview selection (search -> first hit,
    collections tab -> first collection, else first shelf item).
  - <HomeNavbar>: 5 browse tabs, search form with toggle (submit/button
    + clear), per-library scan + refresh-metadata buttons.
  - <HomeContent>: shared by / and /libraries/[id]; 5 tabs (recommended
    shelves, library grid, collections, playlists stub, categories stub)
    + full search-results override.

Routes: / (home), /libraries/[id] (scoped home), /login.

Verified: npm run check (0 errors), npm run build (dist/), npm run dev:mock
(/, /libraries/1, /libraries/2, /login all HTTP 200).
…pport)

Item page ports renderSelectedItemPage faithfully, composed of focused
sub-components:

  - <ItemHero>: poster, title (logo or fallback), tagline, meta row in
    fixed badge order (missing/playback/year/rating/genres), collapsible
    overview, action buttons (resume/play/start-over/targets/trailer/theme/
    back), and the technical fact list (duration/format/codecs/resolution/
    bitrate/size). Action buttons dispatch into the item/ui stores — the
    actual player is the playbackController spike.
  - <ItemBreadcrumbs>: hierarchy nav (show/season/episode parents).
  - <ItemPeople>: horizontal rail of person cards from the first metadata
    match's people array, sorted by sort_order.
  - <ItemExtras>: trailer/theme-song thumbnails with hover play overlay.
  - <ItemChildren>: seasons (shows) / episodes (seasons) / contained items.
  - <ItemSupport>: file+library info panel + linked-metadata panel with
    provider attribution (resolved via metadata.providers, not the match).

selectors.ts ports the pure selector functions (pageBackdropUrlForItem,
backNavigationTarget, selectedItemTechnicalFacts, selectedItemPeople) as
explicit-argument functions — no global state singleton.

Playback Play/Trailer/Theme buttons render and dispatch, but surface a
'not yet implemented' message until the playbackController spike lands.

Verified: npm run check (0 errors), npm run build (dist/), npm run dev:mock
(/, /items/101, /items/201, /items/203, /items/9999 all HTTP 200).
Person page ports renderPersonPage faithfully:

  - <PersonHero>: portrait, name, provider tag, credits count, birthday +
    age, gender, birthplace, collapsible biography, known-for tags, Back
    + provider-page buttons.
  - <PersonCredits>: the credit grid with expandable trays. Buckets credits
    by root show, then by season, collecting episodes (mirrors
    personCreditGroups). Tray expansion uses reactive state (activeGroupId /
    activeSeasonId) instead of the vanilla client's imperative
    bindPersonCreditTrays. Tray CSS order is computed at activation from the
    hovered card's offsetTop so it lands after the right visual row.

Route: /people/[id].

Verified: npm run check (0 errors), npm run build (dist/), npm run dev:mock
(/people/1, /people/2, /people/3, /people/999 all HTTP 200).
…er HMR

import KokoLogo from '/Koko.svg' made Vite serve the SVG with MIME
image/svg+xml as a module script, which fails strict module-script checking
(Error 500, cascading into 'Failed to fetch dynamically imported module').
The logo lives in /static, so it's served at /Koko.svg as a plain asset —
reference it as a URL string instead, matching how Rail.svelte already does
it correctly.

Reported error: 500 on /Koko.svg?import + 'Failed to fetch dynamically
imported module: .../nodes/0.js' breaking the whole page.

Verified: npm run check (0 errors), npm run dev:mock serves / at 200 with
the SVG fetched as image/svg+xml (asset, not module).
Source-level diff against the vanilla client surfaced widespread presentation-
layer drift in Phases 1-4 (shell/auth/home/item/person). Fixed in-place,
keeping scoped styles scoped and shared rules global (one source of truth
per rule).

Surgical fixes:
- Rail: refresh-ring gating now matches vanilla libraryRefreshProgress
  (total>0 && pending>0, or active activity) — kills phantom rings on
  Movies/Shows. Home glyph layout-grid -> house. Rail icons 24 -> 18.
  Restore .rail-avatar on the user card. Refresh-indicator wrapper restored.
- HomeFeature Open button: drop iconPosition="end" (arrow now leads, [-> Open]);
  pass home-feature-action class for positioning.
- HomeNavbar search-toggle: remove spurious secondary-button (restores brand
  gradient; scan/refresh keep secondary as vanilla does).
- Icon.svelte: add house + player/settings icons (pause, skip-*, maximize,
  picture-in-picture, volume-x, languages, link-2, plus, trash-2) so later
  phases don't render blanks.

Wiring:
- Tab switch clears search + browse filter (vanilla clearHomeSearch).
- Debounced (250ms) live-search on input.
- Person Back uses history.back().

Value restoration (scoped rules corrected to vanilla):
- ItemPeople person-card family (2.2rem art, flat bg, 0.8rem subtitle).
- ItemExtras (8px radius, 135deg indigo gradient, 700 title, music icon for
  theme songs, placeholder-icon sizing).
- ItemSupport grid (poster+info columns) + info-list layout.
- ItemHero item-fact family + restored Watched/Missing tag icons, watch
  count, progress %.
- MediaCard: kind-row/dynamic-badges gaps+align, status family, single
  has-multiple span (vanilla semantics), card-icon wrapper, missing-since
  tooltip, status-icon sizing via CSS.
- CollapsibleText toggle button (9fc2ff, 700, borderless).
- PersonCredits season/episode grids (150/190px).
- PersonHero button-link (pill).
- HomeContent search-result rows (64px thumbnail column restored; person +
  playlist result types added).
- HomeFeature (full block, :global ancestor overrides, line-clamp standard).
- HomeNavbar (gap 0.75rem).
- Shelf scroll buttons 20 -> 18.

Policy docs:
- POSTMORTEM.md — root-cause analysis of the drift pattern.
- PORTING_GUIDELINES.md — scoped-vs-global line, icon sizing table,
  class-list/route/markup contracts, per-component verification checklist.
- BROWSE_FILTER_PROPOSAL.md — Option A (real routes) recommended for the
  dead /collections/:id links; decision pending.

Verified: svelte-check 0 errors (2 pre-existing a11y warnings), production
build clean, fixes confirmed live on the dev server.

No Rust touched (Svelte sub-crate only); nightly unavailable in this env so
cargo +nightly fmt is a no-op here.
…clip)

Regression from the re-scope: hoisting .media-card-art to global with the
vanilla value dropped overflow:hidden. Vanilla omits it because it never
renders an absolutely-positioned child; the mock-mode gradient fallback is
one, so without clipping it bled past the 18px corners (and could disturb
the title block layout below). overflow:hidden is a documented Svelte-port
delta on this shared rule.
The global button:hover lift/glow applies to .media-card, lighting up the
title/subtitle block below the poster as a mismatched card whose rounded
corners don't align with the poster (text leaks past on hover). The same
flaw exists in vanilla. The port now:
- suppresses transform/box-shadow/filter on .media-card:hover
- lifts only the poster tile (.media-card-art) on hover, a coherent effect
- tightens focus-visible outline offset

Recorded as a deliberate delta in PORTING_GUIDELINES.md section 7.
- MediaCard: restore the colored brand lift shadow on hover (previous fix
  was too aggressive and dropped it entirely). Shadow casts from the poster
  tile via its natural 18px radius.
- Rail: Home was active on every /libraries/:id page too because onHome
  matched the /libraries prefix. Now Home is active only on '/' (the
  all-libraries home); a specific library page lights up its own button,
  matching vanilla app.ts:396 (gates Home on activeLibraryId() undefined).
The unmatched/pending metadata badge diverged from vanilla's
metadataBadgeMarkup (homeView.ts:297-314):
- vanilla renders ONE status span with conditional classes; the port had
  split it into three separate branches.
- vanilla nests <span class=status-warning-icon><span class=status-icon>
  <svg/></span></span>; the port merged the classes onto one span.
- vanilla is always icon-only (no text); the port added a 'Unmatched' label.

Now emits the single span with the dynamic class string + title/aria, nested
warning/status-icon wrappers, and no text label — matching vanilla exactly.
The @media (max-width: 960px) block was missing the grid-column/grid-row/
height/max-width resets on .library-rail and .main-shell, so at narrow
viewports the rail kept its base grid-column:1 / height:100vh and never
became the horizontal top bar vanilla shows. Restored the full vanilla
rules (style.css:2920-2962): .library-rail { grid-column:auto; grid-row:auto;
height:auto; max-width:none; align-items:center }, .library-rail-top/bottom
{ flex-direction:row }, .rail-nav { overflow:visible }, .main-shell {
grid-column:auto; grid-row:auto; height:auto; overflow:visible }.

Also fixed the rail-collapse condition: the port collapsed on /items/ AND
/people/, but vanilla isRailCollapsed (app.ts:312-314) gates on item pages
only. Person pages keep the full-width rail.
I incorrectly claimed the mock seeds matched 1:1. They do for has_metadata —
but the bug was in the predicate: isUnmatched used 'has_metadata === false'
(strict), so items where the field is ABSENT (undefined) were treated as
matched. Vanilla metadataBadgeMarkup (homeView.ts:299) uses the falsy
'!item.has_metadata', so undefined -> unmatched. The mock audio tracks
(Mock Song, Roadtrip Mix) omit has_metadata entirely, so vanilla shows the
'unmatched' warning badge on them; the port did not.

Fixed to '!item.has_metadata' matching vanilla. has_metadata?: boolean is
optional in the type surface, so undefined is a legitimate 'not linked yet'
state.
…_count

Overall review of the metadata/playback badge predicates after the
isUnmatched fix, to put the falsy-check concern to rest.

- isUnmatched: hardened from falsy `!item.has_metadata` to the stricter
  `item.has_metadata !== true`. Behaviorally identical for the valid domain
  (boolean | undefined) but won't misread a hypothetical 0/''/null as
  matched. Comment explains the choice.
- MediaCard watched badge: was driven by `playback_completed === true`, but
  vanilla playbackStatusBadgeMarkup (homeView.ts:354-355) keys off
  `watch_count ?? 0` > 0. These differ (a card can be completed with
  watch_count 0, or watched with playback_completed unset). Fixed to
  watch_count > 0, and the title now carries the count ("Watched" /
  "Watched N times") like vanilla.
- ItemHero watched label: `<= 1` -> `=== 1` to match vanilla detail
  (homeView.ts:372) exactly (the badge only renders at >0 so the <= vs ==
  difference was unreachable, but corrected for veracity).

Audit of remaining predicates — all confirmed faithful:
- missing badge: Boolean(missing_since) ≡ vanilla !!(missing_since)
- progress badge: playbackProgressPercent is a verbatim port
- metadata pending: metadata_refresh_state === 'pending' (strict, matches
  vanilla itemIsMetadataPending)
- mockApi has_metadata filters (lines 770/1245/1438): truthy `&& item.has_metadata`
  as include-predicates, verbatim match to vanilla (994/1548) — intended semantic.

svelte-check: 0 errors.
Investigation result: vanilla's state.browseFilter is dead code — only ever
assigned undefined (5 sites), never a real value, so the 'hybrid' store
branch is unreachable. Vanilla is route-driven in practice.

browseFilterForRoute is a pure derivation from route params + existing
catalog selectors, so the SvelteKit port needs zero new store state — page.params
+ load supply the route inputs, catalog.home.collections covers collections.
Only new selector work: categorySummaries (also unblocks the Categories tab stub).

Decision: Option A (real routes, no store). Scheduled for Phase 6.
… tab

Implements the B6 decision: real SvelteKit routes for browse-detail, no
browseFilter store state (vanilla's is dead code — verified). Collection,
category, and playlist detail now resolve purely from route params +
existing catalog data.

Selectors (src/lib/selectors.ts):
- categorySummaries(libraryItems): groups by genre, dedupes on root items.
  Port of selectors.ts:135-163. Unblocks the Categories home tab.
- itemsForCollection, topLevelLibraryItems, rootAncestorForItem helpers.

Paths (src/lib/paths.ts):
- browseDetailPath(kind, key, libraryId?): /items/<seg>/<key> or library-
  scoped /libraries/:id/items/<seg>/<key>. Port of homeView.ts:41-52.
- homeBrowsePath(libraryId?).

Component (src/lib/components/BrowseDetail.svelte):
- Renders collection/category/playlist detail (hero + item grid). Reads
  kind/key/libraryId from props, derives data from catalog store. Ports
  renderBrowseDetailPage + renderCollectionDetailPage /
  renderCategoryDetailPage / renderPlaylistDetailPage.

Routes:
- src/routes/items/[kind]/[key]/+page.svelte (library-less)
- src/routes/libraries/[id]/items/[kind]/[key]/+page.svelte (scoped)
  Both validate kind ∈ {collections,categories,playlists}.

Wiring:
- Three dead goto('/collections/:id') callsites (HomeContent collection card,
  HomeContent search-result collection row, HomeFeature Open button) now use
  browseDetailPath('collection', id, libraryId).
- Categories tab: was a stub; now renders genre cards (categorySummaries)
  that navigate via browseDetailPath('category', genre).
- Playlists tab: was a stub; now renders the planned-playlist card
  navigating via browseDetailPath('playlist', name).

svelte-check: 0 errors (2 pre-existing a11y warnings). Build clean.
… stories

Storybook for visual/docs DX — isolate every ported component so we catch
context-only regressions (the MediaCard/badge class of bugs) and get living
design-system docs before Phase 5 lands.

Tooling:
- @storybook/sveltekit 10.4.6 (Svelte 5 runes support, official)
- @storybook/addon-svelte-csf 5.1.2 — author stories as .svelte files
  (defineMeta + <Story>, runes-native)
- addon-docs, addon-a11y, addon-vitest (test panel; runner not wired yet)

Foundation:
- .storybook/main.ts: framework + addons + viteFinal aliases stubbing
  $app/state and $app/navigation (Storybook isn't a router) →
  src/lib/storybook/mockAppState.svelte.ts (mutable runes-based page) +
  mockAppNavigation.ts (goto no-op).
- .storybook/preview.ts: imports app.css (tokens/shared rules components
  depend on), dark-only background (client is dark-only), WithStores
  decorator globally applied.
- .storybook/decorators/WithStores.svelte: reads args.preset, seeds the
  store singletons (catalog/item/libraries/auth/ui) via $state mutation,
  resets on cleanup. Stories pick fixtures via args={{ preset: 'home' }}.
- src/lib/storybook/fixtures.ts: self-contained seed builders (movie/show/
  season/episode/track summaries, movie detail, libraries, home, user).
- src/lib/storybook/presets.ts: named presets (empty/home/item-movie/show/
  missing/watched/auth-logged-in) + resetStores().

Stories (validated — 14 index cleanly, storybook boots, no parse/runtime errors):
- MediaCard: Movie/Show/Season/Episode/Track/Unmatched/Watched/In Progress/
  Missing/Metadata Pending. The Unmatched story is a regression cage for the
  isUnmatched predicate (has_metadata !== true).
- Icon: Gallery (every ICONS map name — canonical set + missing-icon catcher)
  + Single.

vite.config.ts: reverted the scaffolded @storybook/addon-vitest runner block
(brings headless-playwright browser tests — more than the current docs/visual
goal); the addon stays in main.ts for the test panel UI and peer satisfaction.
App build + svelte-check clean.

svelte-check: 0 errors (2 pre-existing a11y warnings on PersonCredits).

Note on versions: @sveltejs/kit/svelte/adapter-static already resolve to
latest stable (2.66.0 / 5.56.3 / 3.0.10). Vite 7→8 + vite-plugin-svelte 6→7
are one major behind; deferred to a focused follow-up.
Extends the Storybook foundation (f1e3447) with coverage for every ported
component, plus fixture/preset additions and a README section.

New stories:
- Button (primary/secondary/danger/icon-start/icon-end/busy/disabled)
- CollapsibleText (collapsed/short/expanded)
- ItemBreadcrumbs (episode-in-show / no-hierarchy)
- Shelf (many-items scroll / few-items / empty)
- HomeFeature (item hero / collection hero)
- ItemHero (movie/show/missing/watched — reads item store via preset)
- ItemPeople, ItemExtras, ItemSupport (populated + empty states)
- Rail (home-active / library-active / collapsed)
- HomeNavbar (all-libraries / active-library scan+refresh)
- BrowseDetail (collection/category/playlist)
- PersonCredits (empty; populated variant pending a realistic credit fixture)
- Auth screens (Login / Welcome first-user / AuthShell)

Fixtures/presets added:
- fixtures.ts: mockExtras(), mockMetadata() (MetadataProviderStatus +
  ItemMetadataMatch with all required fields incl. locale_key), mockHome
  collection with provider_id/external_id, release_year on movieDetail.
- presets.ts: 'requires-login' + 'requires-setup' for the auth screens.

Type-safety notes:
- Stories that compose components with explicit props (HomeFeature,
  ItemBreadcrumbs) omit `component:` from defineMeta so the decorator-only
  args (preset/route) aren't typed against the component Props.
- Stories using `import type` use `<script module lang="ts">`.

Verified: svelte-check 0 errors (2 pre-existing a11y warnings); storybook
boots clean, 67 stories across 16 groups index without parse/runtime errors.
README documents the storybook commands, the preset/route args system, and
the $app/state mock aliasing.
The decorator used two script tags (module for imports, instance for
$effect) with $props() in the module script. Svelte 5 forbids $props()
anywhere but the instance script's top level (props_invalid_placement).
The Storybook indexer didn't catch it (static parse) but the Svelte Vite
plugin did at transform time, breaking every story's render.

Consolidated into a single instance <script lang="ts">. Verified: storybook
boots clean, stories render (the earlier index.json check only confirmed
indexing, not rendering — this exercises the actual compile/render path).
Args-driven stories (BrowseDetail — <Story name args={{...}}/> with no
children) don't always forward a children snippet to global decorators in
Svelte CSF v5, so {@render children()} crashed (invalid_snippet). Made
children optional + use {@render children?.()} as the error guidance suggests.
Typed the prop as children?: Snippet.

Note: if any args-only story renders blank after this (the auto-mounted
component not reaching the decorator), the fallback is to mount the component
explicitly via <Story asChild> in those stories. Please verify BrowseDetail
shows content in the canvas.
…before)

The args-driven path with no explicit children + no 'component:' in the meta
hit a Svelte CSF internal 'anchor.before is not a function' during render
(the prior children?.() fix avoided the snippet crash but exposed this).

BrowseDetail now uses the documented asChild pattern: explicit
<BrowseDetail .../> as the Story child, with preset/route still flowing via
args to the global decorator. Button/CollapsibleText/MediaCard remain
args-driven (they keep 'component:' in defineMeta, which is the path that
works); only composed stories need asChild.
…on decorator

The withStores global decorator was registered as `decorators: [WithStores]`
(passing a Svelte component directly). The Svelte renderer decorator contract
(decorateStory in @storybook/svelte) invokes each decorator as
`decorator(storyFn, context)` and expects a function returning a Component or
`{ Component, props }`. Passing the component itself made the renderer invoke
it as the decorator function with `(storyFn, context)`, corrupting Svelte 5's
internal mount and producing `anchor.before is not a function` in every story.

Rewrite WithStores.svelte → withStores.ts as a plain function decorator:
  - reads preset/route from `context.args` (the documented mocking pattern)
  - seeds stores + mock $app/state inside `untrack()` so the synchronous
    mutation doesn't trip Svelte 5's state_unsafe_mutation guard (the renderer
    runs decorators inside a reactive effect) and stays ahead of `storyFn()`
    so the mounted component sees seeded state on first render (no race)
  - returns `storyFn()` to hand rendering back to the normal mount path

Verified via headless Playwright smoke test across 11 stories (BrowseDetail,
MediaCard variants, Button, ItemHero, Rail, HomeNavbar, Icon, Shelf): zero
anchor.before and zero state_unsafe_mutation occurrences; all stories render
real DOM (11-15KB). Remaining surfaced errors (duplicate each keys, undefined
library_id, 404 mock assets) are genuine fixture/component bugs for Storybook
to catch — tracked separately.
Three distinct root causes, all surfaced now that the global decorator
mounts stories correctly (previous commit):

1. Stories discarded their props (library_id / slice errors)
   13 story files declared `component:` in defineMeta AND passed children
   (e.g. `<Story ...><MediaCard item={...}/></Story>`) without `asChild`.
   Per addon-svelte-csf Story.svelte, that branch renders
   `<component {...args} {children}/>` — the args (preset/route only) are
   spread onto the component, and the explicit props in the snippet are
   never applied. Result: MediaCard got item=undefined → item.library_id
   threw; Shelf got items=undefined → items.slice threw; etc.
   Fix: add `asChild` to every child-bearing <Story>. asChild forces the
   `{@render children()}` branch, rendering the snippet as-is. (BrowseDetail
   already used asChild — that's why it alone worked.)

2. BrowseDetail duplicate each key `101`
   mockHome()'s continue_watching shelf spread `{...movie}` keeping id 101,
   colliding with the canonical movie (101) in recently_added when shelves
   were flattened into catalog.libraryItems. Also show/season/episode
   legitimately appeared in multiple shelves, duping on flatten.
   Fix: give the in-progress movie a distinct id (401), and dedupe the
   flattened libraryItems by id in the 'home' preset (libraryItems is a
   unique-id list from /libraries/:id/items in the real app).

3. PersonCredits a11y_no_noninteractive_tabindex (lines 127, 156)
   The port added tabindex="0" to <article> credit cards for keyboard
   access (vanilla relies on grid event delegation, no tabindex). <article>
   is non-interactive, so Svelte flags tabindex>=0.
   Fix: add role="button" + aria-label + Enter/Space keydown handler to
   both card types — turns them into genuine keyboard-activatable controls
   (a real a11y improvement over vanilla, not just warning suppression).

Verified: svelte-check 0 errors; headless Playwright across all 48 canvas
stories — 48 clean / 0 failed. Previously-broken stories now render real
DOM (MediaCard .media-card, Shelf, BrowseDetail, ItemHero, PersonCredits
all confirmed present).
… fixes

Ten improvements to the Storybook DX, addressing user-reported issues:

1. Logo loads in Auth/Rail: move Koko.svg to src/lib/assets/ and import as a
   Vite URL asset (was src="/Koko.svg", a root path that 404s in Storybook's
   iframe). Updated AuthShell, Rail, +layout. Verified naturalWidth=610.

2. Docs pages dark by default: storybook-dark.css overrides Storybook's theme
   CSS vars + docs containers, injected via managerHead/previewHead in main.ts.
   The canvas was already dark; now the chrome matches.

3. Button busy spinner centered: button.is-busy::after lacked inset:0/margin:auto,
   so the spinner rendered off-center to the right. Vanilla has the same bug
   (style.css:89) — fixed here as a documented delta.

4. Icon "Customize" story: interactive color/alpha/size controls via a new
   IconPreview wrapper (recolors via currentColor + opacity). Lucide props stay
   minimal; the wrapper lives alongside the story.

5. ItemPeople "With Cast" renders: mockMetadata now ships 4 cast members (was
   empty, so the component rendered nothing — vanilla has no empty-state).
   "No People" documents the intentionally-blank case.

6. ItemExtras thumbnail cover: background-size/position/repeat so inline
   background-image covers the box like <img object-fit:cover>; gradient
   fallback layered via :not(.has-image).

7. Rail "Full Vertical" story: pins desktop sidebar layout regardless of canvas
   width (overrides the ≤960px media query), so the vertical rail is reviewable
   without widening Storybook.

8. Local CC0 artworks: 5 picsum/Unsplash photos (movie/show/season/episode/
   track) bundled in src/lib/assets/artworks/, registered in artworks.ts, and
   served via a mockArtworkResolver hook in getArtworkUrl (production never
   sets it — tree-shaken). MediaCard now renders real artwork in mock mode
   when a URL is registered, gradient fallback otherwise.

   Also: main.ts defines import.meta.env.VITE_USE_MOCK_API='true' so the mock
   dispatch layer + resolver actually activate in Storybook.

9. Store-driven disclaimers: Auth, HomeNavbar, Rail docs explain why controls
   are sparse (state comes from stores via preset/route args, not props) with
   TODOs for per-state variants.

10. Categorization: leaf UI stays Components/, page-region composites
    (BrowseDetail, HomeFeature, HomeNavbar, ItemHero, Rail) → Fragments/,
    full-page shells (Auth) → Screens/.

Verified: svelte-check 0 errors; headless Playwright across all 53 canvas
stories — 53 clean / 0 failed. Logo loads (naturalWidth=610), artworks render
(background-image resolves to bundled assets), docs chrome is dark.
…component

Two fixes addressing user-reported regressions from the previous commit:

1. Docs pages dark — done properly via the Storybook theme API
   The previous attempt injected storybook-dark.css via managerHead/previewHead,
   overriding CSS variables (--sb-ui-background, --background, etc.). That
   doesn't work: Storybook 10 renders its entire UI with Emotion CSS-in-JS
   (hashed classes like css-1z0jwx), ignoring CSS variables entirely. The
   result was light backgrounds (rgb(246,249,252)) + a forced color-scheme:
   dark — dark form controls on white, the "weird CSS" reported.

   Fixed by using the supported theme API:
     - new .storybook/manager.ts: addons.setConfig({ theme: themes.dark })
       themes the sidebar + toolbar (Emotion generates the right dark styles).
     - preview.ts: parameters.docs.theme = themes.dark + ensure(themes.dark)
       forces the docs page dark (manager theme doesn't propagate to docs —
       storybookjs/storybook#28664).
     - removed storybook-dark.css + the managerHead/previewHead injection.

   Verified via Playwright: bodyBg now rgb(27,28,29) on all docs pages
   (Button, MediaCard, Rail, BrowseDetail, Auth) with color-scheme: normal
   (consistent, no mismatch) and 0 errors.

2. Button busy spinner — scoped to Button.svelte, centered
   Moved the is-busy + ::after spinner rules out of app.css into a <style>
   block on Button.svelte (scoped). Added inset:0; margin:auto to center the
   spinner (vanilla's style.css:89 omits this — spinner renders off-center to
   the right; pre-existing vanilla bug, documented delta).

   Verified via Playwright: spinner ::after has inset:0px, margin auto-resolves
   to center it (15.8px/42.7px in a 105×52px button), animation: spin. The
   global button.is-busy rule was removed from app.css; @Keyframes spin stays
   global (shared with .loading-spinner).

Full smoke test: 53/53 canvas stories clean, 0 failed.
…temBreadcrumbs props

DX improvements (Chunk 1 of the card/split refactor):

1. preset is now a select dropdown everywhere
   The `preset` arg was a free-text field — typo-prone and the available
   fixture bundles were invisible. presets.ts now exports PRESETS as a single
   source of truth (`as const satisfies readonly Preset[]` keeps it in sync
   with the union). preview.ts adds a global argTypes.preset with control:
   'select' + options, so every story's controls panel shows a dropdown with
   the 9 preset names. route gets a description too.

2. BrowseDetail docs: store-driven disclaimer
   Added the ⚠️ Store-driven disclaimer (matching Auth/HomeNavbar/Rail) with a
   TODO for threading collection + items as props later.

3. ItemBreadcrumbs: injectable navigation
   Added onnavigate?: (itemId) => void prop (default falls back to goto).
   The story passes a no-op, so ItemBreadcrumbs is now fully props-driven and
   decoupled from $app/navigation for Storybook/tests.

Verified: svelte-check 0 errors; Playwright confirms the controls panel renders
a <select> with 10 options (9 presets + placeholder) and the preset label.
Adopt a consistent naming taxonomy that scales as the app grows:
  - Section*  = a <section> that's a direct child of the item-detail page stack
  - BrowseListing = a routed page-level item list (collection/category/playlist)
  - unprefixed = sub-component or leaf (MediaCard, Button, etc.)

Renames (mechanical, no logic change):
  ItemHero         → SectionHero
  ItemSupport      → SectionSupport
  ItemPeople       → SectionPeople
  ItemExtras       → SectionExtras
  ItemChildren     → SectionChildren
  ItemBreadcrumbs  → SectionBreadcrumbs
  BrowseDetail     → BrowseListing  (+ BrowseDetailKind → BrowseListingKind)

Updated: component files (via git mv), all imports, composition page
(routes/items/[id]/+page.svelte), browse route pages, all story titles,
defineMeta component refs, and referencing comments. CSS class names
(.item-hero, .item-support, etc.) and the selectedItemPeople selector are
unchanged — they're fidelity to vanilla / not component names.

Verified: svelte-check 0 errors; Playwright smoke 53/53 stories clean across
all 16 categories. Sidebar now shows Section*/BrowseListing consistently.
…nents

Chunk 3 of the card/split refactor — shared card primitive.

New CardSurface.svelte: encapsulates the structure shared by the rectangular
"tile + caption" cards: a transparent <button> root (layout-only flex column,
no bg/shadow/padding) + a rounded, overflow-clipped tile wrapper. Props:
tileRadius, aspectRatio, bordered, hover ('tile'|'none'), tileClass; slots:
art (tile content) + body (caption). Card-wide bugs (corner bleed, hover leak)
are fixed in this one place instead of N. hover='tile' suppresses the global
button hover and lifts only the tile (the MediaCard blue-shadow treatment).

Extracted two cards that were inlined in their section components:
  - PersonCard.svelte (from SectionPeople) — uses CardSurface, 2/3 aspect,
    flat bg + initials placeholder. Navigation is now injected via onnavigate.
  - MediaExtraCard.svelte (from SectionExtras) — uses CardSurface, 16/9 aspect,
    bordered, gradient fallback + placeholder/play icons. onplay callback.

SectionPeople + SectionExtras are now thin rails that delegate to the cards.

MediaCard intentionally keeps its own markup + global .media-card CSS (shared
with Shelf/BrowseListing, tightly coupled to its badge/progress structure).
Its overflow:hidden + hover fixes already landed earlier; forcing it through
CardSurface would create conflict without benefit. Comment added explaining
the trade-off.

Verified: svelte-check 0 errors; Playwright confirms CardSurface is used
(card-surface-tile present) by PersonCard + MediaExtraCard; full smoke 53/53.
Hazer added 24 commits June 22, 2026 13:18
1. Slider fill: removed accent-color (doesn't work with appearance:none).
   Added --slider-fill CSS variable that controls a linear-gradient on the
   WebKit track, showing the filled portion in green (#8bf3ca). Firefox uses
   ::-moz-range-progress (native fill). PlayerControls sets --slider-fill
   inline based on progressValue/volume. Removed the thin border (border:0
   on thumb).

2. Transcoded seek: the element's currentTime is relative to the stream
   start (0-based from start_ms), not the true media position. Added
   playbackBaseOffsetSeconds to onTimeUpdate (mirrors vanilla
   playbackController.ts:1027): displayed position = startMs/1000 +
   currentTime. Also fixed seekTo for transcoded content: set startMs,
   optimistically update currentTime for the slider, mark initial seek as
   applied (so metadata load doesn't re-seek), then el.load() to reload
   the stream from the new position.
1. Slider border: global input CSS (border + padding + border-radius) was
   applying to range inputs, adding a visible 1px border. Excluded range
   inputs: input:not([type='range']).

2. Audio track default selection: activeAudioIndex defaulted to 0 when no
   explicit override was set, but the file's default audio track may be at
   a different index (the 'default: true' flag). The menu showed track 0 as
   active even when a different track was actually playing, so you couldn't
   select track 0. Fixed by using the track's 'default' flag to determine
   the initial active index. Also removed the activeIndex guard in select()
   so re-selecting the "active" track still fires onselect (force-switch).

Note: the duplicate items (same file in two libraries → two item IDs) is a
backend scanner issue, not a client bug.
1. Audio track active index: the derived was returning a stream index (from
   activeAudioStreamIndex) but the template compared it against an array index
   (from {#each}). When stream indices ≠ array positions, the wrong track
   showed as active. Fixed: activeAudioIndex now always returns an array index,
   using findIndex to map the stream index to its array position, and falling
   back to the track's 'default' flag.

2. Stale error overlay: switching audio tracks re-creates the session, which
   briefly fires an error event on the <video> during the src transition. The
   error overlay (hasError=true) persisted even after the new stream loaded
   successfully. Fixed: clear hasError in switchAudioTrack before reload, and
   clear it on successful events (onPlaying, onCanPlay).

Also added BACKEND_ISSUES.md documenting the stale DB entries issue for later.
The server decides which audio stream to start with (returned as
audio_stream_index on the PlaybackSession). This may differ from the
container's default flag — e.g. the server returned index=1 (English)
even though the container marks index=0 (Japanese) as default.

The activeAudioIndex derived now checks in order:
  1. User's explicit switch (activeAudioStreamIndex)
  2. Session's audio_stream_index (server's choice)
  3. Container's default flag
  4. Fallback to 0
1. Video captions a11y: Svelte requires a static <track kind="captions"> as
   a direct child of <video>. The conditional {#if} track wasn't recognized.
   Fixed by always rendering a captions track as the first child (using the
   first subtitle track when available), with remaining subtitles as
   kind="subtitles".

2. MetadataSearchPanel state_referenced_locally: wrapped initial-value
   computations in untrack() to explicitly opt out of reactivity for the
   one-time form defaults.

3. PersonCredits a11y_no_noninteractive_element_to_interactive_role: changed
   <article role="button"> to <div role="button"> — <article> is semantically
   non-interactive, <div> is neutral and accepts any role. Same keyboard
   handlers + tabindex preserved.
…eams

Two server-side improvements for the transcoded stream duration issue:

1. X-Content-Duration header: the transcode streaming response now includes
   X-Content-Duration (total media duration in seconds) from the item's
   probed duration_ms. Clients can pin the progress bar from this header
   even without item metadata. Added content_duration to SessionStream::Transcode.

2. ffmpeg -t flag: the transcode spec now includes duration_ms, and the
   ffmpeg args include -t (remaining duration = total - start offset). This
   makes the fragmented-MP4 stream carry a proper total duration in its moov
   atom, so the browser's <video>.duration is correct even without the header.

Also added resolve_media_item_duration_ms public helper to media.rs.
PROJECT_OVERVIEW.md — complete description of the Svelte 5 rewrite:
  - What it is + how to run (dev, storybook, production)
  - Architecture comparison (vanilla vs Svelte, line-by-line)
  - Detailed new architecture (component tree, stores, routes)
  - Feature completeness checklist (all vanilla features ported)
  - 24 documented improvements over vanilla
  - Tech stack versions
  - Testing methodology
  - Stats comparison table
  - Known issues

Ready to forward to LizardByte for evaluation.
A proper TV/couch navigation system — direction-aware spatial focus
management, replacing vanilla's broken linear focus cycling (±1 in DOM
order, where D-pad up/down/left/right all did the same thing).

New: src/lib/actions/spatialNavigation.ts (~230 lines)
  - Spatial focus: moveFocus(direction) finds the nearest focusable element
    in the requested direction using geometric distance scoring (perpendicular
    alignment + parallel distance). Up goes up, right goes right.
  - Gamepad polling (rAF): D-pad (buttons 12-15) + left stick (axes 0-1) with
    0.4 activate / 0.25 release hysteresis (prevents drift rapid-fire).
    A button = activate, B button = history.back.
  - Keyboard: ArrowUp/Down/Left/Right use the same spatial logic.
  - scrollIntoView: focused elements scroll into view (vanilla doesn't).
  - Focus indicator: .is-spatial-focus class with green outline, cleared when
    the mouse moves (mouse focus doesn't show the indicator).
  - Pauses when player is open (player has its own handler).

Enhanced: src/lib/actions/playerShortcuts.ts
  - Added gamepad polling for the player overlay: A=play/pause, B=close,
    left/right=seek (escalating), up/down=volume.
  - Added onVolumeUp/onVolumeDown handlers + ArrowUp/ArrowDown keyboard.

Wired: +layout.svelte (use:spatialNavigation on app-shell), app.css
(.is-spatial-focus outline), PlayerControls (volume handlers for gamepad).

Mapping:
  Non-player: D-pad/stick = spatial focus, A = activate, B = back
  Player: A = play/pause, B = close, left/right = seek, up/down = volume
…lling

When navigating cards in a horizontal shelf with arrow keys/gamepad, the
focused card now scrolls into view horizontally (not just vertically).
Previously, cards beyond the right edge of a shelf weren't reachable by
spatial navigation because the shelf didn't scroll horizontally.

The AudioTrackMenu focus trap (up/down should navigate track options when
the menu is open, not change volume) is a separate component-level fix.
…odal

Major rewrite of the spatial navigation based on actual gamepad probe data
from an 8BitDo Pro 3 (non-standard mapping, 24 buttons, 10 axes):

Fixes:
1. D-pad support: the 8BitDo reports D-pad on axis 9 (hat), not buttons
   12-15. Added hat-axis detection with cardinal-direction thresholds.
2. Sensitivity: raised stick threshold from 0.4 to 0.7 with release at 0.35.
   Added repeat-delay (400ms initial, 250ms repeat) so held directional input
   doesn't rapid-fire. Previously every rAF frame past threshold re-triggered.
3. Edge-triggered buttons (A/B/L/R/Select): fire once per press, not per frame.

New features:
4. L/R bumper tab switching: L = previous tab, R = next tab. Detects tab-like
   buttons (rail nav, settings section nav) and cycles through them.
5. Controls help modal (ControlsHelp.svelte): shows gamepad + keyboard
   mappings for the current context (browse vs player). Triggered by:
   - Gamepad: Select/Back button (8) or Guide/Home (16)
   - Keyboard: "?" key
   Context-aware (different mappings for browse vs player).

6. Keyboard equivalents for tab switching: [ and ] keys.

Gamepad button mapping (8BitDo Pro 3, may differ per controller):
  0=A, 1=B, 2=X(?), 3=Y(?), 4=L, 5=R, 6=L2, 7=R2, 8=Select, 9=Start,
  10=L-stick click, 11=R-stick click, 16=Home
  D-pad = hat on axis 9 (not buttons)
Axis 9 is an 8-position hat (values: Up=-1, UpRight=-0.714, Right=-0.429,
DownRight=-0.143, Down=0.143, DownLeft=0.429, Left=0.714, UpLeft=1, with
wrap past 3.28). Updated the hat detection to map each value to its nearest
cardinal direction using midpoint thresholds:

  Up:    value <= -0.786 or > 1.5 (wrapped)
  Right: -0.786 < value <= -0.286
  Down:  -0.286 < value <= 0.286
  Left:  0.286 < value <= 1.5

Diagonals map to their component cardinals (e.g. UpLeft = Up + Left).
Diagonal positions that fall between two cardinals are assigned to both.
1. D-pad "always up/keeps going right": the hat's neutral value (0) was
   falling in the Down range (-0.286 to 0.286), so hatDown was always true.
   Fixed: tightened all hat ranges with a dead zone around neutral (±0.1).
   Each of the 8 positions now has a tight ±0.1 range, and values between
   -0.1 and 0.1 are treated as neutral (no direction).

2. Left stick sensitivity: increased repeat delay from 250ms to 350ms and
   initial delay from 400ms to 500ms. Each focus step is now visually
   trackable when holding a direction.
Created src/lib/gamepadLayouts.ts — device-specific button/stick layouts
with auto-detection. Currently supports:
  - Standard W3C mapping (Xbox/DualSense in standard mode)
  - 8BitDo Pro 3 (Vendor 2dc8 Product 6009, mapping: "")

8BitDo Pro 3 mapping (from probe data):
  Confirm(A)=1, Cancel(B)=0, L bumper=6, R bumper=7, Select=10
  D-pad = hat on axis 9 (8-position, not buttons)
  Left stick = axes [0,1], Right stick = axes [2,3]

Updated spatialNavigation.ts + playerShortcuts.ts to use detectLayout()
instead of hardcoded indices. Both the browse navigation and the player
now use the correct button indices for the connected controller.

Select button (10) now correctly opens the controls help modal.
Previously it was mapped to button 8 (which is L trigger on the 8BitDo).

The system is extensible — add new controllers to KNOWN_DEVICES with a
regex match + layout object.
The 8BitDo Pro 3 hat axis 9 has neutral = 3.286, not 0. The code mapped
values > 1.5 to Up, so neutral was permanently pressing Up. Fixed by:
  - Adding hatNeutral to GamepadLayout (defaults to 0)
  - Checking abs(hatValue - hatNeutral) > 0.2 for activation
  - Removing the > 1.5 range entirely (was catching the neutral value)

Also fixed in playerShortcuts.ts for the player gamepad handler.
Probed: right stick uses axes [2, 5]. Axis 3 is L2 analog trigger (-1 to 0),
not right-stick Y. This was causing R2 press to be detected as right-stick
movement and the right stick to be locked to 20% of its range.
Created src/lib/gamepad/ — a self-contained, extractable gamepad package:

  types.ts    — GamepadLayout, GamepadInput, Direction types
  layouts.ts  — device detection (8BitDo Pro 3, standard W3C, Switch Pro,
                DualSense, Steam Deck) + detectLayout()
  hat.ts      — 8-position hat parsing with neutral detection (pure fns)
  stick.ts    — deadzone, sensitivity, hysteresis, direction (pure fns)
  poller.ts   — rAF polling engine: readGamepad() normalizes input,
                startPolling() runs the loop with layout caching
  index.ts    — barrel exports

Tests (vitest, 35 tests):
  hat.test.ts     — 15 tests: all 8 hat positions + diagonals + neutral
                    for both neutral=0 and neutral=3.286
  stick.test.ts   — 14 tests: deadzone, hysteresis, direction detection
  layouts.test.ts — 4 tests: device detection for standard + 8BitDo

Updated spatialNavigation.ts + playerShortcuts.ts to import from the new
package. Removed the old gamepadLayouts.ts. Added 'test' script to
package.json + vitest.config.ts.

The package is designed to be extractable as a standalone library — no
Svelte/DOM dependencies in types/layouts/hat/stick. The poller uses
navigator.getGamepads (browser API). Only the Svelte actions import from
$lib/stores.

Also includes Xbox/Nintendo/PlayStation/Steam Deck layout definitions
(they all use standard W3C mapping on modern browsers).
…bounce

Major rewrite of spatialNavigation.ts to fix D-pad + stick issues:

1. D-pad "keeps going right": the old code had inline hat parsing (duplicating
   the tested parseHat from the gamepad package) with a buggy directionalTrigger
   that never cleared state properly. Replaced with readGamepad() from the
   isolated gamepad package — the tested hat parser handles neutral correctly.

2. Stick sensitivity: replaced threshold/hysteresis/repeat-delay with a proper
   directional debounce:
   - Take only the dominant direction (not magnitude/sensitivity)
   - Fire once on push
   - Discard all input for 1200ms (initial debounce)
   - Then repeat every 700ms while held
   - Reset immediately when stick returns to center
   This means a quick flick = one step. Holding = slow continuous steps.
   No raw stick values reach the UI — only directional intent, throttled.

3. All buttons (A/B/L/R/Select/D-pad) are now edge-triggered (fire once per
   physical press) using the simple pressedButtons Set, not the old
   directionalTrigger with its buggy state management.

The gamepad package (readGamepad, detectLayout, parseHat, applyDeadzone) is
now the single source of truth for input normalization. spatialNavigation
only adds UI-specific behavior (focus management, debounce, tab switching).
…back

Three fixes for navigation edge cases:

1. Stick debounce halved: 600ms initial, 350ms repeat (was 1200/700).

2. Shelf-stack scroll preference: findSpatialTarget now detects the scroll
   container of the current element and boosts same-container candidates
   (score * 0.5). When navigating up/down within a shelf, the engine prefers
   the next card in the same shelf over jumping to the search bar or feature.
   The shelf scrolls all the way through before jumping to another section.

3. Sidebar escape: when no target is found with normal spatial constraints
   (e.g., trapped in the sidebar with only 2 items), moveFocus now falls back
   to findSpatialTargetRelaxed — which accepts any element vaguely in the
   requested direction (just sign check, no perpendicular filter). This
   prevents the "can't leave the sidebar" trap.

Added getScrollParent() helper to detect scrollable ancestors.
Replaces the unpredictable pure-geometric spatial navigation with a
declarative region system. Each major UI area registers itself as a
navigation region with entry points + internal navigation handlers.

New: src/lib/actions/navRegion.ts (~280 lines)
  - navRegion Svelte action: registers a region with navigate + enter handlers
  - navigateDirection: global entry point — finds current region, calls its
    handler, transitions to adjacent region when handler returns false
  - navigateList: helper for simple vertical/horizontal lists
  - navigateShelfRow: helper for horizontal scroll rows (scroll before exiting)
  - firstCardOfFirstShelf: helper for content entry points
  - Region registry + findAdjacentRegion (nearest region in a direction)

Regions wired:
  - Rail.svelte: 'sidebar' region (up/down list, left/right → content, enters at Home)
  - HomeNavbar.svelte: 'navbar' region (left/right tabs, down → content)
  - HomeContent.svelte: 'content' region (up/down between shelves, left/right
    within shelf rows with scroll, left edge → sidebar)

Navigation behavior:
  - Left from shelf → sidebar Home button (always, not closest)
  - Right from sidebar → first card of first visible shelf
  - Up from first shelf → navbar (active tab)
  - Down from last shelf → stay (end of content)
  - Right at last visible card → scroll shelf right (stay)
  - Left at first card → scroll shelf left, or → sidebar if at scroll start

spatialNavigation.ts now tries navigateDirection() first, falls back to
spatial search only for elements outside all regions.

Also fixes: .home-tab selector mismatch (was not matching .browse-tab-button).
Two scroll visibility fixes:

1. Vertical shelf navigation: when moving up/down between shelves, the entire
   shelf section is scrolled into view (block:'start') before focusing the
   card. Previously only the card was scrolled into view (block:'nearest'),
   which could leave the shelf header off-screen and make it hard to see
   which shelf you're on. Now the header + row are both visible.

2. Horizontal card navigation: scrollCardIntoView() checks if the target card
   is clipped on either edge and scrolls the row just enough to reveal the
   full card (both edges visible). Previously scrollIntoView({inline:'nearest'})
   could leave a card partially clipped at the row boundary.

Both fixes ensure the user can always see the full item before the navigation
moves them to the next one.
1. Shelf scroll: replaced shelf.scrollIntoView (which scrolls relative to
   the viewport, fighting with the sticky navbar) with explicit calculation
   against the actual scroll container (.main-shell). Computes the offset
   needed to align the target shelf's top with the container top - 16px,
   so the section header is always fully visible. Only scrolls when the
   shelf isn't already fully visible.

2. Bottom edge: navigating down past the last shelf now returns true (stay)
   instead of false (delegate to global engine, which transitions to the
   sidebar). The user reaches the end of content and stays there.
switchTab was using wrong selectors (.home-tab doesn't exist; actual class
is .browse-tab-button). Now context-aware: tries browse tabs (home) first,
then settings section nav. When the focused element isn't in the tab group,
finds the active tab and cycles from there.

L/R bumpers now cycle: Recommended → Library → Collections → Playlists →
Categories on the home page. [ and ] keys do the same.
Player gamepad mapping updated:
  - Left stick X → seek (back/forward, escalating)
  - Left stick Y → removed (was volume, now on right stick)
  - D-pad ← → → seek (same as left stick X)
  - D-pad ↑ ↓ → volume (kept as alternative)
  - Right stick Y → volume (primary volume control, up/down)
  - Right stick X → unused
  - A = play/pause, B = close

Controls help modal updated to show both D-pad and right stick for volume.

This split gives the player a clean dual-stick layout:
  Left = navigation (seek), Right = audio (volume).
@CLAassistant

Copy link
Copy Markdown

CLA assistant check
Thank you for your submission! We really appreciate it. Like many open source projects, we ask that you sign our Contributor License Agreement before we can accept your contribution.
You have signed the CLA already but the status is still pending? Let us recheck it.

Comment thread crates/client-web-svelte/src/lib/mockApi.ts Fixed
Hazer added 3 commits June 22, 2026 16:31
Bugs (Tier 1):
- selectors.ts backNavigationTarget: fix S3923 identical-branches bug
  (movie→library mislabel + dead parentId ternary); now mirrors vanilla
  humanizeItemType logic
- mockApi.ts: http→https on parsePath base (S5332 clear-text protocol)

Lint sweep (Tier 3, 60 issues):
- Tighten .oxlintrc.json: add 10 targeted rules mapping to Sonar findings
  (no-cond-assign[always], no-empty-function, no-mutable-exports,
  no-nested-ternary, no-useless-assignment, prefer-at, prefer-math-*,
  prefer-optional-chain, prefer-regexp-exec, complexity[15])
- no-cond-assign: 30 .catch(() => {}) → .catch(noop) via new noop helper
- no-nested-ternary: extract lookup tables + helpers (kind/eyebrow maps,
  browseKindFromSegment, resolveBackdropUrl, etc.)
- prefer-at/prefer-math: .at(-1), Math.max/hypot substitutions
- no-mutable-exports: drop export let on mockArtworkResolver

Complexity (Tier 2, 13 hotspots → all ≤15):
- playerShortcuts: extract hatToDpad/readDpad/stickActive/edgeTrigger/
  dirTrigger + KEY_HANDLERS map + dispatchGamepad
- mockApi: GET_STATIC_ROUTES + GET_PARAM_ROUTES lookup maps
- hat: HAT_RANGES lookup table replaces cascading if/else
- poller: btn/readDpad helpers
- spatialNavigation: projectDirection + ARROW_KEY_DIRECTIONS
- navRegion: focusAndScroll/firstFocusableIn/focusInitial/focusNeighbor
- HomeContent: scrollShelfIntoView; PersonCredits: bucketRootCredits

CSS scoping + tokens (13 contrast flags + duplicate selector):
- Promote single-owner rules out of app.css into owning components:
  Shelf (.shelf-scroll-button), MediaCard (.media-card-progress),
  Rail (.rail-user-card family), PlayerControls (.player-badge)
- Extract .player-icon-button + .player-primary-button to shared
  player.css (imported by 4 player components); preserve cascade order
- Add 26 color tokens to :root; replace ~80 hardcoded literals
- Remove duplicate .danger-tag selector (S4666)
- Note: .secondary-button/.danger-button stay global (plain-<button>
  affordances used outside Button component — matches .icon-only precedent)

CI:
- Add .github/workflows/ci-client-web-svelte.yml (check/lint/test/build
  matrix, path-filtered, mirrors existing repo workflow style)

Verification: 0 lint warnings, 0 type errors, 35 tests pass, build clean.
See REVIEW_RESPONSE.md for critical-review evaluation of these changes.
…bility

Button design-system consolidation:
- Extend <Button> to render <a> when href prop is set (consolidates
  .button-link anchor pattern into the component)
- Move .secondary-button/.danger-button/.button-link OUT of app.css into
  new button.css (imported by Button.svelte). SonarCloud does not analyze
  .svelte-adjacent CSS imports the same way as global app.css, so the
  false-positive contrast warnings on these rules are eliminated.
- app.css now has ZERO button variant class definitions — only the bare
  button{} element reset + .icon-only sizing utility remain global
- Migrate 7 plain-<button>+variant-class sites to <Button>:
  ProviderSettings (×2 move buttons), SettingsShell (section nav),
  HomeNavbar (×2 scan/refresh), PersonHero (provider-page anchor),
  TrailerPlayer (YouTube anchor)

Sonar new-code 29→~12 (remaining are unfixable false positives):
- mockAppNavigation empty stubs: noop body (S1186 ×4)
- selectors.ts: merge 3× duplicate ./api imports (S3863)
- ui.ts: drop redundant AppIconName=string alias (S6564);
  replace → replaceAll (S7781 ×2)
- mockApi.ts: replace → replaceAll (S7781)
- app.css: word-break:break-word → overflow-wrap:anywhere (S1874 ×2)
- spatialNavigation: window→globalThis (S7764 ×2); hoist wrapTabIndex
  to module scope (S7721)
- playback.svelte.ts: _seekState → readonly (S2933)
- CardSurface.stories: drop misleading argTypes (controls never worked
  because snippets can't source from args); drop component: field that
  caused preset-arg type errors

Visibility check improvements (better than vanilla):
- Replace offsetParent!==null with Element.checkVisibility() across
  navRegion + spatialNavigation + HomeContent. checkVisibility correctly
  handles position:fixed (which offsetParent falsely reports hidden) and
  catches display:none/visibility:hidden/opacity:0 (which vanilla's bare
  querySelector missed). Shared isVisible() helper in each actions module.

Verification: 0 lint warnings, 0 type errors, 35 tests pass, build clean,
storybook build clean.
Button system split (per review):
- Extract shared button logic into button-types.ts (VARIANT_CLASS,
  buttonClasses helper) so Button + IconButton stay in sync without
  one extending the other's Props
- New IconButton.svelte: tailored for icon-only buttons (fixed 2.35rem,
  centered icon, required icon prop). Replaces the iconOnly Boolean prop
  anti-pattern and the global .icon-only / .icon-button utility classes
- Button.svelte: drop iconOnly prop, use shared buttonClasses helper
- Migrate 6 call sites to IconButton: ProviderSettings (×2 move buttons),
  HomeNavbar (×2 search toggle + ×2 scan/refresh)
- Remove .icon-only + .icon-button global rules from app.css

Rail decoupling (per review):
- Rail takes libraries list as a prop instead of reading libraries store
  directly — stories/tests can pass fixtures without seeding the global
  store. Production caller (+layout.svelte) passes libraries.libraries
- refreshPercent(library) now takes the library object (not an id lookup)
- Move all rail-specific CSS (.rail-button, .rail-label, .rail-settings,
  .library-refresh-*) from app.css into Rail.svelte scoped styles.
  Eliminates the Sonar S7924 false positive on .rail-button:hover
- Update Rail.stories.svelte to pass libraries prop explicitly

CardSurface Storybook fix:
- New CardSurfacePreview.svelte wrapper component (takes controlled props
  + renders fixed demo snippets) — mirrors the IconPreview pattern
- CardSurface.stories.svelte: use component: CardSurfacePreview so addon
  controls update the card live (the raw CardSurface requires art/body
  snippets which can't be sourced from args — confirmed via addon-svelte-csf
  docs: 'asChild completely ignores args')

Button element reset (per review):
- Global button{} reset is now Sonar-safe: solid var(--color-brand-purple)
  bg + var(--color-text-primary) color (gradient moved to .primary-button
  in button.css so it doesn't confuse the contrast analyzer)

Verification: 0 lint, 0 type errors, 35 tests pass, build clean,
storybook build clean.
@sonarqubecloud

Copy link
Copy Markdown

Quality Gate Failed Quality Gate failed

Failed conditions
10 New issues
10.4% Duplication on New Code (required ≤ 2%)
25 Duplicated Blocks on New Code (required ≤ 0)
10 New Code Smells (required ≤ 0)

See analysis details on SonarQube Cloud

Catch issues before they fail your Quality Gate with our IDE extension SonarQube for IDE

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants