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

View file

@ -1,82 +1,169 @@
<script lang="ts">
import { onDestroy } from 'svelte'
import { fade } from 'svelte/transition'
import { api } from '../api'
import { mediaTypes } from '../session'
import { go } from '../nav'
let {
item,
kicker,
percent = 0,
items,
kickerFor,
percentFor = () => 0,
}: {
item: { id: number; title: string; kind?: string }
kicker: string
percent?: number
items: { id: number; title: string; kind?: string }[]
kickerFor: (item: { id: number; title: string; kind?: string }) => string
percentFor?: (id: number) => number
} = $props()
const kindLabel = $derived(
($mediaTypes as { Kind: string; Name: string }[]).find((t) => t.Kind === item.kind)?.Name ??
item.kind ??
'',
)
const ROTATE_MS = 8000
const action = $derived(
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 ??
item.kind ??
''
const action = (item: { kind?: string }) =>
item.kind === 'audiobooks' || item.kind === 'music' || item.kind === 'games'
? 'Play'
: item.kind === 'photos'
? 'View'
: '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)}")`)
: 'Read'
</script>
<section class="featured">
<div class="wash" style="background-image: {wash}"></div>
<div class="scrim"></div>
{#if current}
<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="body">
<span class="kicker">{kicker}</span>
<h1>{item.title}</h1>
<p class="meta">{kindLabel}{#if percent > 0} · {Math.round(percent * 100)}% complete{/if}</p>
<div class="body">
<span class="kicker">{kickerFor(current)}</span>
<h1>{current.title}</h1>
<p class="meta">
{kindLabel(current)}
{#if percentFor(current.id) > 0}
· {Math.round(percentFor(current.id) * 100)}% complete
{/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 class="actions">
<button class="btn btn-primary btn-lg" onclick={() => go({ name: 'reader', itemId: item.id, title: item.title })}>
{percent > 0 ? 'Continue' : action}
</button>
<button class="btn btn-lg" onclick={() => go({ name: 'item', id: item.id })}>Details</button>
</div>
<div class="poster">
<div class="cover">
<img
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>
{/key}
{#if percent > 0}
<div class="bar"><i style="width:{Math.min(Math.max(percent, 0), 1) * 100}%"></i></div>
{#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}
</div>
<div class="poster">
<div class="cover">
<img src={api.coverUrl(item.id)} alt="" onerror={(e) => (e.currentTarget as HTMLImageElement).remove()} />
<span class="placeholder">{(item.title || '?').slice(0, 1).toUpperCase()}</span>
</div>
</div>
</section>
</section>
{/if}
<style>
.featured {
position: relative;
display: flex;
align-items: flex-end;
gap: var(--space-6);
min-height: clamp(240px, 34vh, 380px);
margin-bottom: var(--space-6);
padding: var(--space-6);
border: 1px solid var(--border);
border-radius: var(--radius-lg);
overflow: hidden;
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 {
position: absolute;
inset: -20%;
@ -93,7 +180,11 @@
inset: 0;
background:
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;
}
@ -104,46 +195,26 @@
gap: var(--space-2);
max-width: 560px;
}
.kicker {
font-size: 0.857rem;
font-weight: 500;
font-size: var(--fs-sm);
font-weight: 600;
color: var(--accent);
}
h1 {
font-size: clamp(1.7rem, 3.2vw, 2.4rem);
line-height: 1.1;
letter-spacing: -0.02em;
font-size: clamp(1.7rem, 3.2vw, 2.5rem);
text-wrap: balance;
}
.meta {
margin: 0;
font-size: 0.929rem;
font-size: var(--fs-md);
color: var(--muted);
}
.actions {
display: flex;
gap: 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 {
position: relative;
margin-left: auto;
@ -155,8 +226,61 @@
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) {
.featured {
.slide {
padding: var(--space-5);
}
.poster {

View file

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