@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 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 { class: klass = '' }: { class?: string } = $props();
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
- themes: theme.all.filter((t) => t.mode === mode)
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="Theme: {theme.label}" placement="bottom-end" triggerClass={klass} box="md">
30
- {#snippet trigger()}<span class="trigger" data-tsu="ThemePicker" title="Theme: {theme.label}">{@render swatch(theme.current)}</span>{/snippet}
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.mode}</div>
34
- <div class="grid" role="group" aria-label="{g.mode} themes">
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
- aria-pressed={t.id === theme.current}
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
- <div class="caption" aria-live="polite">{shown.icon ?? theme.fallbackIcon} {shown.label}</div>
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:focus-visible {
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.current = this.has(this.saved) ? this.saved : this.fallback;
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.saved = mode;
121
- this.current = mode;
122
- if (browser)
123
- localStorage.setItem(KEY, mode);
124
- this.apply();
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dorsk/tsumikit",
3
- "version": "0.46.0",
3
+ "version": "0.47.0",
4
4
  "description": "Minimal, dependency-free Svelte 5 + pure-CSS UI kit. Token-driven atoms, molecules & layouts with theming out of the box.",
5
5
  "type": "module",
6
6
  "license": "MIT",