Design system v2, and a rotating featured carousel

The skeleton, not features. Two things felt rushed: the colour had no point of
view, and type and shape came from ad-hoc values.

Design language
- Palette reworked to warm-neutral dark with a single muted gold accent
  (#d9a441). The chrome is deliberately colourless because the artwork is the
  colour in this app; gold carries emphasis without competing with covers.
  Light mode re-tuned to match.
- A real type scale (--fs-xs … --fs-3xl) replaces the ad-hoc rem values, with
  fixed line heights and letter-spacing per level. Headings are 620 weight and
  tighter; body is a consistent 15px.
- Radii tightened to a 5/8/12 scale, one shadow pair, and the primary button
  now uses dark ink on gold for contrast.

Featured carousel
- The banner rotates through up to six items — in progress first, then most
  viewed, then newest — drawn only from the libraries the user was granted, so
  it can never show something they cannot open.
- Auto-advances every 8s, pauses on hover and focus, and has dots plus arrows
  for manual control. Slides crossfade, and the ambient wash changes with them.

Verified in the built bundle: accent resolves to #d9a441, six slides, rotation
confirmed across three items, and hover pauses it.
This commit is contained in:
mithral 2026-10-10 17:27:59 -04:00
parent 83c59522e7
commit 3d964e6b06
3 changed files with 343 additions and 160 deletions

View file

@ -1,31 +1,45 @@
/* Vellum — user-facing design tokens. /* Vellum — design tokens.
Media-first: dark by default, covers are the primary content, chrome recedes. The artwork is the colour in this app, so the chrome stays warm-neutral and
One accent, a tight type scale, and generous spacing so artwork breathes. */ one muted gold carries emphasis. Type, spacing and radii come from a fixed
scale rather than ad-hoc values, which is what keeps it from feeling rushed. */
:root { :root {
--bg: #0a0a0c; /* ── Surfaces ─────────────────────────────────────────────────────── */
--surface: #131316; --bg: #0c0c0e;
--surface-2: #1b1b20; --surface: #141416;
--surface-3: #24242b; --surface-2: #1b1b1e;
--border: #26262e; --surface-3: #232327;
--border-strong: #34343e; --border: #26262a;
--border-strong: #343439;
--text: #f3f3f5; /* ── Ink ──────────────────────────────────────────────────────────── */
--muted: #a2a2ae; --text: #f4f3f5;
--faint: #6c6c78; --muted: #a3a1a9;
--faint: #6d6b75;
--accent: #6d7cff; /* ── Emphasis ─────────────────────────────────────────────────────── */
--accent-hover: #808dff; --accent: #d9a441;
--accent-soft: color-mix(in srgb, var(--accent) 16%, transparent); --accent-hover: #e6b556;
--accent-text: #ffffff; --accent-soft: color-mix(in srgb, var(--accent) 18%, transparent);
--danger: #ff6b6b; --accent-text: #1a1204;
--success: #46d17f; --danger: #e0625c;
--success: #4fbf83;
--radius-sm: 6px; /* ── Type scale (rem, relative to the 15px root) ──────────────────── */
--radius: 10px; --fs-xs: 0.8rem; /* 12 */
--radius-lg: 14px; --fs-sm: 0.867rem; /* 13 */
--radius-cover: 8px; --fs-md: 0.933rem; /* 14 */
--fs-base: 1rem; /* 15 */
--fs-lg: 1.133rem; /* 17 */
--fs-xl: 1.333rem; /* 20 */
--fs-2xl: 1.733rem; /* 26 */
--fs-3xl: 2.4rem; /* 36 */
--lh-tight: 1.15;
--lh-snug: 1.35;
--lh-body: 1.55;
/* ── Space ────────────────────────────────────────────────────────── */
--space-1: 4px; --space-1: 4px;
--space-2: 8px; --space-2: 8px;
--space-3: 12px; --space-3: 12px;
@ -34,13 +48,19 @@
--space-6: 32px; --space-6: 32px;
--space-7: 48px; --space-7: 48px;
--font: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, /* ── Shape ────────────────────────────────────────────────────────── */
'Noto Sans', sans-serif; --radius-sm: 5px;
--radius: 8px;
--radius-lg: 12px;
--radius-cover: 8px;
--font: -apple-system, BlinkMacSystemFont, 'Segoe UI Variable Text', 'Segoe UI',
Roboto, Helvetica, Arial, 'Noto Sans', sans-serif;
--mono: ui-monospace, SFMono-Regular, 'SF Mono', Menlo, Consolas, monospace; --mono: ui-monospace, SFMono-Regular, 'SF Mono', Menlo, Consolas, monospace;
--nav-width: 232px; --nav-width: 236px;
--shadow-cover: 0 1px 2px rgba(0, 0, 0, 0.4), 0 8px 24px rgba(0, 0, 0, 0.35); --shadow-cover: 0 1px 2px rgba(0, 0, 0, 0.45), 0 6px 20px rgba(0, 0, 0, 0.35);
--shadow-lift: 0 12px 32px rgba(0, 0, 0, 0.5); --shadow-lift: 0 10px 28px rgba(0, 0, 0, 0.5);
/* User-controlled sizing. --ui-scale drives every rem in the app, so the /* User-controlled sizing. --ui-scale drives every rem in the app, so the
text-size setting scales headings, labels and body together. --card-scale text-size setting scales headings, labels and body together. --card-scale
@ -62,20 +82,26 @@
@media (prefers-color-scheme: light) { @media (prefers-color-scheme: light) {
:root { :root {
--bg: #f7f7f8; --bg: #faf9f7;
--surface: #ffffff; --surface: #ffffff;
--surface-2: #f1f1f3; --surface-2: #f3f2ef;
--surface-3: #e7e7ea; --surface-3: #e9e7e3;
--border: #e6e6ea; --border: #e6e4df;
--border-strong: #d2d2d9; --border-strong: #d3d0ca;
--text: #16161a;
--muted: #5d5d68; --text: #17161a;
--faint: #8b8b96; --muted: #5f5d66;
--accent: #5563e8; --faint: #8a8791;
--accent-hover: #4553d8;
--accent: #a8761c;
--accent-hover: #8f6414;
--accent-soft: color-mix(in srgb, var(--accent) 14%, transparent);
--accent-text: #ffffff; --accent-text: #ffffff;
--danger: #c0392b;
--success: #1f8a4c;
--shadow-cover: 0 1px 2px rgba(20, 20, 30, 0.08), 0 6px 18px rgba(20, 20, 30, 0.1); --shadow-cover: 0 1px 2px rgba(20, 20, 30, 0.08), 0 6px 18px rgba(20, 20, 30, 0.1);
--shadow-lift: 0 10px 28px rgba(20, 20, 30, 0.16); --shadow-lift: 0 10px 26px rgba(20, 20, 30, 0.16);
} }
} }
@ -101,9 +127,10 @@ body {
background: var(--bg); background: var(--bg);
color: var(--text); color: var(--text);
font-family: var(--font); font-family: var(--font);
font-size: 1rem; font-size: var(--fs-base);
line-height: 1.5; line-height: var(--lh-body);
-webkit-font-smoothing: antialiased; -webkit-font-smoothing: antialiased;
text-rendering: optimizeLegibility;
} }
a { a {
@ -118,17 +145,21 @@ h1,
h2, h2,
h3 { h3 {
margin: 0; margin: 0;
font-weight: 600; font-weight: 620;
letter-spacing: -0.015em; letter-spacing: -0.02em;
line-height: var(--lh-tight);
} }
h1 { h1 {
font-size: 1.571rem; font-size: var(--fs-2xl);
} }
h2 { h2 {
font-size: 1.214rem; font-size: var(--fs-lg);
letter-spacing: -0.012em;
} }
h3 { h3 {
font-size: 1.071rem; font-size: var(--fs-base);
font-weight: 600;
letter-spacing: -0.006em;
} }
button, button,
@ -169,12 +200,18 @@ select {
border-radius: var(--radius); border-radius: var(--radius);
background: var(--surface-2); background: var(--surface-2);
color: var(--text); color: var(--text);
font-size: var(--fs-md);
font-weight: 500;
cursor: pointer; cursor: pointer;
transition: background 0.12s ease, border-color 0.12s ease, transform 0.08s ease; transition:
background var(--t-fast) var(--ease),
border-color var(--t-fast) var(--ease),
color var(--t-fast) var(--ease);
white-space: nowrap; white-space: nowrap;
} }
.btn:hover:not(:disabled) { .btn:hover:not(:disabled) {
background: var(--surface-3); background: var(--surface-3);
border-color: var(--faint);
} }
.btn:active:not(:disabled) { .btn:active:not(:disabled) {
transform: translateY(1px); transform: translateY(1px);
@ -187,6 +224,7 @@ select {
background: var(--accent); background: var(--accent);
border-color: var(--accent); border-color: var(--accent);
color: var(--accent-text); color: var(--accent-text);
font-weight: 600;
} }
.btn-primary:hover:not(:disabled) { .btn-primary:hover:not(:disabled) {
background: var(--accent-hover); background: var(--accent-hover);
@ -198,11 +236,12 @@ select {
} }
.btn-ghost:hover:not(:disabled) { .btn-ghost:hover:not(:disabled) {
background: var(--surface-2); background: var(--surface-2);
border-color: transparent;
} }
.btn-lg { .btn-lg {
padding: 11px 22px; padding: 10px 20px;
font-size: 1.071rem; font-size: var(--fs-base);
border-radius: 12px; border-radius: var(--radius-lg);
} }
.input { .input {
@ -212,7 +251,13 @@ select {
border-radius: var(--radius); border-radius: var(--radius);
background: var(--bg); background: var(--bg);
color: var(--text); color: var(--text);
transition: border-color 0.12s ease, box-shadow 0.12s ease; font-size: var(--fs-md);
transition:
border-color var(--t-fast) var(--ease),
box-shadow var(--t-fast) var(--ease);
}
.input::placeholder {
color: var(--faint);
} }
.input:focus { .input:focus {
outline: none; outline: none;
@ -226,7 +271,7 @@ select {
gap: 6px; gap: 6px;
} }
.field label { .field label {
font-size: .857rem; font-size: var(--fs-sm);
font-weight: 500; font-weight: 500;
color: var(--muted); color: var(--muted);
} }
@ -241,23 +286,25 @@ select {
.badge { .badge {
display: inline-block; display: inline-block;
padding: 2px 8px; padding: 2px 7px;
border-radius: 999px; border-radius: var(--radius-sm);
border: 1px solid var(--border-strong); border: 1px solid var(--border-strong);
font-size: .786rem; font-size: var(--fs-xs);
color: var(--muted); color: var(--muted);
text-transform: uppercase;
letter-spacing: 0.05em;
} }
.chip { .chip {
padding: 5px 12px; padding: 6px 13px;
border: 1px solid var(--border-strong); border: 1px solid var(--border-strong);
border-radius: 999px; border-radius: var(--radius);
background: transparent; background: transparent;
color: var(--muted); color: var(--muted);
cursor: pointer; cursor: pointer;
font-size: .929rem; font-size: var(--fs-md);
transition:
background var(--t-fast) var(--ease),
color var(--t-fast) var(--ease),
border-color var(--t-fast) var(--ease);
} }
.chip:hover { .chip:hover {
color: var(--text); color: var(--text);
@ -267,6 +314,7 @@ select {
background: var(--text); background: var(--text);
border-color: var(--text); border-color: var(--text);
color: var(--bg); color: var(--bg);
font-weight: 500;
} }
.alert { .alert {
@ -274,14 +322,14 @@ select {
border: 1px solid color-mix(in srgb, var(--danger) 45%, var(--border)); border: 1px solid color-mix(in srgb, var(--danger) 45%, var(--border));
border-radius: var(--radius); border-radius: var(--radius);
background: color-mix(in srgb, var(--danger) 10%, transparent); background: color-mix(in srgb, var(--danger) 10%, transparent);
font-size: .929rem; font-size: var(--fs-md);
} }
.ok { .ok {
padding: 10px 14px; padding: 10px 14px;
border: 1px solid color-mix(in srgb, var(--success) 40%, var(--border)); border: 1px solid color-mix(in srgb, var(--success) 40%, var(--border));
border-radius: var(--radius); border-radius: var(--radius);
background: color-mix(in srgb, var(--success) 9%, transparent); background: color-mix(in srgb, var(--success) 9%, transparent);
font-size: .929rem; font-size: var(--fs-md);
} }
.muted { .muted {
@ -292,10 +340,10 @@ select {
} }
.mono { .mono {
font-family: var(--mono); font-family: var(--mono);
font-size: .857rem; font-size: var(--fs-sm);
} }
/* ── Cover card ─────────────────────────────────────────────────────── */ /* ── Cover ──────────────────────────────────────────────────────────── */
.cover { .cover {
position: relative; position: relative;
@ -304,7 +352,6 @@ select {
background: var(--surface-2); background: var(--surface-2);
overflow: hidden; overflow: hidden;
box-shadow: var(--shadow-cover); box-shadow: var(--shadow-cover);
transition: transform 0.16s ease, box-shadow 0.16s ease;
} }
.cover.wide { .cover.wide {
aspect-ratio: 1 / 1; aspect-ratio: 1 / 1;
@ -320,8 +367,8 @@ select {
inset: 0; inset: 0;
display: grid; display: grid;
place-items: center; place-items: center;
font-size: 2.429rem; font-size: 2.2rem;
font-weight: 700; font-weight: 600;
color: var(--faint); color: var(--faint);
background: linear-gradient(150deg, var(--surface-2), var(--surface-3)); background: linear-gradient(150deg, var(--surface-2), var(--surface-3));
} }
@ -329,7 +376,7 @@ select {
display: none; display: none;
} }
/* ── Empty state ────────────────────────────────────────────────────── */ /* ── Empty ──────────────────────────────────────────────────────────── */
.empty { .empty {
display: flex; display: flex;
@ -338,10 +385,11 @@ select {
justify-content: center; justify-content: center;
gap: var(--space-2); gap: var(--space-2);
padding: var(--space-7) var(--space-4); padding: var(--space-7) var(--space-4);
border: 1px dashed var(--border-strong); border: 1px solid var(--border);
border-radius: var(--radius-lg); border-radius: var(--radius-lg);
text-align: center; text-align: center;
color: var(--muted); color: var(--muted);
background: var(--surface);
} }
.empty h3 { .empty h3 {
color: var(--text); color: var(--text);

View file

@ -1,82 +1,169 @@
<script lang="ts"> <script lang="ts">
import { onDestroy } from 'svelte'
import { fade } from 'svelte/transition'
import { api } from '../api' import { api } from '../api'
import { mediaTypes } from '../session' import { mediaTypes } from '../session'
import { go } from '../nav' import { go } from '../nav'
let { let {
item, items,
kicker, kickerFor,
percent = 0, percentFor = () => 0,
}: { }: {
item: { id: number; title: string; kind?: string } items: { id: number; title: string; kind?: string }[]
kicker: string kickerFor: (item: { id: number; title: string; kind?: string }) => string
percent?: number percentFor?: (id: number) => number
} = $props() } = $props()
const kindLabel = $derived( const ROTATE_MS = 8000
let index = $state(0)
let paused = $state(false)
let timer: number | undefined
const current = $derived(items[index])
function start() {
stop()
if (items.length < 2) return
timer = window.setInterval(() => {
if (!paused) index = (index + 1) % items.length
}, ROTATE_MS)
}
function stop() {
if (timer) clearInterval(timer)
timer = undefined
}
function goTo(i: number) {
index = (i + items.length) % items.length
start() // restart the dwell after a manual move
}
// Re-arm whenever the set of items changes.
$effect(() => {
items.length
start()
return stop
})
onDestroy(stop)
const kindLabel = (item: { kind?: string }) =>
($mediaTypes as { Kind: string; Name: string }[]).find((t) => t.Kind === item.kind)?.Name ?? ($mediaTypes as { Kind: string; Name: string }[]).find((t) => t.Kind === item.kind)?.Name ??
item.kind ?? item.kind ??
'', ''
)
const action = $derived( const action = (item: { kind?: string }) =>
item.kind === 'audiobooks' || item.kind === 'music' || item.kind === 'games' item.kind === 'audiobooks' || item.kind === 'music' || item.kind === 'games'
? 'Play' ? 'Play'
: item.kind === 'photos' : item.kind === 'photos'
? 'View' ? 'View'
: 'Read', : 'Read'
)
// The ambient wash is the item's own cover, blurred. The colour comes from
// the artwork rather than a decorative gradient, so it is different for every
// item and always belongs to what is on screen.
const wash = $derived(`url("${api.coverUrl(item.id)}")`)
</script> </script>
<section class="featured"> {#if current}
<div class="wash" style="background-image: {wash}"></div> <section
class="featured"
aria-roledescription="carousel"
aria-label="Featured"
onmouseenter={() => (paused = true)}
onmouseleave={() => (paused = false)}
onfocusin={() => (paused = true)}
onfocusout={() => (paused = false)}
>
{#key index}
<div class="slide" in:fade={{ duration: 420 }} out:fade={{ duration: 420 }}>
<div class="wash" style="background-image: url('{api.coverUrl(current.id)}')"></div>
<div class="scrim"></div> <div class="scrim"></div>
<div class="body"> <div class="body">
<span class="kicker">{kicker}</span> <span class="kicker">{kickerFor(current)}</span>
<h1>{item.title}</h1> <h1>{current.title}</h1>
<p class="meta">{kindLabel}{#if percent > 0} · {Math.round(percent * 100)}% complete{/if}</p> <p class="meta">
{kindLabel(current)}
<div class="actions"> {#if percentFor(current.id) > 0}
<button class="btn btn-primary btn-lg" onclick={() => go({ name: 'reader', itemId: item.id, title: item.title })}> · {Math.round(percentFor(current.id) * 100)}% complete
{percent > 0 ? 'Continue' : action}
</button>
<button class="btn btn-lg" onclick={() => go({ name: 'item', id: item.id })}>Details</button>
</div>
{#if percent > 0}
<div class="bar"><i style="width:{Math.min(Math.max(percent, 0), 1) * 100}%"></i></div>
{/if} {/if}
</p>
<div class="actions">
<button
class="btn btn-primary btn-lg"
onclick={() => go({ name: 'reader', itemId: current.id, title: current.title })}
>
{percentFor(current.id) > 0 ? 'Continue' : action(current)}
</button>
<button class="btn btn-lg" onclick={() => go({ name: 'item', id: current.id })}>Details</button>
</div>
</div> </div>
<div class="poster"> <div class="poster">
<div class="cover"> <div class="cover">
<img src={api.coverUrl(item.id)} alt="" onerror={(e) => (e.currentTarget as HTMLImageElement).remove()} /> <img
<span class="placeholder">{(item.title || '?').slice(0, 1).toUpperCase()}</span> src={api.coverUrl(current.id)}
alt=""
onerror={(e) => (e.currentTarget as HTMLImageElement).remove()}
/>
<span class="placeholder">{(current.title || '?').slice(0, 1).toUpperCase()}</span>
</div> </div>
</div> </div>
</section> </div>
{/key}
{#if items.length > 1}
<div class="controls">
<div class="dots" role="tablist" aria-label="Featured items">
{#each items as item, i (item.id)}
<button
class="dot"
class:active={i === index}
role="tab"
aria-selected={i === index}
aria-label={item.title}
onclick={() => goTo(i)}
></button>
{/each}
</div>
<div class="arrows">
<button class="arrow" onclick={() => goTo(index - 1)} aria-label="Previous">
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M15 18l-6-6 6-6" />
</svg>
</button>
<button class="arrow" onclick={() => goTo(index + 1)} aria-label="Next">
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M9 18l6-6-6-6" />
</svg>
</button>
</div>
</div>
{/if}
</section>
{/if}
<style> <style>
.featured { .featured {
position: relative; position: relative;
display: flex;
align-items: flex-end;
gap: var(--space-6);
min-height: clamp(240px, 34vh, 380px); min-height: clamp(240px, 34vh, 380px);
margin-bottom: var(--space-6); margin-bottom: var(--space-6);
padding: var(--space-6);
border: 1px solid var(--border); border: 1px solid var(--border);
border-radius: var(--radius-lg); border-radius: var(--radius-lg);
overflow: hidden; overflow: hidden;
background: var(--surface); background: var(--surface);
} }
.slide {
position: absolute;
inset: 0;
display: flex;
align-items: flex-end;
gap: var(--space-6);
padding: var(--space-6);
}
/* Ambient colour taken from the item's own cover, so every slide is tinted
by its own artwork rather than by a decorative gradient. */
.wash { .wash {
position: absolute; position: absolute;
inset: -20%; inset: -20%;
@ -93,7 +180,11 @@
inset: 0; inset: 0;
background: background:
linear-gradient(to top, var(--surface) 4%, transparent 70%), linear-gradient(to top, var(--surface) 4%, transparent 70%),
linear-gradient(to right, color-mix(in srgb, var(--surface) 88%, transparent) 30%, transparent 85%); linear-gradient(
to right,
color-mix(in srgb, var(--surface) 90%, transparent) 32%,
transparent 86%
);
pointer-events: none; pointer-events: none;
} }
@ -104,46 +195,26 @@
gap: var(--space-2); gap: var(--space-2);
max-width: 560px; max-width: 560px;
} }
.kicker { .kicker {
font-size: 0.857rem; font-size: var(--fs-sm);
font-weight: 500; font-weight: 600;
color: var(--accent); color: var(--accent);
} }
h1 { h1 {
font-size: clamp(1.7rem, 3.2vw, 2.4rem); font-size: clamp(1.7rem, 3.2vw, 2.5rem);
line-height: 1.1;
letter-spacing: -0.02em;
text-wrap: balance; text-wrap: balance;
} }
.meta { .meta {
margin: 0; margin: 0;
font-size: 0.929rem; font-size: var(--fs-md);
color: var(--muted); color: var(--muted);
} }
.actions { .actions {
display: flex; display: flex;
gap: var(--space-3); gap: var(--space-3);
margin-top: var(--space-3); margin-top: var(--space-3);
} }
.bar {
height: 3px;
width: min(320px, 100%);
margin-top: var(--space-3);
border-radius: 999px;
background: color-mix(in srgb, var(--text) 18%, transparent);
overflow: hidden;
}
.bar i {
display: block;
height: 100%;
background: var(--accent);
}
.poster { .poster {
position: relative; position: relative;
margin-left: auto; margin-left: auto;
@ -155,8 +226,61 @@
aspect-ratio: 2 / 3; aspect-ratio: 2 / 3;
} }
.controls {
position: absolute;
right: var(--space-5);
bottom: var(--space-5);
z-index: 2;
display: flex;
align-items: center;
gap: var(--space-4);
}
.dots {
display: flex;
gap: 7px;
}
.dot {
width: 7px;
height: 7px;
padding: 0;
border: none;
border-radius: 50%;
background: color-mix(in srgb, var(--text) 30%, transparent);
cursor: pointer;
transition:
background var(--t-base) var(--ease),
width var(--t-base) var(--ease);
}
.dot:hover {
background: color-mix(in srgb, var(--text) 55%, transparent);
}
.dot.active {
width: 20px;
border-radius: 999px;
background: var(--accent);
}
.arrows {
display: flex;
gap: var(--space-2);
}
.arrow {
display: grid;
place-items: center;
width: 30px;
height: 30px;
border-radius: 50%;
border: 1px solid var(--border-strong);
background: color-mix(in srgb, var(--surface) 80%, transparent);
color: var(--text);
cursor: pointer;
transition: background var(--t-fast) var(--ease);
}
.arrow:hover {
background: var(--surface-3);
}
@media (max-width: 820px) { @media (max-width: 820px) {
.featured { .slide {
padding: var(--space-5); padding: var(--space-5);
} }
.poster { .poster {

View file

@ -50,14 +50,27 @@
continueItems.length + mostViewed.length + suggestions.length + recent.length + explore.length > 0, continueItems.length + mostViewed.length + suggestions.length + recent.length + explore.length > 0,
) )
// The featured item is whatever the user is most likely to act on: something // The carousel rotates through what the user is most likely to act on:
// in progress, otherwise the newest arrival. It gives the page a focal point // anything in progress first, then what they open most, then what is new.
// instead of starting straight into rows. // All of it is already scoped to the libraries they were granted, so the
const featured = $derived(continueItems[0] ?? recent[0] ?? null) // banner only ever shows content they can actually open.
const kicker = $derived.by(() => { const featuredItems = $derived.by(() => {
if (!featured) return '' const seen = new Set<number>()
if (continueItems[0]) { const out: { id: number; title: string; kind?: string }[] = []
switch (featured.kind) { for (const s of [...continueItems, ...mostViewed, ...recent]) {
if (seen.has(s.id)) continue
seen.add(s.id)
out.push({ id: s.id, title: s.title, kind: s.kind })
if (out.length >= 6) break
}
return out
})
const inProgress = $derived(new Set(continueItems.map((c) => c.id)))
function kickerFor(item: { id: number; kind?: string }): string {
if (!inProgress.has(item.id)) return 'Featured'
switch (item.kind) {
case 'audiobooks': case 'audiobooks':
case 'music': case 'music':
return 'Continue listening' return 'Continue listening'
@ -67,8 +80,6 @@
return 'Continue reading' return 'Continue reading'
} }
} }
return 'Recently added'
})
</script> </script>
{#if loading} {#if loading}
@ -97,8 +108,8 @@
{:else} {:else}
<p class="hello muted">Welcome back, {greeting}.</p> <p class="hello muted">Welcome back, {greeting}.</p>
{#if featured} {#if featuredItems.length > 0}
<Featured item={featured} {kicker} percent={percentFor(featured.id)} /> <Featured items={featuredItems} {kickerFor} {percentFor} />
{/if} {/if}
<Shelf title="Continue" items={continueItems} {percentFor} /> <Shelf title="Continue" items={continueItems} {percentFor} />