diff --git a/web/src/app.css b/web/src/app.css
index 603ca35..b916c55 100644
--- a/web/src/app.css
+++ b/web/src/app.css
@@ -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));
diff --git a/web/src/lib/components/AudiobookPlayer.svelte b/web/src/lib/components/AudiobookPlayer.svelte
index 752f1e9..45a5ef2 100644
--- a/web/src/lib/components/AudiobookPlayer.svelte
+++ b/web/src/lib/components/AudiobookPlayer.svelte
@@ -238,7 +238,7 @@
.times {
display: flex;
justify-content: space-between;
- font-size: 12px;
+ font-size: .857rem;
}
.controls {
display: flex;
diff --git a/web/src/lib/components/Browse.svelte b/web/src/lib/components/Browse.svelte
index 4f58b0c..7668f60 100644
--- a/web/src/lib/components/Browse.svelte
+++ b/web/src/lib/components/Browse.svelte
@@ -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);
}
}
diff --git a/web/src/lib/components/Card.svelte b/web/src/lib/components/Card.svelte
index 4cd19f7..801a445 100644
--- a/web/src/lib/components/Card.svelte
+++ b/web/src/lib/components/Card.svelte
@@ -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;
}
diff --git a/web/src/lib/components/GamePlayer.svelte b/web/src/lib/components/GamePlayer.svelte
index 01d0b31..f942cc8 100644
--- a/web/src/lib/components/GamePlayer.svelte
+++ b/web/src/lib/components/GamePlayer.svelte
@@ -173,7 +173,7 @@
}
.status {
margin: 0 0 var(--space-3);
- font-size: 13px;
+ font-size: .929rem;
}
.stage {
height: calc(100vh - 60px - 150px);
diff --git a/web/src/lib/components/Home.svelte b/web/src/lib/components/Home.svelte
index c68b23c..2134e21 100644
--- a/web/src/lib/components/Home.svelte
+++ b/web/src/lib/components/Home.svelte
@@ -107,7 +107,7 @@
diff --git a/web/src/lib/components/Shelf.svelte b/web/src/lib/components/Shelf.svelte
index 4cdb1a8..f6b91bc 100644
--- a/web/src/lib/components/Shelf.svelte
+++ b/web/src/lib/components/Shelf.svelte
@@ -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);
}
}
diff --git a/web/src/lib/components/Shell.svelte b/web/src/lib/components/Shell.svelte
index a2fd6ca..1b3ca19 100644
--- a/web/src/lib/components/Shell.svelte
+++ b/web/src/lib/components/Shell.svelte
@@ -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'
}
}
@@ -66,6 +69,9 @@
+
{#if $user?.is_admin}
@@ -125,6 +131,8 @@
{:else if $view.name === 'users'}
+ {:else if $view.name === 'settings'}
+
{/if}
@@ -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 {
diff --git a/web/src/lib/components/Users.svelte b/web/src/lib/components/Users.svelte
index 8cb2ed4..3ada004 100644
--- a/web/src/lib/components/Users.svelte
+++ b/web/src/lib/components/Users.svelte
@@ -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;
}
diff --git a/web/src/lib/nav.ts b/web/src/lib/nav.ts
index 08c496e..f0f1584 100644
--- a/web/src/lib/nav.ts
+++ b/web/src/lib/nav.ts
@@ -9,6 +9,7 @@ export type View =
| { name: 'search'; query: string }
| { name: 'libraries' }
| { name: 'users' }
+ | { name: 'settings' }
export const view = writable({ name: 'home' })
diff --git a/web/src/lib/settings.ts b/web/src/lib/settings.ts
new file mode 100644
index 0000000..fa2ce92
--- /dev/null
+++ b/web/src/lib/settings.ts
@@ -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
+ 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(read())
+
+/** Update one or more settings, persist them, and apply them immediately. */
+export function setSettings(patch: Partial) {
+ 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())