Appearance settings: text size and card size, and a larger default

- All 50 font-size declarations converted from px to rem, so a single root
  value scales headings, labels and body together. The root is now 15px, a
  deliberate ~7% increase over the previous 14px.
- Card width and grid column width are now --card-w / --grid-min variables,
  driven by --card-scale independently of text. Defaults bumped too: shelf
  cards 150 -> 160px, grid columns 148 -> 158px.
- Appearance panel (nav item, visible to everyone): Text size and Card size
  as segmented controls, a live preview, and a reset. Settings are stored in
  localStorage, per device rather than per account, because a TV across the
  room wants bigger text than a laptop and the same person uses both.
- nav: 'settings' was missing from the View union after the home rewrite;
  added back along with the Appearance entry.

Verified in the built bundle: root font 15px by default; "Larger" scales it to
19.2px; "Large" cards scale 160 to 192px; both persist across a reload and are
independent of each other.
This commit is contained in:
mithral 2026-10-10 17:07:43 -04:00
parent 4c51482dd6
commit 8178502b73
17 changed files with 340 additions and 58 deletions

View file

@ -41,6 +41,15 @@
--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);
/* User-controlled sizing. --ui-scale drives every rem in the app, so the
text-size setting scales headings, labels and body together. --card-scale
drives cover widths in shelves and grids, so card size is independent of
text size. Both are set from the Appearance panel and persisted locally. */
--ui-scale: 1;
--card-scale: 1;
--card-w: calc(160px * var(--card-scale));
--grid-min: calc(158px * var(--card-scale));
}
@media (prefers-color-scheme: light) {
@ -73,11 +82,18 @@ body,
margin: 0;
}
html {
/* The single root size every rem is relative to. 15px is a deliberate ~7%
increase over the previous 14px baseline; the Appearance panel multiplies
it via --ui-scale. */
font-size: calc(15px * var(--ui-scale));
}
body {
background: var(--bg);
color: var(--text);
font-family: var(--font);
font-size: 14px;
font-size: 1rem;
line-height: 1.5;
-webkit-font-smoothing: antialiased;
}
@ -98,13 +114,13 @@ h3 {
letter-spacing: -0.015em;
}
h1 {
font-size: 22px;
font-size: 1.571rem;
}
h2 {
font-size: 17px;
font-size: 1.214rem;
}
h3 {
font-size: 15px;
font-size: 1.071rem;
}
button,
@ -158,7 +174,7 @@ select {
}
.btn-lg {
padding: 11px 22px;
font-size: 15px;
font-size: 1.071rem;
border-radius: 12px;
}
@ -183,7 +199,7 @@ select {
gap: 6px;
}
.field label {
font-size: 12px;
font-size: .857rem;
font-weight: 500;
color: var(--muted);
}
@ -201,7 +217,7 @@ select {
padding: 2px 8px;
border-radius: 999px;
border: 1px solid var(--border-strong);
font-size: 11px;
font-size: .786rem;
color: var(--muted);
text-transform: uppercase;
letter-spacing: 0.05em;
@ -214,7 +230,7 @@ select {
background: transparent;
color: var(--muted);
cursor: pointer;
font-size: 13px;
font-size: .929rem;
}
.chip:hover {
color: var(--text);
@ -231,14 +247,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: 13px;
font-size: .929rem;
}
.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: 13px;
font-size: .929rem;
}
.muted {
@ -249,7 +265,7 @@ select {
}
.mono {
font-family: var(--mono);
font-size: 12px;
font-size: .857rem;
}
/* ── Cover card ─────────────────────────────────────────────────────── */
@ -277,7 +293,7 @@ select {
inset: 0;
display: grid;
place-items: center;
font-size: 34px;
font-size: 2.429rem;
font-weight: 700;
color: var(--faint);
background: linear-gradient(150deg, var(--surface-2), var(--surface-3));

View file

@ -238,7 +238,7 @@
.times {
display: flex;
justify-content: space-between;
font-size: 12px;
font-size: .857rem;
}
.controls {
display: flex;

View file

@ -72,7 +72,7 @@
}
.grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(148px, 1fr));
grid-template-columns: repeat(auto-fill, minmax(var(--grid-min), 1fr));
gap: var(--space-5) var(--space-4);
}
.skeleton.tile {
@ -80,7 +80,7 @@
}
@media (max-width: 700px) {
.grid {
grid-template-columns: repeat(auto-fill, minmax(110px, 1fr));
grid-template-columns: repeat(auto-fill, minmax(calc(var(--grid-min) * 0.75), 1fr));
gap: var(--space-4) var(--space-3);
}
}

View file

@ -77,7 +77,7 @@
min-width: 0;
}
.title {
font-size: 13px;
font-size: .929rem;
font-weight: 500;
color: var(--text);
overflow: hidden;
@ -88,7 +88,7 @@
line-height: 1.3;
}
.subtitle {
font-size: 11.5px;
font-size: .821rem;
color: var(--faint);
overflow: hidden;
text-overflow: ellipsis;
@ -102,7 +102,7 @@
border-radius: 999px;
background: rgba(0, 0, 0, 0.66);
color: #fff;
font-size: 11px;
font-size: .786rem;
font-weight: 600;
backdrop-filter: blur(4px);
}
@ -122,9 +122,9 @@
background: var(--accent);
}
.sm .title {
font-size: 12px;
font-size: .857rem;
}
.lg .title {
font-size: 14px;
font-size: 1.000rem;
}
</style>

View file

@ -173,7 +173,7 @@
}
.status {
margin: 0 0 var(--space-3);
font-size: 13px;
font-size: .929rem;
}
.stage {
height: calc(100vh - 60px - 150px);

View file

@ -107,7 +107,7 @@
<style>
.hello {
margin: 0 0 var(--space-5);
font-size: 15px;
font-size: 1.071rem;
}
.explore {
margin-top: var(--space-2);
@ -121,14 +121,14 @@
margin-bottom: var(--space-4);
}
.explore h2 {
font-size: 17px;
font-size: 1.214rem;
}
.small {
font-size: 12.5px;
font-size: .893rem;
}
.grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(148px, 1fr));
grid-template-columns: repeat(auto-fill, minmax(var(--grid-min), 1fr));
gap: var(--space-5) var(--space-4);
}
.loading {
@ -144,7 +144,7 @@
.skeleton-row {
display: grid;
grid-auto-flow: column;
grid-auto-columns: 150px;
grid-auto-columns: var(--card-w);
gap: var(--space-4);
overflow: hidden;
}
@ -153,7 +153,7 @@
}
@media (max-width: 700px) {
.grid {
grid-template-columns: repeat(auto-fill, minmax(110px, 1fr));
grid-template-columns: repeat(auto-fill, minmax(calc(var(--grid-min) * 0.75), 1fr));
gap: var(--space-4) var(--space-3);
}
}

View file

@ -141,18 +141,18 @@
padding-top: var(--space-2);
}
.kind {
font-size: 12px;
font-size: .857rem;
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--accent);
}
h1 {
font-size: 30px;
font-size: 2.143rem;
line-height: 1.15;
}
.byline {
margin: 0;
font-size: 15px;
font-size: 1.071rem;
color: var(--muted);
}
.resume {
@ -173,7 +173,7 @@
background: var(--accent);
}
.resume .muted {
font-size: 12px;
font-size: .857rem;
}
.actions {
display: flex;
@ -196,7 +196,7 @@
margin-bottom: var(--space-2);
color: var(--muted);
font-weight: 500;
font-size: 13px;
font-size: .929rem;
}
.tracks ol {
list-style: none;
@ -209,7 +209,7 @@
gap: var(--space-3);
padding: 6px 0;
border-bottom: 1px solid var(--border);
font-size: 13px;
font-size: .929rem;
}
.tracks li:last-child {
border-bottom: none;

View file

@ -191,7 +191,7 @@
background: var(--bg-hover);
}
.kind {
font-size: 11px;
font-size: .786rem;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--accent);
@ -215,7 +215,7 @@
border-radius: var(--radius);
background: color-mix(in srgb, var(--success) 8%, transparent);
margin-bottom: var(--space-4);
font-size: 13px;
font-size: .929rem;
}
.alert {
margin-bottom: var(--space-4);

View file

@ -98,7 +98,7 @@
}
.lead {
margin: -8px 0 0;
font-size: 13px;
font-size: .929rem;
}
.submit {
justify-content: center;

View file

@ -172,13 +172,13 @@
flex: 1;
}
.info strong {
font-size: 13px;
font-size: .929rem;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.info .muted {
font-size: 12px;
font-size: .857rem;
}
.pct {
color: var(--text-faint);

View file

@ -68,7 +68,7 @@
<style>
.count {
margin: 0 0 var(--space-5);
font-size: 13px;
font-size: .929rem;
}
section {
margin-bottom: var(--space-6);
@ -78,7 +78,7 @@
}
.grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(148px, 1fr));
grid-template-columns: repeat(auto-fill, minmax(var(--grid-min), 1fr));
gap: var(--space-5) var(--space-4);
}
.skeleton.tile {

View file

@ -0,0 +1,178 @@
<script lang="ts">
import { settings, setSettings, resetSettings, TEXT_STEPS, CARD_STEPS } from '../settings'
function isActive(current: number, value: number): boolean {
return Math.abs(current - value) < 0.001
}
</script>
<div class="wrap">
<p class="muted intro">
These preferences are saved on this device only, so a TV and a laptop can
each have their own.
</p>
<section class="card group">
<header>
<h3>Text size</h3>
<span class="muted small">{Math.round($settings.textScale * 100)}%</span>
</header>
<div class="segmented">
{#each TEXT_STEPS as step (step.label)}
<button
class="seg"
class:active={isActive($settings.textScale, step.value)}
onclick={() => setSettings({ textScale: step.value })}
>
{step.label}
</button>
{/each}
</div>
</section>
<section class="card group">
<header>
<h3>Card size</h3>
<span class="muted small">{Math.round($settings.cardScale * 100)}%</span>
</header>
<div class="segmented">
{#each CARD_STEPS as step (step.label)}
<button
class="seg"
class:active={isActive($settings.cardScale, step.value)}
onclick={() => setSettings({ cardScale: step.value })}
>
{step.label}
</button>
{/each}
</div>
</section>
<section class="group">
<h3 class="preview-heading">Preview</h3>
<div class="preview">
<div class="mock">
<div class="mock-cover">A</div>
<span class="mock-title">A Long Book Title</span>
<span class="mock-sub">3 views</span>
</div>
<div class="mock">
<div class="mock-cover alt">B</div>
<span class="mock-title">Another Title</span>
<span class="mock-sub">More by an author</span>
</div>
<div class="mock">
<div class="mock-cover alt2">C</div>
<span class="mock-title">Third Item</span>
<span class="mock-sub">42%</span>
</div>
</div>
<p class="sample muted">
Body text looks like this. Headings, labels, and captions all scale
together, while card size is controlled separately.
</p>
</section>
<button class="btn" onclick={resetSettings}>Reset to defaults</button>
</div>
<style>
.wrap {
max-width: 720px;
display: flex;
flex-direction: column;
gap: var(--space-5);
}
.intro {
margin: 0;
font-size: 0.929rem;
}
.group {
padding: var(--space-4);
}
.group header {
display: flex;
align-items: baseline;
justify-content: space-between;
margin-bottom: var(--space-3);
}
h3 {
font-size: 1rem;
}
.small {
font-size: 0.857rem;
}
.segmented {
display: flex;
flex-wrap: wrap;
gap: var(--space-2);
}
.seg {
padding: 7px 14px;
border: 1px solid var(--border-strong);
border-radius: var(--radius);
background: var(--bg);
color: var(--muted);
cursor: pointer;
font-size: 0.929rem;
}
.seg:hover {
color: var(--text);
border-color: var(--faint);
}
.seg.active {
background: var(--accent);
border-color: var(--accent);
color: var(--accent-text);
font-weight: 500;
}
.preview-heading {
margin-bottom: var(--space-3);
}
.preview {
display: grid;
grid-auto-flow: column;
grid-auto-columns: var(--card-w);
gap: var(--space-4);
overflow-x: auto;
padding-bottom: var(--space-2);
}
.mock {
display: flex;
flex-direction: column;
gap: var(--space-2);
}
.mock-cover {
aspect-ratio: 2 / 3;
border-radius: var(--radius-cover);
background: linear-gradient(150deg, var(--surface-2), var(--surface-3));
display: grid;
place-items: center;
font-size: 2.4rem;
font-weight: 700;
color: var(--faint);
box-shadow: var(--shadow-cover);
}
.mock-cover.alt {
background: linear-gradient(150deg, color-mix(in srgb, var(--accent) 40%, var(--surface-2)), var(--surface-3));
}
.mock-cover.alt2 {
background: linear-gradient(150deg, #b23b3b, var(--surface-3));
}
.mock-title {
font-size: 0.929rem;
font-weight: 500;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.mock-sub {
font-size: 0.821rem;
color: var(--faint);
}
.sample {
margin: var(--space-4) 0 0;
max-width: 60ch;
line-height: 1.6;
}
</style>

View file

@ -52,13 +52,13 @@
margin-bottom: var(--space-3);
}
h2 {
font-size: 17px;
font-size: 1.214rem;
}
.see-all {
border: none;
background: transparent;
color: var(--muted);
font-size: 13px;
font-size: .929rem;
cursor: pointer;
padding: 0;
}
@ -68,7 +68,7 @@
.row {
display: grid;
grid-auto-flow: column;
grid-auto-columns: 150px;
grid-auto-columns: var(--card-w);
gap: var(--space-4);
overflow-x: auto;
padding-bottom: var(--space-2);
@ -79,7 +79,7 @@
}
@media (max-width: 700px) {
.row {
grid-auto-columns: 120px;
grid-auto-columns: calc(var(--card-w) * 0.8);
}
}
</style>

View file

@ -8,6 +8,7 @@
import SearchResults from './SearchResults.svelte'
import Libraries from './Libraries.svelte'
import Users from './Users.svelte'
import Settings from './Settings.svelte'
let searchBox = $state('')
@ -38,6 +39,8 @@
return 'Libraries'
case 'users':
return 'Users'
case 'settings':
return 'Appearance'
}
}
</script>
@ -66,6 +69,9 @@
<button class="navlink" class:active={$view.name === 'browse' && !$view.kind} onclick={() => go({ name: 'browse' })}>
<span>Browse all</span>
</button>
<button class="navlink" class:active={$view.name === 'settings'} onclick={() => go({ name: 'settings' })}>
<span>Appearance</span>
</button>
</nav>
{#if $user?.is_admin}
@ -125,6 +131,8 @@
<Libraries />
{:else if $view.name === 'users'}
<Users />
{:else if $view.name === 'settings'}
<Settings />
{/if}
</div>
</main>
@ -154,7 +162,7 @@
background: transparent;
color: var(--text);
cursor: pointer;
font-size: 15px;
font-size: 1.071rem;
}
.mark {
display: grid;
@ -164,7 +172,7 @@
border-radius: 7px;
background: var(--accent);
color: var(--accent-text);
font-size: 13px;
font-size: .929rem;
font-weight: 700;
}
.links {
@ -184,7 +192,7 @@
color: var(--muted);
text-align: left;
cursor: pointer;
font-size: 13.5px;
font-size: .964rem;
}
.navlink:hover {
background: var(--surface-2);
@ -196,13 +204,13 @@
font-weight: 500;
}
.count {
font-size: 12px;
font-size: .857rem;
color: var(--faint);
font-variant-numeric: tabular-nums;
}
.section {
margin: 0 var(--space-2) -8px;
font-size: 11px;
font-size: .786rem;
text-transform: uppercase;
letter-spacing: 0.07em;
color: var(--faint);
@ -228,7 +236,7 @@
height: 28px;
border-radius: 50%;
background: var(--surface-3);
font-size: 12px;
font-size: .857rem;
font-weight: 600;
flex: none;
}
@ -239,13 +247,13 @@
min-width: 0;
}
.who strong {
font-size: 13px;
font-size: .929rem;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.who .faint {
font-size: 11px;
font-size: .786rem;
}
.main {
display: flex;
@ -274,7 +282,7 @@
height: 34px;
border-radius: 50%;
cursor: pointer;
font-size: 16px;
font-size: 1.143rem;
line-height: 1;
}
.back:hover {

View file

@ -204,7 +204,7 @@
flex: 1;
}
.id .muted {
font-size: 12px;
font-size: .857rem;
}
.grants {
padding: 0 var(--space-4) var(--space-4);
@ -214,7 +214,7 @@
}
.note {
margin: 0;
font-size: 12px;
font-size: .857rem;
}
.grantrow {
display: flex;
@ -223,10 +223,10 @@
}
.libname {
flex: 1;
font-size: 13px;
font-size: .929rem;
}
.libname .faint {
font-size: 11px;
font-size: .786rem;
text-transform: uppercase;
letter-spacing: 0.04em;
}
@ -240,7 +240,7 @@
display: flex;
align-items: center;
gap: 6px;
font-size: 13px;
font-size: .929rem;
color: var(--text-muted);
white-space: nowrap;
}

View file

@ -9,6 +9,7 @@ export type View =
| { name: 'search'; query: string }
| { name: 'libraries' }
| { name: 'users' }
| { name: 'settings' }
export const view = writable<View>({ name: 'home' })

79
web/src/lib/settings.ts Normal file
View file

@ -0,0 +1,79 @@
import { writable } from 'svelte/store'
// Display preferences. These are per-device, not per-account: a TV across the
// room wants bigger text than a laptop, and the same person uses both. Stored
// in localStorage so a change is instant and needs no round trip.
export interface UiSettings {
/** Multiplier on the 15px root size; scales all text. */
textScale: number
/** Multiplier on cover width in shelves and grids. */
cardScale: number
}
export const DEFAULTS: UiSettings = { textScale: 1, cardScale: 1 }
export const TEXT_STEPS = [
{ label: 'Small', value: 0.9 },
{ label: 'Default', value: 1 },
{ label: 'Large', value: 1.12 },
{ label: 'Larger', value: 1.28 },
]
export const CARD_STEPS = [
{ label: 'Compact', value: 0.85 },
{ label: 'Default', value: 1 },
{ label: 'Large', value: 1.2 },
{ label: 'Extra large', value: 1.45 },
]
const KEY = 'vellum.ui.settings'
function read(): UiSettings {
try {
const raw = localStorage.getItem(KEY)
if (!raw) return DEFAULTS
const parsed = JSON.parse(raw) as Partial<UiSettings>
return {
textScale: clamp(parsed.textScale ?? DEFAULTS.textScale, 0.8, 1.6),
cardScale: clamp(parsed.cardScale ?? DEFAULTS.cardScale, 0.7, 1.8),
}
} catch {
return DEFAULTS
}
}
function clamp(n: number, lo: number, hi: number): number {
if (!Number.isFinite(n)) return 1
return Math.min(Math.max(n, lo), hi)
}
/** Apply the settings to the document as CSS variables. */
export function applySettings(s: UiSettings) {
const root = document.documentElement
root.style.setProperty('--ui-scale', String(s.textScale))
root.style.setProperty('--card-scale', String(s.cardScale))
}
export const settings = writable<UiSettings>(read())
/** Update one or more settings, persist them, and apply them immediately. */
export function setSettings(patch: Partial<UiSettings>) {
settings.update((current) => {
const next = { ...current, ...patch }
applySettings(next)
try {
localStorage.setItem(KEY, JSON.stringify(next))
} catch {
// Private browsing or a full quota: keep the change for this session.
}
return next
})
}
export function resetSettings() {
setSettings(DEFAULTS)
}
// Apply whatever was stored before the first paint.
applySettings(read())