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.
|
/* 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);
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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} />
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue