refactor: Proposal for refactor in SvelteKit with Storybook#142
Draft
Hazer wants to merge 92 commits into
Draft
Conversation
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.
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).
|
|
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.
|
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.




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
addEventListenerfor every interaction (1395 lines ineventBindings.ts), a custom DOM diffing layer (domPatcher.ts, 290 lines), and a single mutableAppStateobject 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:
domPatcher.tsdoes 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.$state,$derived,$effectare compile-time annotations that tell Svelte what to track. No proxy wrapping, no hook rules, no mental model gap. A$statefield is just a variable the compiler knows to make reactive. This is conceptually similar to how the vanilla code mutatesstateand callsrender()— but automatic.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.$statefields. There's no JSX, no hooks, no provider tree. A contributor who knows HTML/CSS/JS can read a.sveltefile 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 sendsX-Content-Durationheader + ffmpeg-tflag 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:
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" findsSectionHero.svelteimmediately, instead of grepping throughitemPersonView.tsfor the right render function. SplittingSectionSupportintoSupportFileInfo+SupportMetadatameans 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
AppStateis a single mutable object. Any change triggersrender(), which re-renders the entire DOM tree (mitigated bydomPatcher.ts). The rewrite splits this into 8 domain stores, each a class with$statefields:Why this improves maintainability: a bug in metadata search only requires reading
item.svelte.ts+MetadataSearchPanel.svelte— not the entireeventBindings.ts(1395 lines) to find which listener handles the form submit. Each store is independently testable. State changes are surgical — updatingcatalog.searchQueryonly re-renders the search results, not the entire page. The vanilladomPatcher.tsdiff 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:querySelectorfor every control,addEventListenerfor every interaction,classList.togglefor state changes, manualrender()calls to recreate the<video>element on audio-track switches. The rewrite replaces this with:playback.svelte.tsstore (~250 lines) — session lifecycle, player element state, seek logic, progress reportingConcrete 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 newsrc. The rewrite changesplayback.activeAudioStreamIndex→ the derivedstreamUrlupdates → Svelte patches thesrcattribute → the browser reloads the stream. No DOM re-render, no manual render call, ~50 lines of logic vs ~200.Player UI improvements
.media-card-artlacksoverflow: hidden, so gradient fallbacks bleed past the 18px border radius. Fixed withoverflow: hidden(scoped).button:hoverlifts the entire card (including text below the poster) with a blue shadow. Fixed: only the poster tile lifts, not the text block.button.is-busy::afterhasposition: absolutebut no centering, so the spinner renders off-center to the right. Fixed withinset: 0; margin: auto(scoped to Button.svelte).<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.<video>element's reported duration grows as fragmented-MP4 fragments arrive. Fixed by pinning from item metadata (matching vanilla's intended behavior) + server-sideX-Content-Durationheader + ffmpeg-tflag.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:GamepadInputobject. 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:
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_APIand 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
X-Content-Durationheader + ffmpeg-t)Roadmap Issues
N/A
Type of Change
Checklist
AI Usage