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:
parent
83c59522e7
commit
3d964e6b06
3 changed files with 343 additions and 160 deletions
184
web/src/app.css
184
web/src/app.css
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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} />
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue