UI: make it move like it is alive

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.
This commit is contained in:
mithral 2026-10-10 17:16:49 -04:00
parent 8178502b73
commit 83c59522e7
7 changed files with 547 additions and 45 deletions

View file

@ -50,6 +50,14 @@
--card-scale: 1; --card-scale: 1;
--card-w: calc(160px * var(--card-scale)); --card-w: calc(160px * var(--card-scale));
--grid-min: calc(158px * 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) { @media (prefers-color-scheme: light) {
@ -130,6 +138,25 @@ select {
color: inherit; 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 ───────────────────────────────────────────────────────── */ /* ── Controls ───────────────────────────────────────────────────────── */
.btn { .btn {

View file

@ -19,6 +19,16 @@
subtitle?: string subtitle?: string
badge?: string badge?: string
} = $props() } = $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',
)
</script> </script>
<button class="tile {size}" onclick={() => go({ name: 'item', id: item.id })}> <button class="tile {size}" onclick={() => go({ name: 'item', id: item.id })}>
@ -30,6 +40,22 @@
onerror={(e) => (e.currentTarget as HTMLImageElement).remove()} onerror={(e) => (e.currentTarget as HTMLImageElement).remove()}
/> />
<span class="placeholder">{(item.title || '?').slice(0, 1).toUpperCase()}</span> <span class="placeholder">{(item.title || '?').slice(0, 1).toUpperCase()}</span>
<span class="scrim"></span>
<span class="action" aria-hidden="true">
{#if glyph === 'play'}
<svg viewBox="0 0 24 24" width="18" height="18"><path fill="currentColor" d="M8 5.5v13l11-6.5z" /></svg>
{:else if glyph === 'expand'}
<svg viewBox="0 0 24 24" width="17" height="17" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M15 3h6v6M9 21H3v-6M21 3l-7 7M3 21l7-7" />
</svg>
{:else}
<svg viewBox="0 0 24 24" width="17" height="17" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M4 5.5A1.5 1.5 0 0 1 5.5 4H11v16H5.5A1.5 1.5 0 0 1 4 18.5zM20 5.5A1.5 1.5 0 0 0 18.5 4H13v16h5.5a1.5 1.5 0 0 0 1.5-1.5z" />
</svg>
{/if}
</span>
{#if badge} {#if badge}
<span class="badge">{badge}</span> <span class="badge">{badge}</span>
{/if} {/if}
@ -37,6 +63,7 @@
<span class="progress"><i style="width:{Math.min(Math.max(percent, 0), 1) * 100}%"></i></span> <span class="progress"><i style="width:{Math.min(Math.max(percent, 0), 1) * 100}%"></i></span>
{/if} {/if}
</div> </div>
<span class="meta"> <span class="meta">
<span class="title">{item.title}</span> <span class="title">{item.title}</span>
{#if subtitle} {#if subtitle}
@ -60,16 +87,72 @@
cursor: pointer; cursor: pointer;
width: 100%; width: 100%;
} }
.tile:hover .cover { .cover {
transform: translateY(-3px); position: relative;
width: 100%;
transition:
transform var(--t-base) var(--ease),
box-shadow var(--t-base) var(--ease);
}
.tile:hover .cover,
.tile:focus-visible .cover {
transform: translateY(-4px) scale(1.03);
box-shadow: var(--shadow-lift); box-shadow: var(--shadow-lift);
} }
.tile:active .cover {
transform: translateY(-1px) scale(1.005);
transition-duration: var(--t-fast);
}
.tile:focus-visible {
outline: none;
}
.tile:focus-visible .cover {
outline: 2px solid var(--accent);
outline-offset: 3px;
}
.tile:hover .title { .tile:hover .title {
color: var(--text); color: var(--text);
} }
.cover {
width: 100%; /* A soft darkening so the action glyph always has contrast, whatever the
artwork underneath happens to be. */
.scrim {
position: absolute;
inset: 0;
background: linear-gradient(to top, rgba(0, 0, 0, 0.6), rgba(0, 0, 0, 0.15) 55%, transparent);
opacity: 0;
transition: opacity var(--t-base) var(--ease);
} }
.tile:hover .scrim,
.tile:focus-visible .scrim {
opacity: 1;
}
.action {
position: absolute;
left: 50%;
top: 50%;
display: grid;
place-items: center;
width: 42px;
height: 42px;
margin: -21px 0 0 -21px;
border-radius: 50%;
background: rgba(255, 255, 255, 0.94);
color: #101014;
opacity: 0;
transform: scale(0.86);
transition:
opacity var(--t-base) var(--ease),
transform var(--t-base) var(--ease);
pointer-events: none;
}
.tile:hover .action,
.tile:focus-visible .action {
opacity: 1;
transform: scale(1);
}
.meta { .meta {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@ -77,7 +160,7 @@
min-width: 0; min-width: 0;
} }
.title { .title {
font-size: .929rem; font-size: 0.929rem;
font-weight: 500; font-weight: 500;
color: var(--text); color: var(--text);
overflow: hidden; overflow: hidden;
@ -88,7 +171,7 @@
line-height: 1.3; line-height: 1.3;
} }
.subtitle { .subtitle {
font-size: .821rem; font-size: 0.821rem;
color: var(--faint); color: var(--faint);
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
@ -99,12 +182,11 @@
top: 6px; top: 6px;
left: 6px; left: 6px;
padding: 2px 7px; padding: 2px 7px;
border-radius: 999px; border-radius: 6px;
background: rgba(0, 0, 0, 0.66); background: rgba(0, 0, 0, 0.68);
color: #fff; color: #fff;
font-size: .786rem; font-size: 0.786rem;
font-weight: 600; font-weight: 600;
backdrop-filter: blur(4px);
} }
.progress { .progress {
position: absolute; position: absolute;
@ -122,9 +204,9 @@
background: var(--accent); background: var(--accent);
} }
.sm .title { .sm .title {
font-size: .857rem; font-size: 0.857rem;
} }
.lg .title { .lg .title {
font-size: 1.000rem; font-size: 1rem;
} }
</style> </style>

View file

@ -0,0 +1,169 @@
<script lang="ts">
import { api } from '../api'
import { mediaTypes } from '../session'
import { go } from '../nav'
let {
item,
kicker,
percent = 0,
}: {
item: { id: number; title: string; kind?: string }
kicker: string
percent?: number
} = $props()
const kindLabel = $derived(
($mediaTypes as { Kind: string; Name: string }[]).find((t) => t.Kind === item.kind)?.Name ??
item.kind ??
'',
)
const action = $derived(
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)}")`)
</script>
<section class="featured">
<div class="wash" style="background-image: {wash}"></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="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>
{#if percent > 0}
<div class="bar"><i style="width:{Math.min(Math.max(percent, 0), 1) * 100}%"></i></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>
<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);
}
.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) 88%, transparent) 30%, transparent 85%);
pointer-events: none;
}
.body {
position: relative;
display: flex;
flex-direction: column;
gap: var(--space-2);
max-width: 560px;
}
.kicker {
font-size: 0.857rem;
font-weight: 500;
color: var(--accent);
}
h1 {
font-size: clamp(1.7rem, 3.2vw, 2.4rem);
line-height: 1.1;
letter-spacing: -0.02em;
text-wrap: balance;
}
.meta {
margin: 0;
font-size: 0.929rem;
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;
align-self: center;
flex: none;
width: 150px;
}
.poster .cover {
aspect-ratio: 2 / 3;
}
@media (max-width: 820px) {
.featured {
padding: var(--space-5);
}
.poster {
display: none;
}
.body {
max-width: none;
}
}
</style>

View file

@ -5,6 +5,7 @@
import { go } from '../nav' import { go } from '../nav'
import Shelf from './Shelf.svelte' import Shelf from './Shelf.svelte'
import Card from './Card.svelte' import Card from './Card.svelte'
import Featured from './Featured.svelte'
let home = $state<Home | null>(null) let home = $state<Home | null>(null)
let loading = $state(true) let loading = $state(true)
@ -48,6 +49,26 @@
const hasAnything = $derived( const hasAnything = $derived(
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
// 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'
})
</script> </script>
{#if loading} {#if loading}
@ -76,6 +97,10 @@
{:else} {:else}
<p class="hello muted">Welcome back, {greeting}.</p> <p class="hello muted">Welcome back, {greeting}.</p>
{#if featured}
<Featured item={featured} {kicker} percent={percentFor(featured.id)} />
{/if}
<Shelf title="Continue" items={continueItems} {percentFor} /> <Shelf title="Continue" items={continueItems} {percentFor} />
<Shelf <Shelf

View file

@ -70,6 +70,9 @@
<div class="alert">{error}</div> <div class="alert">{error}</div>
{:else if detail} {:else if detail}
<div class="detail"> <div class="detail">
<div class="wash" style="background-image: url('{api.coverUrl(detail.item.id)}')"></div>
<div class="scrim"></div>
<div class="poster"> <div class="poster">
<div class="cover"> <div class="cover">
<img <img
@ -125,11 +128,37 @@
<style> <style>
.detail { .detail {
position: relative;
display: grid; display: grid;
grid-template-columns: 260px 1fr; grid-template-columns: 260px 1fr;
gap: var(--space-6); gap: var(--space-6);
align-items: start; align-items: start;
max-width: 1000px; 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 { .poster .cover {
aspect-ratio: 2 / 3; aspect-ratio: 2 / 3;

View file

@ -1,4 +1,5 @@
<script lang="ts"> <script lang="ts">
import { onMount } from 'svelte'
import Card from './Card.svelte' import Card from './Card.svelte'
let { let {
@ -16,6 +17,35 @@
badgeFor?: (item: { id: number; title: string; kind?: string }) => string badgeFor?: (item: { id: number; title: string; kind?: string }) => string
onSeeAll?: (() => void) | null onSeeAll?: (() => void) | null
} = $props() } = $props()
let scroller = $state<HTMLDivElement>()
let canLeft = $state(false)
let canRight = $state(false)
// Edge fades and arrows only appear when there is actually more to see, so
// the shelf never implies content that is not there.
function update() {
if (!scroller) return
const max = scroller.scrollWidth - scroller.clientWidth
canLeft = scroller.scrollLeft > 4
canRight = scroller.scrollLeft < max - 4
}
function page(direction: 1 | -1) {
if (!scroller) return
scroller.scrollBy({ left: direction * scroller.clientWidth * 0.8, behavior: 'smooth' })
}
onMount(() => {
update()
const ro = new ResizeObserver(update)
if (scroller) ro.observe(scroller)
window.addEventListener('resize', update)
return () => {
ro.disconnect()
window.removeEventListener('resize', update)
}
})
</script> </script>
{#if items.length > 0} {#if items.length > 0}
@ -23,20 +53,34 @@
<header> <header>
<h2>{title}</h2> <h2>{title}</h2>
{#if onSeeAll} {#if onSeeAll}
<button class="see-all" onclick={onSeeAll}>See all →</button> <button class="see-all" onclick={onSeeAll}>See all</button>
{/if} {/if}
</header> </header>
<div class="row">
{#each items as item (item.id)} <div class="viewport" class:left={canLeft} class:right={canRight}>
<div class="slot"> <div class="row" bind:this={scroller} onscroll={update}>
<Card {#each items as item (item.id)}
{item} <div class="slot">
percent={percentFor(item.id)} <Card
subtitle={subtitleFor(item)} {item}
badge={badgeFor(item)} percent={percentFor(item.id)}
/> subtitle={subtitleFor(item)}
</div> badge={badgeFor(item)}
{/each} />
</div>
{/each}
</div>
<button class="arrow left" onclick={() => page(-1)} aria-label="Scroll left" tabindex={canLeft ? 0 : -1}>
<svg viewBox="0 0 24 24" width="18" height="18" 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 right" onclick={() => page(1)} aria-label="Scroll right" tabindex={canRight ? 0 : -1}>
<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M9 18l6-6-6-6" />
</svg>
</button>
</div> </div>
</section> </section>
{/if} {/if}
@ -52,19 +96,53 @@
margin-bottom: var(--space-3); margin-bottom: var(--space-3);
} }
h2 { h2 {
font-size: 1.214rem; font-size: 1.143rem;
} }
.see-all { .see-all {
border: none; border: none;
background: transparent; background: transparent;
color: var(--muted); color: var(--muted);
font-size: .929rem; font-size: 0.857rem;
cursor: pointer; cursor: pointer;
padding: 0; padding: 0;
transition: color var(--t-fast) var(--ease);
} }
.see-all:hover { .see-all:hover {
color: var(--text); 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 { .row {
display: grid; display: grid;
grid-auto-flow: column; grid-auto-flow: column;
@ -73,10 +151,62 @@
overflow-x: auto; overflow-x: auto;
padding-bottom: var(--space-2); padding-bottom: var(--space-2);
scroll-snap-type: x proximity; scroll-snap-type: x proximity;
scrollbar-width: none;
}
.row::-webkit-scrollbar {
display: none;
} }
.slot { .slot {
scroll-snap-align: start; 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) { @media (max-width: 700px) {
.row { .row {
grid-auto-columns: calc(var(--card-w) * 0.8); grid-auto-columns: calc(var(--card-w) * 0.8);

View file

@ -1,4 +1,6 @@
<script lang="ts"> <script lang="ts">
import { fly } from 'svelte/transition'
import { cubicOut } from 'svelte/easing'
import { user, logout, mediaTypes, counts } from '../session' import { user, logout, mediaTypes, counts } from '../session'
import { view, go, type View } from '../nav' import { view, go, type View } from '../nav'
import Home from './Home.svelte' import Home from './Home.svelte'
@ -11,6 +13,25 @@
import Settings from './Settings.svelte' import Settings from './Settings.svelte'
let searchBox = $state('') let searchBox = $state('')
let scrolled = $state(false)
let contentEl = $state<HTMLDivElement>()
// A stable key for the current view, so navigating transitions the content
// instead of swapping it instantly.
const viewKey = $derived(JSON.stringify($view))
function onScroll() {
scrolled = (contentEl?.scrollTop ?? 0) > 8
}
// Each new view starts at the top, and the topbar un-frosts.
$effect(() => {
viewKey
if (contentEl) {
contentEl.scrollTo({ top: 0 })
scrolled = false
}
})
const types = $derived( const types = $derived(
($mediaTypes as { Kind: string; Name: string }[]).filter((t) => ($counts[t.Kind] ?? 0) > 0), ($mediaTypes as { Kind: string; Name: string }[]).filter((t) => ($counts[t.Kind] ?? 0) > 0),
@ -99,7 +120,7 @@
</aside> </aside>
<main class="main"> <main class="main">
<header class="topbar"> <header class="topbar" class:scrolled>
{#if title($view)} {#if title($view)}
<h1>{title($view)}</h1> <h1>{title($view)}</h1>
{:else} {:else}
@ -116,24 +137,31 @@
</form> </form>
</header> </header>
<div class="content"> <div class="content" bind:this={contentEl} onscroll={onScroll}>
{#if $view.name === 'home'} {#key viewKey}
<Home /> <div
{:else if $view.name === 'browse'} class="view"
<Browse kind={$view.kind} libraryId={$view.libraryId} /> in:fly={{ y: 8, duration: 220, easing: cubicOut }}
{:else if $view.name === 'item'} >
<ItemDetail id={$view.id} /> {#if $view.name === 'home'}
{:else if $view.name === 'reader'} <Home />
<Reader itemId={$view.itemId} /> {:else if $view.name === 'browse'}
{:else if $view.name === 'search'} <Browse kind={$view.kind} libraryId={$view.libraryId} />
<SearchResults query={$view.query} /> {:else if $view.name === 'item'}
{:else if $view.name === 'libraries'} <ItemDetail id={$view.id} />
<Libraries /> {:else if $view.name === 'reader'}
{:else if $view.name === 'users'} <Reader itemId={$view.itemId} />
<Users /> {:else if $view.name === 'search'}
{:else if $view.name === 'settings'} <SearchResults query={$view.query} />
<Settings /> {:else if $view.name === 'libraries'}
{/if} <Libraries />
{:else if $view.name === 'users'}
<Users />
{:else if $view.name === 'settings'}
<Settings />
{/if}
</div>
{/key}
</div> </div>
</main> </main>
</div> </div>
@ -269,6 +297,18 @@
height: 60px; height: 60px;
padding: 0 var(--space-6); padding: 0 var(--space-6);
flex: none; flex: none;
border-bottom: 1px solid transparent;
transition:
background var(--t-base) var(--ease),
border-color var(--t-base) var(--ease);
}
/* Frosts once the content moves, so the header separates from the page
without a permanent bar of chrome. */
.topbar.scrolled {
background: color-mix(in srgb, var(--bg) 82%, transparent);
backdrop-filter: blur(14px);
-webkit-backdrop-filter: blur(14px);
border-bottom-color: var(--border);
} }
.search { .search {
flex: 1; flex: 1;