From 83c59522e74c91160c3d48e5a3f60624ff8357d8 Mon Sep 17 00:00:00 2001 From: mithral Date: Sat, 10 Oct 2026 17:16:49 -0400 Subject: [PATCH] UI: make it move like it is alive MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Not new features — motion, focus, and depth, kept restrained. - Motion tokens: one easing curve and three durations, used everywhere, so the app moves as a single thing rather than component by component. Honours prefers-reduced-motion by disabling all of it. - View transitions: navigating fades and rises the content instead of swapping it, and each view starts scrolled to the top. - Topbar frosts once the content scrolls, so the header separates from the page without a permanent bar of chrome. - Featured area on Home: the item you are most likely to act on (something in progress, else the newest), with the cover as an ambient blurred wash. The colour comes from the artwork, not a decorative gradient, so it belongs to whatever is on screen. - Cards: lift and scale on hover, a scrim so the action glyph always has contrast, a kind-appropriate glyph (play / read / expand), press feedback, and a visible keyboard focus ring. - Shelves: edge fades and arrow buttons that appear only when the row actually continues that way; the scrollbar is hidden and the row snaps. - Item page: ambient wash from its own cover. - Global :focus-visible ring. Verified in the built bundle: featured renders the in-progress item, hover scales a 192px card to 197.8px with the glyph visible, and the item page tints from its artwork. --- web/src/app.css | 27 ++++ web/src/lib/components/Card.svelte | 106 ++++++++++++-- web/src/lib/components/Featured.svelte | 169 +++++++++++++++++++++++ web/src/lib/components/Home.svelte | 25 ++++ web/src/lib/components/ItemDetail.svelte | 29 ++++ web/src/lib/components/Shelf.svelte | 158 +++++++++++++++++++-- web/src/lib/components/Shell.svelte | 78 ++++++++--- 7 files changed, 547 insertions(+), 45 deletions(-) create mode 100644 web/src/lib/components/Featured.svelte diff --git a/web/src/app.css b/web/src/app.css index b916c55..48f8393 100644 --- a/web/src/app.css +++ b/web/src/app.css @@ -50,6 +50,14 @@ --card-scale: 1; --card-w: calc(160px * var(--card-scale)); --grid-min: calc(158px * var(--card-scale)); + + /* Motion. Short, eased, and purposeful: things respond to you, nothing + bounces. Every transition uses one of these so the app moves as one. */ + --ease: cubic-bezier(0.22, 1, 0.36, 1); + --ease-in-out: cubic-bezier(0.65, 0, 0.35, 1); + --t-fast: 120ms; + --t-base: 200ms; + --t-slow: 320ms; } @media (prefers-color-scheme: light) { @@ -130,6 +138,25 @@ select { color: inherit; } +/* Keyboard focus is visible everywhere; pointer clicks stay clean. */ +:focus-visible { + outline: 2px solid var(--accent); + outline-offset: 2px; + border-radius: 3px; +} + +/* Anyone who has asked the system for less motion gets none of it. */ +@media (prefers-reduced-motion: reduce) { + *, + *::before, + *::after { + animation-duration: 0.01ms !important; + animation-iteration-count: 1 !important; + transition-duration: 0.01ms !important; + scroll-behavior: auto !important; + } +} + /* ── Controls ───────────────────────────────────────────────────────── */ .btn { diff --git a/web/src/lib/components/Card.svelte b/web/src/lib/components/Card.svelte index 801a445..8f48ccb 100644 --- a/web/src/lib/components/Card.svelte +++ b/web/src/lib/components/Card.svelte @@ -19,6 +19,16 @@ subtitle?: string badge?: string } = $props() + + // The glyph hints at what opening the item does. It is the only decoration + // on the card, and it only appears on hover. + const glyph = $derived( + item.kind === 'audiobooks' || item.kind === 'music' || item.kind === 'games' + ? 'play' + : item.kind === 'photos' + ? 'expand' + : 'read', + ) + + + + {#if percent > 0} +
+ {/if} + + +
+
+ (e.currentTarget as HTMLImageElement).remove()} /> + {(item.title || '?').slice(0, 1).toUpperCase()} +
+
+ + + diff --git a/web/src/lib/components/Home.svelte b/web/src/lib/components/Home.svelte index 2134e21..31584e9 100644 --- a/web/src/lib/components/Home.svelte +++ b/web/src/lib/components/Home.svelte @@ -5,6 +5,7 @@ import { go } from '../nav' import Shelf from './Shelf.svelte' import Card from './Card.svelte' + import Featured from './Featured.svelte' let home = $state(null) let loading = $state(true) @@ -48,6 +49,26 @@ const hasAnything = $derived( 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' + } + } + return 'Recently added' + }) {#if loading} @@ -76,6 +97,10 @@ {:else}

Welcome back, {greeting}.

+ {#if featured} + + {/if} + {error} {:else if detail}
+
+
+
.detail { + position: relative; display: grid; grid-template-columns: 260px 1fr; gap: var(--space-6); align-items: start; max-width: 1000px; + padding: var(--space-5); + margin: calc(var(--space-1) * -1); + border-radius: var(--radius-lg); + overflow: hidden; + } + /* Ambient colour taken from the item's own cover, so each page is tinted by + what you opened rather than by a decorative gradient. */ + .wash { + position: absolute; + inset: -25%; + background-size: cover; + background-position: center; + filter: blur(60px) saturate(1.4); + opacity: 0.4; + pointer-events: none; + } + .scrim { + position: absolute; + inset: 0; + background: linear-gradient(to bottom, transparent, var(--bg) 88%); + pointer-events: none; + } + .poster, + .info { + position: relative; } .poster .cover { aspect-ratio: 2 / 3; diff --git a/web/src/lib/components/Shelf.svelte b/web/src/lib/components/Shelf.svelte index f6b91bc..41abf6b 100644 --- a/web/src/lib/components/Shelf.svelte +++ b/web/src/lib/components/Shelf.svelte @@ -1,4 +1,5 @@ {#if items.length > 0} @@ -23,20 +53,34 @@

{title}

{#if onSeeAll} - + {/if}
-
- {#each items as item (item.id)} -
- -
- {/each} + +
+
+ {#each items as item (item.id)} +
+ +
+ {/each} +
+ + +
{/if} @@ -52,19 +96,53 @@ margin-bottom: var(--space-3); } h2 { - font-size: 1.214rem; + font-size: 1.143rem; } .see-all { border: none; background: transparent; color: var(--muted); - font-size: .929rem; + font-size: 0.857rem; cursor: pointer; padding: 0; + transition: color var(--t-fast) var(--ease); } .see-all:hover { color: var(--text); } + + .viewport { + position: relative; + } + + /* Fade the edge only when the row continues that way. */ + .viewport::before, + .viewport::after { + content: ''; + position: absolute; + top: 0; + bottom: var(--space-2); + width: 48px; + pointer-events: none; + opacity: 0; + transition: opacity var(--t-base) var(--ease); + z-index: 1; + } + .viewport::before { + left: 0; + background: linear-gradient(to right, var(--bg), transparent); + } + .viewport::after { + right: 0; + background: linear-gradient(to left, var(--bg), transparent); + } + .viewport.left::before { + opacity: 1; + } + .viewport.right::after { + opacity: 1; + } + .row { display: grid; grid-auto-flow: column; @@ -73,10 +151,62 @@ overflow-x: auto; padding-bottom: var(--space-2); scroll-snap-type: x proximity; + scrollbar-width: none; + } + .row::-webkit-scrollbar { + display: none; } .slot { scroll-snap-align: start; } + + .arrow { + position: absolute; + top: 50%; + transform: translateY(-50%); + z-index: 2; + display: grid; + place-items: center; + width: 34px; + height: 34px; + border-radius: 50%; + border: 1px solid var(--border-strong); + background: var(--surface); + color: var(--text); + cursor: pointer; + opacity: 0; + transition: opacity var(--t-base) var(--ease), background var(--t-fast) var(--ease); + } + .arrow.left { + left: -12px; + } + .arrow.right { + right: -12px; + } + .arrow:hover { + background: var(--surface-3); + } + /* Arrows only exist on pointer devices; touch users swipe. */ + @media (hover: hover) { + .viewport:hover .arrow.left { + opacity: 1; + } + .viewport.right:hover .arrow.right { + opacity: 1; + } + .viewport:not(.left) .arrow.left { + display: none; + } + .viewport:not(.right) .arrow.right { + display: none; + } + } + @media (hover: none) { + .arrow { + display: none; + } + } + @media (max-width: 700px) { .row { grid-auto-columns: calc(var(--card-w) * 0.8); diff --git a/web/src/lib/components/Shell.svelte b/web/src/lib/components/Shell.svelte index 1b3ca19..93331d2 100644 --- a/web/src/lib/components/Shell.svelte +++ b/web/src/lib/components/Shell.svelte @@ -1,4 +1,6 @@