Vellum/web/src/lib/components/Featured.svelte
mithral 3d964e6b06 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.
2026-10-10 17:27:59 -04:00

293 lines
7.3 KiB
Svelte

<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 {
items,
kickerFor,
percentFor = () => 0,
}: {
items: { id: number; title: string; kind?: string }[]
kickerFor: (item: { id: number; title: string; kind?: string }) => string
percentFor?: (id: number) => number
} = $props()
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 ??
item.kind ??
''
const action = (item: { kind?: string }) =>
item.kind === 'audiobooks' || item.kind === 'music' || item.kind === 'games'
? 'Play'
: item.kind === 'photos'
? 'View'
: 'Read'
</script>
{#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">{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="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 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>
.featured {
position: relative;
min-height: clamp(240px, 34vh, 380px);
margin-bottom: 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%;
background-size: cover;
background-position: center;
filter: blur(48px) saturate(1.5);
opacity: 0.5;
transform: scale(1.1);
pointer-events: none;
}
.scrim {
position: absolute;
inset: 0;
background:
linear-gradient(to top, var(--surface) 4%, transparent 70%),
linear-gradient(
to right,
color-mix(in srgb, var(--surface) 90%, transparent) 32%,
transparent 86%
);
pointer-events: none;
}
.body {
position: relative;
display: flex;
flex-direction: column;
gap: var(--space-2);
max-width: 560px;
}
.kicker {
font-size: var(--fs-sm);
font-weight: 600;
color: var(--accent);
}
h1 {
font-size: clamp(1.7rem, 3.2vw, 2.5rem);
text-wrap: balance;
}
.meta {
margin: 0;
font-size: var(--fs-md);
color: var(--muted);
}
.actions {
display: flex;
gap: var(--space-3);
margin-top: var(--space-3);
}
.poster {
position: relative;
margin-left: auto;
align-self: center;
flex: none;
width: 150px;
}
.poster .cover {
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) {
.slide {
padding: var(--space-5);
}
.poster {
display: none;
}
.body {
max-width: none;
}
}
</style>