@dorsk/tsumikit 0.46.0 → 0.47.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +12 -0
- package/dist/components/molecules/ThemePicker.svelte +129 -10
- package/dist/components/molecules/ThemePicker.svelte.d.ts +7 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/stores/theme.svelte.d.ts +23 -0
- package/dist/stores/theme.svelte.js +78 -6
- package/dist/theme-mode.d.ts +33 -0
- package/dist/theme-mode.js +45 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -64,6 +64,18 @@ import { Button, Field, Input, Modal, ThemePicker } from '@dorsk/tsumikit';
|
|
|
64
64
|
- `<ThemePicker />` and `<FontScalePicker />` wire the stores to the UI. Theme
|
|
65
65
|
is persisted to `localStorage` and applied with no flash (head snippet in
|
|
66
66
|
`app.html`) and updates the mobile `<meta name="theme-color">`.
|
|
67
|
+
- The store keeps a **preference**, not just an id: one remembered light theme,
|
|
68
|
+
one remembered dark theme, and which of the two is pinned — or `auto`, which
|
|
69
|
+
follows `prefers-color-scheme` and repaints when the system flips.
|
|
70
|
+
`theme.choose(id | 'auto')`, `theme.mode`, `theme.pref`, `theme.resolved`,
|
|
71
|
+
`theme.hydrate(pref)` (replay a server-side preference) and
|
|
72
|
+
`theme.onchange = (pref) => …` (mirror it back). `theme.set(id)` and
|
|
73
|
+
`theme.current` are unchanged. `<ThemePicker auto />` adds the "Auto" row;
|
|
74
|
+
its labels (`autoLabel`, `autoHelp`, `lightLabel`, `darkLabel`) are props.
|
|
75
|
+
- **Storage format (0.47):** `localStorage['tsumikit-theme']` now holds
|
|
76
|
+
`{"mode","light","dark"}` instead of a bare theme id. A bare id left by an
|
|
77
|
+
older version still loads — it pins its own slot — but a downgrade will not
|
|
78
|
+
read the new blob and falls back to the default theme.
|
|
67
79
|
|
|
68
80
|
### Stylesheets
|
|
69
81
|
|
|
@@ -7,17 +7,45 @@
|
|
|
7
7
|
// has no [data-theme] block, so its swatch carries the :root values.
|
|
8
8
|
import Popover from './Popover.svelte';
|
|
9
9
|
import { type ThemeDef, theme } from '../../stores/theme.svelte';
|
|
10
|
+
import { AUTO_THEME } from '../../theme-mode';
|
|
10
11
|
|
|
11
|
-
let {
|
|
12
|
+
let {
|
|
13
|
+
auto = false,
|
|
14
|
+
autoLabel = 'Auto',
|
|
15
|
+
autoHelp = 'Follow the system light/dark setting',
|
|
16
|
+
lightLabel = 'Light',
|
|
17
|
+
darkLabel = 'Dark',
|
|
18
|
+
class: klass = '',
|
|
19
|
+
}: {
|
|
20
|
+
/** Offer an "auto" row that follows `prefers-color-scheme`, remembering
|
|
21
|
+
* one light and one dark theme. */
|
|
22
|
+
auto?: boolean;
|
|
23
|
+
autoLabel?: string;
|
|
24
|
+
autoHelp?: string;
|
|
25
|
+
lightLabel?: string;
|
|
26
|
+
darkLabel?: string;
|
|
27
|
+
class?: string;
|
|
28
|
+
} = $props();
|
|
12
29
|
|
|
13
30
|
let hovered = $state<ThemeDef | null>(null);
|
|
31
|
+
let hoveredAuto = $state(false);
|
|
32
|
+
const isAuto = $derived(auto && theme.mode === AUTO_THEME);
|
|
14
33
|
const shown = $derived(hovered ?? theme.option);
|
|
15
34
|
const groups = $derived(
|
|
16
35
|
(['light', 'dark'] as const).map((mode) => ({
|
|
17
36
|
mode,
|
|
18
|
-
|
|
37
|
+
label: mode === 'light' ? lightLabel : darkLabel,
|
|
38
|
+
themes: theme.all.filter((t) => t.mode === mode),
|
|
19
39
|
}))
|
|
20
40
|
);
|
|
41
|
+
const named = (id: string) => theme.all.find((t) => t.id === id)?.label ?? id;
|
|
42
|
+
const autoCaption = $derived(`${autoLabel} · ${named(theme.pref.light)} / ${named(theme.pref.dark)}`);
|
|
43
|
+
const title = $derived(isAuto ? `${autoLabel} · ${theme.label}` : `Theme: ${theme.label}`);
|
|
44
|
+
const caption = $derived(
|
|
45
|
+
hoveredAuto || (isAuto && !hovered)
|
|
46
|
+
? autoCaption
|
|
47
|
+
: `${shown.icon ?? theme.fallbackIcon} ${shown.label}`
|
|
48
|
+
);
|
|
21
49
|
</script>
|
|
22
50
|
|
|
23
51
|
{#snippet swatch(id: string)}
|
|
@@ -26,18 +54,19 @@
|
|
|
26
54
|
</span>
|
|
27
55
|
{/snippet}
|
|
28
56
|
|
|
29
|
-
<Popover label=
|
|
30
|
-
{#snippet trigger()}<span class="trigger" data-tsu="ThemePicker" title
|
|
57
|
+
<Popover label={title} placement="bottom-end" triggerClass={klass} box="md">
|
|
58
|
+
{#snippet trigger()}<span class="trigger" data-tsu="ThemePicker" {title}>{@render swatch(theme.current)}{#if isAuto}<span class="auto-dot" aria-hidden="true">◐</span>{/if}</span>{/snippet}
|
|
31
59
|
<div class="panel">
|
|
32
60
|
{#each groups as g (g.mode)}
|
|
33
|
-
<div class="group-label">{g.
|
|
34
|
-
<div class="grid" role="group" aria-label="{g.
|
|
61
|
+
<div class="group-label">{g.label}</div>
|
|
62
|
+
<div class="grid" role="group" aria-label="{g.label} themes">
|
|
35
63
|
{#each g.themes as t (t.id)}
|
|
36
64
|
<button
|
|
37
65
|
type="button"
|
|
38
66
|
class="cell"
|
|
39
|
-
class:current={t.id === theme.current}
|
|
40
|
-
|
|
67
|
+
class:current={!isAuto && t.id === theme.current}
|
|
68
|
+
class:remembered={isAuto && t.id === theme.pref[g.mode]}
|
|
69
|
+
aria-pressed={!isAuto && t.id === theme.current}
|
|
41
70
|
aria-label={t.label}
|
|
42
71
|
title="{t.icon ?? theme.fallbackIcon} {t.label}"
|
|
43
72
|
onclick={() => theme.set(t.id)}
|
|
@@ -51,14 +80,50 @@
|
|
|
51
80
|
{/each}
|
|
52
81
|
</div>
|
|
53
82
|
{/each}
|
|
54
|
-
|
|
83
|
+
{#if auto}
|
|
84
|
+
<button
|
|
85
|
+
type="button"
|
|
86
|
+
class="auto"
|
|
87
|
+
class:current={isAuto}
|
|
88
|
+
aria-pressed={isAuto}
|
|
89
|
+
title={autoCaption}
|
|
90
|
+
onclick={() => theme.choose(AUTO_THEME)}
|
|
91
|
+
onpointerenter={() => (hoveredAuto = true)}
|
|
92
|
+
onpointerleave={() => (hoveredAuto = false)}
|
|
93
|
+
onfocus={() => (hoveredAuto = true)}
|
|
94
|
+
onblur={() => (hoveredAuto = false)}
|
|
95
|
+
>
|
|
96
|
+
<span class="auto-glyph" aria-hidden="true">◐</span>
|
|
97
|
+
<span class="auto-text">
|
|
98
|
+
<span class="auto-name">{autoLabel}</span>
|
|
99
|
+
<span class="auto-help">{autoHelp}</span>
|
|
100
|
+
</span>
|
|
101
|
+
<span class="auto-pair" aria-hidden="true">
|
|
102
|
+
{@render swatch(theme.pref.light)}
|
|
103
|
+
<span class="slash">/</span>
|
|
104
|
+
{@render swatch(theme.pref.dark)}
|
|
105
|
+
</span>
|
|
106
|
+
</button>
|
|
107
|
+
{/if}
|
|
108
|
+
<div class="caption" aria-live="polite">{caption}</div>
|
|
55
109
|
</div>
|
|
56
110
|
</Popover>
|
|
57
111
|
|
|
58
112
|
<style>
|
|
59
113
|
.trigger {
|
|
114
|
+
position: relative;
|
|
60
115
|
display: inline-flex;
|
|
61
116
|
}
|
|
117
|
+
.auto-dot {
|
|
118
|
+
position: absolute;
|
|
119
|
+
right: -0.35rem;
|
|
120
|
+
bottom: -0.35rem;
|
|
121
|
+
border-radius: 50%;
|
|
122
|
+
background: var(--bg);
|
|
123
|
+
color: var(--text-muted);
|
|
124
|
+
font-size: var(--fs-xs);
|
|
125
|
+
line-height: 1;
|
|
126
|
+
}
|
|
62
127
|
.swatch {
|
|
63
128
|
display: grid;
|
|
64
129
|
grid-template-columns: 1fr 1fr;
|
|
@@ -123,14 +188,68 @@
|
|
|
123
188
|
.cell.current {
|
|
124
189
|
border-color: var(--accent);
|
|
125
190
|
}
|
|
126
|
-
.cell
|
|
191
|
+
.cell.remembered {
|
|
192
|
+
border-color: var(--accent);
|
|
193
|
+
border-style: dashed;
|
|
194
|
+
}
|
|
195
|
+
.cell:focus-visible,
|
|
196
|
+
.auto:focus-visible {
|
|
127
197
|
outline: 2px solid var(--accent);
|
|
128
198
|
outline-offset: 1px;
|
|
129
199
|
}
|
|
200
|
+
.auto {
|
|
201
|
+
display: flex;
|
|
202
|
+
align-items: center;
|
|
203
|
+
gap: var(--sp-2);
|
|
204
|
+
width: 100%;
|
|
205
|
+
margin-top: var(--sp-2);
|
|
206
|
+
padding: var(--sp-1) var(--sp-2);
|
|
207
|
+
border: 2px solid transparent;
|
|
208
|
+
border-top: 1px solid var(--border);
|
|
209
|
+
border-radius: var(--r-md);
|
|
210
|
+
background: none;
|
|
211
|
+
color: inherit;
|
|
212
|
+
text-align: left;
|
|
213
|
+
cursor: pointer;
|
|
214
|
+
}
|
|
215
|
+
.auto:hover {
|
|
216
|
+
background: var(--bg-elevated-2);
|
|
217
|
+
}
|
|
218
|
+
.auto.current {
|
|
219
|
+
border-color: var(--accent);
|
|
220
|
+
}
|
|
221
|
+
.auto-glyph {
|
|
222
|
+
font-size: var(--fs-md);
|
|
223
|
+
line-height: 1;
|
|
224
|
+
}
|
|
225
|
+
.auto-text {
|
|
226
|
+
display: flex;
|
|
227
|
+
flex-direction: column;
|
|
228
|
+
flex: 1 1 auto;
|
|
229
|
+
min-width: 0;
|
|
230
|
+
}
|
|
231
|
+
.auto-name {
|
|
232
|
+
font-size: var(--fs-sm);
|
|
233
|
+
}
|
|
234
|
+
.auto-help {
|
|
235
|
+
font-size: var(--fs-xs);
|
|
236
|
+
color: var(--text-faint);
|
|
237
|
+
white-space: normal;
|
|
238
|
+
}
|
|
239
|
+
.auto-pair {
|
|
240
|
+
display: inline-flex;
|
|
241
|
+
align-items: center;
|
|
242
|
+
gap: var(--sp-1);
|
|
243
|
+
}
|
|
244
|
+
.slash {
|
|
245
|
+
color: var(--text-faint);
|
|
246
|
+
font-size: var(--fs-xs);
|
|
247
|
+
}
|
|
130
248
|
.caption {
|
|
131
249
|
margin-top: var(--sp-2);
|
|
132
250
|
text-align: center;
|
|
133
251
|
font-size: var(--fs-sm);
|
|
134
252
|
color: var(--text-muted);
|
|
253
|
+
white-space: normal;
|
|
135
254
|
}
|
|
136
255
|
</style>
|
|
@@ -1,4 +1,11 @@
|
|
|
1
1
|
type $$ComponentProps = {
|
|
2
|
+
/** Offer an "auto" row that follows `prefers-color-scheme`, remembering
|
|
3
|
+
* one light and one dark theme. */
|
|
4
|
+
auto?: boolean;
|
|
5
|
+
autoLabel?: string;
|
|
6
|
+
autoHelp?: string;
|
|
7
|
+
lightLabel?: string;
|
|
8
|
+
darkLabel?: string;
|
|
2
9
|
class?: string;
|
|
3
10
|
};
|
|
4
11
|
declare const ThemePicker: import("svelte").Component<$$ComponentProps, {}, "">;
|
package/dist/index.d.ts
CHANGED
|
@@ -90,6 +90,7 @@ export type { ControlSize } from './size';
|
|
|
90
90
|
export { fontScale, SCALE_LEVELS, type ScaleLevel } from './stores/fontscale.svelte';
|
|
91
91
|
export { type Mode, THEMES, theme } from './stores/theme.svelte';
|
|
92
92
|
export { type Toast, type ToastAction, type ToastOptions, type ToastTone, type ToastToneInput, toasts, } from './stores/toast.svelte';
|
|
93
|
+
export { AUTO_THEME, chooseTheme, DEFAULT_THEME_PREFERENCE, pickerValue, preferenceFrom, resolveTheme, type SlotOf, type ThemeChoice, type ThemePreference, type ThemeSlot, } from './theme-mode';
|
|
93
94
|
export { formatTimestamp, localTimeZone, relativeTime, type TimeInput, type TimestampMode, } from './timestamp';
|
|
94
95
|
export { canonicalTone, type Tone } from './tone';
|
|
95
96
|
export { pathCandidates, type TruncateMode, type TruncateOptions, truncate } from './truncate';
|
package/dist/index.js
CHANGED
|
@@ -101,6 +101,7 @@ export { fontScale, SCALE_LEVELS } from './stores/fontscale.svelte';
|
|
|
101
101
|
// ---- stores / actions ----
|
|
102
102
|
export { THEMES, theme } from './stores/theme.svelte';
|
|
103
103
|
export { toasts, } from './stores/toast.svelte';
|
|
104
|
+
export { AUTO_THEME, chooseTheme, DEFAULT_THEME_PREFERENCE, pickerValue, preferenceFrom, resolveTheme, } from './theme-mode';
|
|
104
105
|
export { formatTimestamp, localTimeZone, relativeTime, } from './timestamp';
|
|
105
106
|
export { canonicalTone } from './tone';
|
|
106
107
|
export { pathCandidates, truncate } from './truncate';
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { type SlotOf, type ThemeChoice, type ThemePreference } from '../theme-mode';
|
|
1
2
|
export declare const THEMES: readonly [{
|
|
2
3
|
readonly id: "light";
|
|
3
4
|
readonly label: "Light";
|
|
@@ -155,16 +156,32 @@ export interface ThemeDef {
|
|
|
155
156
|
}
|
|
156
157
|
declare class Theme {
|
|
157
158
|
current: ThemeId;
|
|
159
|
+
/** True while the system asks for a dark scheme; only read in `auto`. */
|
|
160
|
+
systemDark: boolean;
|
|
161
|
+
pref: ThemePreference;
|
|
162
|
+
/** Called with the new preference whenever the user changes it, so an app
|
|
163
|
+
* can mirror it into its own (server-side) settings. */
|
|
164
|
+
onchange?: (pref: ThemePreference) => void;
|
|
158
165
|
readonly fallbackIcon = "\u25C8";
|
|
159
166
|
private registered;
|
|
160
167
|
private fallback;
|
|
161
168
|
private saved;
|
|
162
169
|
constructor();
|
|
170
|
+
get slotOf(): SlotOf;
|
|
171
|
+
/** `auto`, or the pinned slot. */
|
|
172
|
+
get mode(): ThemeChoice;
|
|
173
|
+
/** The theme id painted for the current preference. */
|
|
174
|
+
get resolved(): ThemeId;
|
|
175
|
+
/** What a picker shows as selected: `auto`, or the pinned slot's theme id. */
|
|
176
|
+
get choice(): string;
|
|
163
177
|
get all(): readonly ThemeDef[];
|
|
164
178
|
has(id: string | null | undefined): id is ThemeId;
|
|
165
179
|
register(defs: ThemeDef | ThemeDef[]): void;
|
|
166
180
|
setDefault(id: ThemeId): void;
|
|
167
181
|
private resolve;
|
|
182
|
+
private read;
|
|
183
|
+
private paint;
|
|
184
|
+
private persist;
|
|
168
185
|
private apply;
|
|
169
186
|
get option(): ThemeDef;
|
|
170
187
|
get label(): string;
|
|
@@ -172,6 +189,12 @@ declare class Theme {
|
|
|
172
189
|
get next(): ThemeDef;
|
|
173
190
|
toggle(): void;
|
|
174
191
|
set(mode: ThemeId): void;
|
|
192
|
+
/** A picker choice: `auto`, or a theme id, which also becomes its slot's
|
|
193
|
+
* remembered theme. An unregistered id is ignored. */
|
|
194
|
+
choose(choice: string): ThemePreference;
|
|
195
|
+
/** Replay a preference an app persisted itself, without echoing it back
|
|
196
|
+
* through `onchange`. */
|
|
197
|
+
hydrate(pref: ThemePreference): void;
|
|
175
198
|
}
|
|
176
199
|
export declare const theme: Theme;
|
|
177
200
|
export {};
|
|
@@ -1,10 +1,15 @@
|
|
|
1
1
|
import { browser } from '../env';
|
|
2
|
+
import { AUTO_THEME, chooseTheme, DEFAULT_THEME_PREFERENCE, preferenceFrom, resolveTheme, } from '../theme-mode';
|
|
2
3
|
// Theme registry. Built-ins live in THEMES (+ one [data-theme="id"] block in
|
|
3
4
|
// styles/themes.css); consumers append their own with theme.register() and ship
|
|
4
5
|
// the matching block in their own stylesheet. `themeColor` drives the mobile
|
|
5
6
|
// browser-chrome <meta theme-color>; `mode` groups the theme into the picker's
|
|
6
7
|
// light/dark sections.
|
|
8
|
+
//
|
|
9
|
+
// `tsumikit-theme` holds a {mode,light,dark} blob; a bare theme id there is a
|
|
10
|
+
// legacy value and migrates through `preferenceFrom`.
|
|
7
11
|
const KEY = 'tsumikit-theme';
|
|
12
|
+
const SCHEME_QUERY = '(prefers-color-scheme: dark)';
|
|
8
13
|
export const THEMES = [
|
|
9
14
|
// ── Light ── bright, paper-white surfaces
|
|
10
15
|
{ id: 'light', label: 'Light', icon: '☀', themeColor: '#f6f7f9', mode: 'light' },
|
|
@@ -55,16 +60,43 @@ export const THEMES = [
|
|
|
55
60
|
const FALLBACK_ICON = '◈';
|
|
56
61
|
class Theme {
|
|
57
62
|
current = $state('dark');
|
|
63
|
+
/** True while the system asks for a dark scheme; only read in `auto`. */
|
|
64
|
+
systemDark = $state(false);
|
|
65
|
+
pref = $state(DEFAULT_THEME_PREFERENCE);
|
|
66
|
+
/** Called with the new preference whenever the user changes it, so an app
|
|
67
|
+
* can mirror it into its own (server-side) settings. */
|
|
68
|
+
onchange;
|
|
58
69
|
fallbackIcon = FALLBACK_ICON;
|
|
59
70
|
registered = $state([]);
|
|
60
71
|
fallback = 'dark';
|
|
61
72
|
saved = null;
|
|
62
73
|
constructor() {
|
|
63
74
|
if (browser) {
|
|
75
|
+
const mq = typeof matchMedia === 'function' ? matchMedia(SCHEME_QUERY) : null;
|
|
76
|
+
this.systemDark = mq?.matches ?? false;
|
|
77
|
+
mq?.addEventListener?.('change', (e) => {
|
|
78
|
+
this.systemDark = e.matches;
|
|
79
|
+
this.paint();
|
|
80
|
+
});
|
|
64
81
|
this.saved = localStorage.getItem(KEY);
|
|
65
82
|
this.resolve();
|
|
66
83
|
}
|
|
67
84
|
}
|
|
85
|
+
get slotOf() {
|
|
86
|
+
return (id) => this.all.find((t) => t.id === id)?.mode ?? null;
|
|
87
|
+
}
|
|
88
|
+
/** `auto`, or the pinned slot. */
|
|
89
|
+
get mode() {
|
|
90
|
+
return this.pref.mode;
|
|
91
|
+
}
|
|
92
|
+
/** The theme id painted for the current preference. */
|
|
93
|
+
get resolved() {
|
|
94
|
+
return resolveTheme(this.pref, this.systemDark);
|
|
95
|
+
}
|
|
96
|
+
/** What a picker shows as selected: `auto`, or the pinned slot's theme id. */
|
|
97
|
+
get choice() {
|
|
98
|
+
return this.pref.mode === AUTO_THEME ? AUTO_THEME : this.resolved;
|
|
99
|
+
}
|
|
68
100
|
get all() {
|
|
69
101
|
const byId = new Map();
|
|
70
102
|
for (const t of THEMES)
|
|
@@ -86,9 +118,38 @@ class Theme {
|
|
|
86
118
|
this.resolve();
|
|
87
119
|
}
|
|
88
120
|
resolve() {
|
|
89
|
-
this.
|
|
121
|
+
this.pref = this.read();
|
|
122
|
+
this.paint();
|
|
123
|
+
}
|
|
124
|
+
read() {
|
|
125
|
+
const seed = chooseTheme(DEFAULT_THEME_PREFERENCE, this.fallback, this.slotOf);
|
|
126
|
+
if (!this.saved)
|
|
127
|
+
return seed;
|
|
128
|
+
let blob = null;
|
|
129
|
+
try {
|
|
130
|
+
const parsed = JSON.parse(this.saved);
|
|
131
|
+
if (parsed && typeof parsed === 'object')
|
|
132
|
+
blob = parsed;
|
|
133
|
+
}
|
|
134
|
+
catch { }
|
|
135
|
+
return preferenceFrom(blob
|
|
136
|
+
? { themeMode: blob.mode, lightTheme: blob.light, darkTheme: blob.dark }
|
|
137
|
+
: { theme: this.saved }, this.slotOf, seed);
|
|
138
|
+
}
|
|
139
|
+
paint() {
|
|
140
|
+
this.current = this.resolved;
|
|
90
141
|
this.apply();
|
|
91
142
|
}
|
|
143
|
+
persist() {
|
|
144
|
+
this.saved = JSON.stringify(this.pref);
|
|
145
|
+
if (browser) {
|
|
146
|
+
try {
|
|
147
|
+
localStorage.setItem(KEY, this.saved);
|
|
148
|
+
}
|
|
149
|
+
catch { }
|
|
150
|
+
}
|
|
151
|
+
this.onchange?.(this.pref);
|
|
152
|
+
}
|
|
92
153
|
apply() {
|
|
93
154
|
if (!browser)
|
|
94
155
|
return;
|
|
@@ -117,11 +178,22 @@ class Theme {
|
|
|
117
178
|
this.set(this.next.id);
|
|
118
179
|
}
|
|
119
180
|
set(mode) {
|
|
120
|
-
this.
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
181
|
+
this.choose(mode);
|
|
182
|
+
}
|
|
183
|
+
/** A picker choice: `auto`, or a theme id, which also becomes its slot's
|
|
184
|
+
* remembered theme. An unregistered id is ignored. */
|
|
185
|
+
choose(choice) {
|
|
186
|
+
this.pref = chooseTheme(this.pref, choice, this.slotOf);
|
|
187
|
+
this.paint();
|
|
188
|
+
this.persist();
|
|
189
|
+
return this.pref;
|
|
190
|
+
}
|
|
191
|
+
/** Replay a preference an app persisted itself, without echoing it back
|
|
192
|
+
* through `onchange`. */
|
|
193
|
+
hydrate(pref) {
|
|
194
|
+
this.pref = pref;
|
|
195
|
+
this.saved = JSON.stringify(pref);
|
|
196
|
+
this.paint();
|
|
125
197
|
}
|
|
126
198
|
}
|
|
127
199
|
export const theme = new Theme();
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
export type ThemeSlot = 'light' | 'dark';
|
|
2
|
+
export type ThemeChoice = ThemeSlot | 'auto';
|
|
3
|
+
export interface ThemePreference {
|
|
4
|
+
/** `auto` follows the system; `light` / `dark` pin the matching slot. */
|
|
5
|
+
mode: ThemeChoice;
|
|
6
|
+
/** Last light theme the user picked. */
|
|
7
|
+
light: string;
|
|
8
|
+
/** Last dark theme the user picked. */
|
|
9
|
+
dark: string;
|
|
10
|
+
}
|
|
11
|
+
/** The picker value that means "follow the system". Never a theme id. */
|
|
12
|
+
export declare const AUTO_THEME = "auto";
|
|
13
|
+
export declare const DEFAULT_THEME_PREFERENCE: ThemePreference;
|
|
14
|
+
/** Which slot a theme id belongs to; `null` when the id is unknown. */
|
|
15
|
+
export type SlotOf = (id: string) => ThemeSlot | null;
|
|
16
|
+
/** The theme id to paint for a preference, given the system's current scheme. */
|
|
17
|
+
export declare function resolveTheme(pref: ThemePreference, systemDark: boolean): string;
|
|
18
|
+
/** Apply a picker choice: `auto` switches mode only; a theme id pins its slot
|
|
19
|
+
* AND becomes that slot's remembered theme. Unknown ids leave the preference
|
|
20
|
+
* untouched so a stale blob can never paint an unregistered theme. */
|
|
21
|
+
export declare function chooseTheme(pref: ThemePreference, choice: string, slotOf: SlotOf): ThemePreference;
|
|
22
|
+
/** Rebuild a preference from persisted fields. Older blobs only carried a
|
|
23
|
+
* single `theme`: it seeds whichever slot it belongs to and pins that mode, so
|
|
24
|
+
* nothing changes for a user who never touched the new picker. Unknown ids
|
|
25
|
+
* fall back to `defaults` rather than being trusted. */
|
|
26
|
+
export declare function preferenceFrom(raw: {
|
|
27
|
+
theme?: string | null;
|
|
28
|
+
themeMode?: string | null;
|
|
29
|
+
lightTheme?: string | null;
|
|
30
|
+
darkTheme?: string | null;
|
|
31
|
+
}, slotOf: SlotOf, defaults?: ThemePreference): ThemePreference;
|
|
32
|
+
/** The picker's current value: `auto`, or the pinned slot's theme id. */
|
|
33
|
+
export declare function pickerValue(pref: ThemePreference): string;
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
// Pure logic behind the light/dark/auto theme preference: the last light and
|
|
2
|
+
// the last dark theme the user picked, plus an `auto` mode that follows the
|
|
3
|
+
// system's `prefers-color-scheme`. Rune-free so it unit-tests in isolation.
|
|
4
|
+
/** The picker value that means "follow the system". Never a theme id. */
|
|
5
|
+
export const AUTO_THEME = 'auto';
|
|
6
|
+
export const DEFAULT_THEME_PREFERENCE = {
|
|
7
|
+
mode: 'dark',
|
|
8
|
+
light: 'light',
|
|
9
|
+
dark: 'dark',
|
|
10
|
+
};
|
|
11
|
+
/** The theme id to paint for a preference, given the system's current scheme. */
|
|
12
|
+
export function resolveTheme(pref, systemDark) {
|
|
13
|
+
const slot = pref.mode === 'auto' ? (systemDark ? 'dark' : 'light') : pref.mode;
|
|
14
|
+
return slot === 'dark' ? pref.dark : pref.light;
|
|
15
|
+
}
|
|
16
|
+
/** Apply a picker choice: `auto` switches mode only; a theme id pins its slot
|
|
17
|
+
* AND becomes that slot's remembered theme. Unknown ids leave the preference
|
|
18
|
+
* untouched so a stale blob can never paint an unregistered theme. */
|
|
19
|
+
export function chooseTheme(pref, choice, slotOf) {
|
|
20
|
+
if (choice === AUTO_THEME)
|
|
21
|
+
return { ...pref, mode: 'auto' };
|
|
22
|
+
const slot = slotOf(choice);
|
|
23
|
+
if (!slot)
|
|
24
|
+
return pref;
|
|
25
|
+
return { ...pref, mode: slot, [slot]: choice };
|
|
26
|
+
}
|
|
27
|
+
/** Rebuild a preference from persisted fields. Older blobs only carried a
|
|
28
|
+
* single `theme`: it seeds whichever slot it belongs to and pins that mode, so
|
|
29
|
+
* nothing changes for a user who never touched the new picker. Unknown ids
|
|
30
|
+
* fall back to `defaults` rather than being trusted. */
|
|
31
|
+
export function preferenceFrom(raw, slotOf, defaults = DEFAULT_THEME_PREFERENCE) {
|
|
32
|
+
const light = raw.lightTheme && slotOf(raw.lightTheme) === 'light' ? raw.lightTheme : defaults.light;
|
|
33
|
+
const dark = raw.darkTheme && slotOf(raw.darkTheme) === 'dark' ? raw.darkTheme : defaults.dark;
|
|
34
|
+
const pref = { mode: defaults.mode, light, dark };
|
|
35
|
+
if (raw.themeMode === 'auto' || raw.themeMode === 'light' || raw.themeMode === 'dark') {
|
|
36
|
+
return { ...pref, mode: raw.themeMode };
|
|
37
|
+
}
|
|
38
|
+
if (raw.theme)
|
|
39
|
+
return chooseTheme(pref, raw.theme, slotOf);
|
|
40
|
+
return pref;
|
|
41
|
+
}
|
|
42
|
+
/** The picker's current value: `auto`, or the pinned slot's theme id. */
|
|
43
|
+
export function pickerValue(pref) {
|
|
44
|
+
return pref.mode === 'auto' ? AUTO_THEME : pref.mode === 'dark' ? pref.dark : pref.light;
|
|
45
|
+
}
|
package/package.json
CHANGED