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; --nav-width: 232px;
--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.4), 0 8px 24px rgba(0, 0, 0, 0.35);
--shadow-lift: 0 12px 32px rgba(0, 0, 0, 0.5); --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) { @media (prefers-color-scheme: light) {
@ -73,11 +82,18 @@ body,
margin: 0; 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 { body {
background: var(--bg); background: var(--bg);
color: var(--text); color: var(--text);
font-family: var(--font); font-family: var(--font);
font-size: 14px; font-size: 1rem;
line-height: 1.5; line-height: 1.5;
-webkit-font-smoothing: antialiased; -webkit-font-smoothing: antialiased;
} }
@ -98,13 +114,13 @@ h3 {
letter-spacing: -0.015em; letter-spacing: -0.015em;
} }
h1 { h1 {
font-size: 22px; font-size: 1.571rem;
} }
h2 { h2 {
font-size: 17px; font-size: 1.214rem;
} }
h3 { h3 {
font-size: 15px; font-size: 1.071rem;
} }
button, button,
@ -158,7 +174,7 @@ select {
} }
.btn-lg { .btn-lg {
padding: 11px 22px; padding: 11px 22px;
font-size: 15px; font-size: 1.071rem;
border-radius: 12px; border-radius: 12px;
} }
@ -183,7 +199,7 @@ select {
gap: 6px; gap: 6px;
} }
.field label { .field label {
font-size: 12px; font-size: .857rem;
font-weight: 500; font-weight: 500;
color: var(--muted); color: var(--muted);
} }
@ -201,7 +217,7 @@ select {
padding: 2px 8px; padding: 2px 8px;
border-radius: 999px; border-radius: 999px;
border: 1px solid var(--border-strong); border: 1px solid var(--border-strong);
font-size: 11px; font-size: .786rem;
color: var(--muted); color: var(--muted);
text-transform: uppercase; text-transform: uppercase;
letter-spacing: 0.05em; letter-spacing: 0.05em;
@ -214,7 +230,7 @@ select {
background: transparent; background: transparent;
color: var(--muted); color: var(--muted);
cursor: pointer; cursor: pointer;
font-size: 13px; font-size: .929rem;
} }
.chip:hover { .chip:hover {
color: var(--text); color: var(--text);
@ -231,14 +247,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: 13px; font-size: .929rem;
} }
.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: 13px; font-size: .929rem;
} }
.muted { .muted {
@ -249,7 +265,7 @@ select {
} }
.mono { .mono {
font-family: var(--mono); font-family: var(--mono);
font-size: 12px; font-size: .857rem;
} }
/* ── Cover card ─────────────────────────────────────────────────────── */ /* ── Cover card ─────────────────────────────────────────────────────── */
@ -277,7 +293,7 @@ select {
inset: 0; inset: 0;
display: grid; display: grid;
place-items: center; place-items: center;
font-size: 34px; font-size: 2.429rem;
font-weight: 700; font-weight: 700;
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));

View file

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

View file

@ -72,7 +72,7 @@
} }
.grid { .grid {
display: 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); gap: var(--space-5) var(--space-4);
} }
.skeleton.tile { .skeleton.tile {
@ -80,7 +80,7 @@
} }
@media (max-width: 700px) { @media (max-width: 700px) {
.grid { .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); gap: var(--space-4) var(--space-3);
} }
} }

View file

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

View file

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

View file

@ -107,7 +107,7 @@
<style> <style>
.hello { .hello {
margin: 0 0 var(--space-5); margin: 0 0 var(--space-5);
font-size: 15px; font-size: 1.071rem;
} }
.explore { .explore {
margin-top: var(--space-2); margin-top: var(--space-2);
@ -121,14 +121,14 @@
margin-bottom: var(--space-4); margin-bottom: var(--space-4);
} }
.explore h2 { .explore h2 {
font-size: 17px; font-size: 1.214rem;
} }
.small { .small {
font-size: 12.5px; font-size: .893rem;
} }
.grid { .grid {
display: 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); gap: var(--space-5) var(--space-4);
} }
.loading { .loading {
@ -144,7 +144,7 @@
.skeleton-row { .skeleton-row {
display: grid; display: grid;
grid-auto-flow: column; grid-auto-flow: column;
grid-auto-columns: 150px; grid-auto-columns: var(--card-w);
gap: var(--space-4); gap: var(--space-4);
overflow: hidden; overflow: hidden;
} }
@ -153,7 +153,7 @@
} }
@media (max-width: 700px) { @media (max-width: 700px) {
.grid { .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); gap: var(--space-4) var(--space-3);
} }
} }

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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