@dorsk/tsumikit 0.45.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
 
@@ -164,7 +176,8 @@ Tabs, RadioGroup (`variant="rows"`: bordered rows, per-option `note`/`descriptio
164
176
  `action(option)` trailing control that never toggles, `below(option)` inline panel),
165
177
  Tooltip, Accordion, CopyButton, FileButton,
166
178
  Dropzone, CodeBlock, Callout, EmptyState, ConfirmModal, Pagination, Toaster,
167
- ThemePicker (popover grid of 2×2 palette swatches: bg · surface · text · accent per theme), FontScalePicker (popover with a stepped slider across the five text sizes), SectionHeader, KeyValue, LoadMore,
179
+ ThemePicker (popover grid of 2×2 palette swatches: bg · surface · text · accent per theme),
180
+ EmojiPicker (popover with a searchable EN+FR glyph catalogue, grouped tabs, roving-tabindex grid; pass `groups` to swap the catalogue), FontScalePicker (popover with a stepped slider across the five text sizes), SectionHeader, KeyValue, LoadMore,
168
181
  GitRef (branch chip + PR link tinted by state + `+N −N` diff; `collapse`
169
182
  auto/never/glyph degrades to icons inside a narrow `.cq` container),
170
183
  CapBar (consumption track with a draggable, keyboard-steppable cap handle that
@@ -0,0 +1,256 @@
1
+ <script lang="ts">
2
+ // Emoji chooser: a Popover holding a search box, a tab strip of catalogue
3
+ // groups and a grid of glyphs. Desktop browsers have no emoji keyboard, so
4
+ // this is the click-only path next to a plain text field.
5
+ import Input from '../atoms/Input.svelte';
6
+ import Popover from './Popover.svelte';
7
+ import { type EmojiGroup, EMOJI_GROUPS, searchEmoji } from '../../emoji';
8
+
9
+ let {
10
+ value = '',
11
+ onselect,
12
+ groups = EMOJI_GROUPS,
13
+ columns = 8,
14
+ label = 'Choose an emoji',
15
+ searchLabel = 'Search an emoji…',
16
+ emptyLabel = 'No emoji matches.',
17
+ placement = 'bottom-start',
18
+ triggerClass = '',
19
+ disabled = false,
20
+ class: klass = '',
21
+ style: styleProp = '',
22
+ }: {
23
+ /** The current glyph, highlighted in the grid. */
24
+ value?: string;
25
+ onselect: (emoji: string) => void;
26
+ /** Catalogue to browse; defaults to the kit's EN+FR keyword set. */
27
+ groups?: readonly EmojiGroup[];
28
+ columns?: number;
29
+ /** Accessible name of the trigger and of the glyph grid. */
30
+ label?: string;
31
+ searchLabel?: string;
32
+ emptyLabel?: string;
33
+ placement?: 'bottom-start' | 'bottom-end' | 'top-start' | 'top-end';
34
+ triggerClass?: string;
35
+ disabled?: boolean;
36
+ class?: string;
37
+ style?: string;
38
+ } = $props();
39
+
40
+ const uid = `emoji-${Math.random().toString(36).slice(2, 8)}`;
41
+ const tabId = (id: string) => `${uid}-tab-${id}`;
42
+ const panelId = `${uid}-panel`;
43
+
44
+ let query = $state('');
45
+ let groupId = $state<string | null>(null);
46
+ let cursor = $state(0);
47
+ let gridEl = $state<HTMLDivElement | null>(null);
48
+
49
+ const current = $derived(groups.find((g) => g.id === groupId) ?? groups[0]);
50
+ const searching = $derived(query.trim().length > 0);
51
+ const shown = $derived(searching ? searchEmoji(query, groups) : (current?.entries ?? []));
52
+ const active = $derived(Math.min(cursor, Math.max(shown.length - 1, 0)));
53
+ const picked = $derived(value.trim());
54
+
55
+ function focusCell(i: number) {
56
+ const cells = gridEl?.querySelectorAll<HTMLElement>('[role="option"]');
57
+ if (!cells?.length) return;
58
+ cursor = Math.max(0, Math.min(i, cells.length - 1));
59
+ cells[cursor].focus();
60
+ }
61
+
62
+ function pick(emoji: string, close: () => void) {
63
+ onselect(emoji);
64
+ query = '';
65
+ close();
66
+ }
67
+
68
+ function onGridClick(e: MouseEvent, close: () => void) {
69
+ const cell = (e.target as HTMLElement | null)?.closest<HTMLElement>('[role="option"]');
70
+ if (cell?.dataset.emoji) pick(cell.dataset.emoji, close);
71
+ }
72
+
73
+ function onGridKeydown(e: KeyboardEvent, close: () => void) {
74
+ const keys: Record<string, number> = {
75
+ ArrowRight: active + 1,
76
+ ArrowLeft: active - 1,
77
+ ArrowDown: active + columns,
78
+ ArrowUp: active - columns,
79
+ Home: 0,
80
+ End: shown.length - 1,
81
+ };
82
+ if (e.key in keys) {
83
+ e.preventDefault();
84
+ focusCell(keys[e.key]);
85
+ } else if (e.key === 'Enter' || e.key === ' ') {
86
+ e.preventDefault();
87
+ const emoji = shown[active]?.emoji;
88
+ if (emoji) pick(emoji, close);
89
+ }
90
+ }
91
+
92
+ function onTabsKeydown(e: KeyboardEvent) {
93
+ const i = groups.findIndex((g) => g.id === current?.id);
94
+ const next = e.key === 'ArrowRight' ? i + 1 : e.key === 'ArrowLeft' ? i - 1 : null;
95
+ if (next === null) return;
96
+ e.preventDefault();
97
+ const g = groups[(next + groups.length) % groups.length];
98
+ groupId = g.id;
99
+ cursor = 0;
100
+ document.getElementById(tabId(g.id))?.focus();
101
+ }
102
+ </script>
103
+
104
+ <Popover
105
+ {label}
106
+ {placement}
107
+ {triggerClass}
108
+ {disabled}
109
+ class={klass}
110
+ style={styleProp}
111
+ control
112
+ role="dialog"
113
+ onopen={() => {
114
+ query = '';
115
+ cursor = 0;
116
+ }}
117
+ >
118
+ {#snippet trigger()}
119
+ <span class="emoji-trigger" aria-hidden="true">{picked || '🙂'}</span>
120
+ {/snippet}
121
+
122
+ {#snippet children({ close }: { close: () => void })}
123
+ <div class="picker">
124
+ <Input
125
+ bind:value={query}
126
+ placeholder={searchLabel}
127
+ aria-label={searchLabel}
128
+ size="sm"
129
+ clearable
130
+ onclear={() => (query = '')}
131
+ style="width:100%"
132
+ />
133
+
134
+ {#if !searching}
135
+ <div class="tabs" role="tablist" tabindex={-1} aria-label={label} onkeydown={onTabsKeydown}>
136
+ {#each groups as g (g.id)}
137
+ <button
138
+ type="button"
139
+ role="tab"
140
+ id={tabId(g.id)}
141
+ class="tab"
142
+ class:on={g.id === current?.id}
143
+ aria-selected={g.id === current?.id}
144
+ aria-controls={panelId}
145
+ aria-label={g.label}
146
+ tabindex={g.id === current?.id ? 0 : -1}
147
+ onclick={() => {
148
+ groupId = g.id;
149
+ cursor = 0;
150
+ }}
151
+ >
152
+ <span aria-hidden="true">{g.icon}</span>
153
+ </button>
154
+ {/each}
155
+ </div>
156
+ {/if}
157
+
158
+ <div
159
+ id={panelId}
160
+ role={searching ? undefined : 'tabpanel'}
161
+ aria-labelledby={searching || !current ? undefined : tabId(current.id)}
162
+ >
163
+ {#if shown.length}
164
+ <!-- Options are not buttons: `option` is only valid as a listbox child,
165
+ and a roving tabindex keeps the grid to one tab stop. -->
166
+ <div
167
+ bind:this={gridEl}
168
+ class="grid"
169
+ role="listbox"
170
+ tabindex={-1}
171
+ aria-label={label}
172
+ style:--emoji-columns={columns}
173
+ onclick={(e) => onGridClick(e, close)}
174
+ onkeydown={(e) => onGridKeydown(e, close)}
175
+ >
176
+ {#each shown as x, i (x.emoji)}
177
+ <div
178
+ role="option"
179
+ class="cell"
180
+ class:on={x.emoji === picked}
181
+ aria-selected={x.emoji === picked}
182
+ data-emoji={x.emoji}
183
+ title={x.keys}
184
+ tabindex={i === active ? 0 : -1}
185
+ >
186
+ {x.emoji}
187
+ </div>
188
+ {/each}
189
+ </div>
190
+ {:else}
191
+ <p class="empty">{emptyLabel}</p>
192
+ {/if}
193
+ </div>
194
+ </div>
195
+ {/snippet}
196
+ </Popover>
197
+
198
+ <style>
199
+ .emoji-trigger {
200
+ font-size: var(--fs-md);
201
+ line-height: 1;
202
+ }
203
+ .picker {
204
+ display: flex;
205
+ flex-direction: column;
206
+ gap: var(--sp-2);
207
+ width: min(20rem, calc(100vw - var(--sp-6)));
208
+ }
209
+ .tabs {
210
+ display: flex;
211
+ gap: var(--sp-1);
212
+ }
213
+ .tab,
214
+ .cell {
215
+ display: flex;
216
+ align-items: center;
217
+ justify-content: center;
218
+ padding: var(--sp-1);
219
+ border: 1px solid transparent;
220
+ border-radius: var(--r-sm);
221
+ background: none;
222
+ color: inherit;
223
+ font-size: var(--fs-lg);
224
+ line-height: 1;
225
+ cursor: pointer;
226
+ }
227
+ .tab.on,
228
+ .cell.on {
229
+ border-color: var(--accent);
230
+ background: var(--bg-elevated-2);
231
+ }
232
+ .tab:hover,
233
+ .cell:hover,
234
+ .tab:focus-visible,
235
+ .cell:focus-visible {
236
+ background: var(--bg-elevated-2);
237
+ }
238
+ .tab:focus-visible,
239
+ .cell:focus-visible {
240
+ outline: var(--focus-ring);
241
+ outline-offset: var(--focus-ring-offset);
242
+ }
243
+ .grid {
244
+ display: grid;
245
+ grid-template-columns: repeat(var(--emoji-columns), 1fr);
246
+ gap: var(--sp-1);
247
+ max-height: 14rem;
248
+ overflow-y: auto;
249
+ }
250
+ .empty {
251
+ margin: var(--sp-2) 0;
252
+ color: var(--text-muted);
253
+ font-size: var(--fs-xs);
254
+ text-align: center;
255
+ }
256
+ </style>
@@ -0,0 +1,21 @@
1
+ import { type EmojiGroup } from '../../emoji';
2
+ type $$ComponentProps = {
3
+ /** The current glyph, highlighted in the grid. */
4
+ value?: string;
5
+ onselect: (emoji: string) => void;
6
+ /** Catalogue to browse; defaults to the kit's EN+FR keyword set. */
7
+ groups?: readonly EmojiGroup[];
8
+ columns?: number;
9
+ /** Accessible name of the trigger and of the glyph grid. */
10
+ label?: string;
11
+ searchLabel?: string;
12
+ emptyLabel?: string;
13
+ placement?: 'bottom-start' | 'bottom-end' | 'top-start' | 'top-end';
14
+ triggerClass?: string;
15
+ disabled?: boolean;
16
+ class?: string;
17
+ style?: string;
18
+ };
19
+ declare const EmojiPicker: import("svelte").Component<$$ComponentProps, {}, "">;
20
+ type EmojiPicker = ReturnType<typeof EmojiPicker>;
21
+ export default EmojiPicker;
@@ -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, {}, "">;
@@ -0,0 +1,18 @@
1
+ export interface EmojiEntry {
2
+ emoji: string;
3
+ /** Search keywords, lower-case, both languages. */
4
+ keys: string;
5
+ }
6
+ export interface EmojiGroup {
7
+ id: string;
8
+ /** Group glyph shown in the tab strip. */
9
+ icon: string;
10
+ /** Accessible name of the tab. Translate it by supplying your own `groups`. */
11
+ label: string;
12
+ entries: EmojiEntry[];
13
+ }
14
+ export declare const EMOJI_GROUPS: readonly EmojiGroup[];
15
+ /** Entries matching a free-text query across every group; an empty query
16
+ * returns nothing (the caller shows the grouped grid instead). Accent-
17
+ * insensitive so « éléphant » and « elephant » both find the elephant. */
18
+ export declare function searchEmoji(query: string, groups?: readonly EmojiGroup[]): EmojiEntry[];
package/dist/emoji.js ADDED
@@ -0,0 +1,286 @@
1
+ const e = (emoji, keys) => ({ emoji, keys });
2
+ export const EMOJI_GROUPS = [
3
+ {
4
+ id: 'smileys',
5
+ label: 'Smileys',
6
+ icon: '😀',
7
+ entries: [
8
+ e('😀', 'grin smile sourire content happy'),
9
+ e('😄', 'smile laugh rire joie'),
10
+ e('😂', 'joy tears laugh rire larmes'),
11
+ e('🙂', 'slight smile sourire'),
12
+ e('😉', 'wink clin oeil'),
13
+ e('😍', 'heart eyes love amour'),
14
+ e('😎', 'cool sunglasses lunettes'),
15
+ e('🤓', 'nerd glasses lunettes geek'),
16
+ e('🧐', 'monocle inspect examiner'),
17
+ e('🤔', 'think réfléchir penser'),
18
+ e('🤖', 'robot bot machine'),
19
+ e('👻', 'ghost fantôme'),
20
+ e('💀', 'skull crâne tête de mort'),
21
+ e('👽', 'alien extraterrestre'),
22
+ e('🤡', 'clown'),
23
+ e('😈', 'devil diable démon'),
24
+ e('🥳', 'party fête célébration'),
25
+ e('😴', 'sleep dormir zzz'),
26
+ e('🤯', 'mind blown explosion tête'),
27
+ e('🫠', 'melt fondre'),
28
+ e('🙃', 'upside down envers'),
29
+ e('😇', 'angel ange halo'),
30
+ e('🤠', 'cowboy'),
31
+ e('🥸', 'disguise déguisement moustache'),
32
+ e('😶‍🌫️', 'fog brouillard nuage'),
33
+ ],
34
+ },
35
+ {
36
+ id: 'people',
37
+ label: 'People',
38
+ icon: '🧑‍💻',
39
+ entries: [
40
+ e('🧑‍💻', 'technologist developer dev code ordinateur'),
41
+ e('👩‍💻', 'woman technologist développeuse'),
42
+ e('👨‍💻', 'man technologist développeur'),
43
+ e('🧑‍🔬', 'scientist science labo'),
44
+ e('🧑‍🚀', 'astronaut astronaute espace'),
45
+ e('🧑‍🎨', 'artist artiste peinture'),
46
+ e('🧑‍🍳', 'cook chef cuisine'),
47
+ e('🧑‍🏫', 'teacher prof enseignant'),
48
+ e('🧑‍⚕️', 'doctor médecin santé'),
49
+ e('🧑‍🚒', 'firefighter pompier'),
50
+ e('🕵️', 'detective détective enquête'),
51
+ e('🧙', 'wizard mage magicien sorcier'),
52
+ e('🧛', 'vampire'),
53
+ e('🧟', 'zombie'),
54
+ e('🦸', 'superhero héros super'),
55
+ e('🦹', 'villain méchant'),
56
+ e('🧞', 'genie génie'),
57
+ e('👑', 'crown couronne roi reine king queen'),
58
+ e('🎩', 'top hat chapeau haut de forme'),
59
+ e('🧢', 'cap casquette'),
60
+ e('👀', 'eyes yeux regarder'),
61
+ e('🧠', 'brain cerveau'),
62
+ e('💪', 'muscle biceps force'),
63
+ e('👋', 'wave hello bonjour salut'),
64
+ e('👍', 'thumbs up pouce ok'),
65
+ e('🙏', 'pray please merci thanks'),
66
+ ],
67
+ },
68
+ {
69
+ id: 'animals',
70
+ label: 'Animals',
71
+ icon: '🐙',
72
+ entries: [
73
+ e('🐙', 'octopus pieuvre poulpe'),
74
+ e('🦀', 'crab crabe rust'),
75
+ e('🐍', 'snake serpent python'),
76
+ e('🐧', 'penguin pingouin manchot linux'),
77
+ e('🐳', 'whale baleine docker'),
78
+ e('🦊', 'fox renard'),
79
+ e('🐺', 'wolf loup'),
80
+ e('🦁', 'lion'),
81
+ e('🐯', 'tiger tigre'),
82
+ e('🐻', 'bear ours'),
83
+ e('🐼', 'panda'),
84
+ e('🐨', 'koala'),
85
+ e('🐸', 'frog grenouille'),
86
+ e('🦉', 'owl hibou chouette'),
87
+ e('🦅', 'eagle aigle'),
88
+ e('🦜', 'parrot perroquet'),
89
+ e('🐝', 'bee abeille'),
90
+ e('🦋', 'butterfly papillon'),
91
+ e('🐢', 'turtle tortue'),
92
+ e('🦈', 'shark requin'),
93
+ e('🐬', 'dolphin dauphin'),
94
+ e('🦄', 'unicorn licorne'),
95
+ e('🐉', 'dragon'),
96
+ e('🦖', 'dinosaur dinosaure t-rex'),
97
+ e('🐱', 'cat chat'),
98
+ e('🐶', 'dog chien'),
99
+ e('🐭', 'mouse souris'),
100
+ e('🐹', 'hamster'),
101
+ e('🐰', 'rabbit lapin'),
102
+ e('🦔', 'hedgehog hérisson'),
103
+ e('🐘', 'elephant éléphant postgres'),
104
+ e('🦒', 'giraffe girafe'),
105
+ e('🐎', 'horse cheval'),
106
+ e('🐐', 'goat chèvre'),
107
+ e('🐔', 'chicken poule'),
108
+ e('🦆', 'duck canard'),
109
+ e('🐌', 'snail escargot lent'),
110
+ e('🕷️', 'spider araignée'),
111
+ ],
112
+ },
113
+ {
114
+ id: 'nature',
115
+ label: 'Nature',
116
+ icon: '🌿',
117
+ entries: [
118
+ e('🌿', 'herb plante feuille vert'),
119
+ e('🍀', 'clover trèfle chance luck'),
120
+ e('🌲', 'tree arbre sapin forêt'),
121
+ e('🌵', 'cactus'),
122
+ e('🌸', 'blossom fleur cerisier'),
123
+ e('🌻', 'sunflower tournesol'),
124
+ e('🌹', 'rose'),
125
+ e('🍄', 'mushroom champignon'),
126
+ e('🌍', 'earth terre monde globe'),
127
+ e('🌙', 'moon lune nuit'),
128
+ e('☀️', 'sun soleil jour'),
129
+ e('⭐', 'star étoile'),
130
+ e('🌟', 'glowing star étoile brillante'),
131
+ e('✨', 'sparkles étincelles magie'),
132
+ e('⚡', 'lightning éclair zap énergie'),
133
+ e('🔥', 'fire feu flamme hot'),
134
+ e('🌈', 'rainbow arc-en-ciel'),
135
+ e('☁️', 'cloud nuage'),
136
+ e('❄️', 'snowflake flocon neige froid'),
137
+ e('🌊', 'wave vague mer océan'),
138
+ e('🌋', 'volcano volcan'),
139
+ e('🪐', 'planet planète saturne'),
140
+ e('🚀', 'rocket fusée launch'),
141
+ e('🛸', 'ufo soucoupe ovni'),
142
+ e('☄️', 'comet comète'),
143
+ ],
144
+ },
145
+ {
146
+ id: 'objects',
147
+ label: 'Objects',
148
+ icon: '🔧',
149
+ entries: [
150
+ e('🔧', 'wrench clé outil tool'),
151
+ e('🔨', 'hammer marteau build'),
152
+ e('🛠️', 'tools outils'),
153
+ e('⚙️', 'gear engrenage réglages settings'),
154
+ e('🔑', 'key clé password'),
155
+ e('🔒', 'lock cadenas verrou sécurité'),
156
+ e('🛡️', 'shield bouclier protection'),
157
+ e('⚔️', 'swords épées'),
158
+ e('🧰', 'toolbox boîte à outils'),
159
+ e('🧪', 'test tube éprouvette labo'),
160
+ e('🔬', 'microscope'),
161
+ e('🔭', 'telescope télescope'),
162
+ e('💻', 'laptop ordinateur portable'),
163
+ e('🖥️', 'desktop écran ordinateur'),
164
+ e('⌨️', 'keyboard clavier'),
165
+ e('🖱️', 'mouse souris'),
166
+ e('📱', 'phone téléphone mobile'),
167
+ e('💾', 'floppy disquette save'),
168
+ e('🗄️', 'cabinet classeur base données database'),
169
+ e('📦', 'package paquet box colis'),
170
+ e('📚', 'books livres docs'),
171
+ e('📝', 'memo note écrire'),
172
+ e('📌', 'pin punaise épingle'),
173
+ e('🔖', 'bookmark marque-page'),
174
+ e('💡', 'bulb idée ampoule'),
175
+ e('🔦', 'flashlight lampe torche'),
176
+ e('🧭', 'compass boussole'),
177
+ e('⏰', 'alarm réveil horloge'),
178
+ e('⏱️', 'stopwatch chrono'),
179
+ e('🎯', 'target cible dart'),
180
+ e('🎲', 'dice dé hasard'),
181
+ e('🎮', 'game manette jeu'),
182
+ e('🎸', 'guitar guitare musique'),
183
+ e('🎵', 'music note musique'),
184
+ e('🎧', 'headphones casque'),
185
+ e('📷', 'camera appareil photo'),
186
+ e('🎬', 'clapper cinéma film'),
187
+ e('🧩', 'puzzle pièce'),
188
+ e('🪄', 'magic wand baguette magique'),
189
+ e('🧲', 'magnet aimant'),
190
+ e('💎', 'gem diamant'),
191
+ e('💰', 'money bag argent sac'),
192
+ e('🪙', 'coin pièce'),
193
+ e('🏆', 'trophy trophée coupe'),
194
+ e('🥇', 'medal médaille or'),
195
+ e('🎁', 'gift cadeau'),
196
+ e('🎈', 'balloon ballon'),
197
+ e('☕', 'coffee café'),
198
+ e('🍕', 'pizza'),
199
+ e('🍔', 'burger'),
200
+ e('🍩', 'donut beignet'),
201
+ e('🍪', 'cookie biscuit'),
202
+ e('🍺', 'beer bière'),
203
+ e('🍷', 'wine vin'),
204
+ e('🚑', 'ambulance secours urgence rescue'),
205
+ e('🚒', 'fire engine pompiers'),
206
+ e('🚗', 'car voiture'),
207
+ e('🚲', 'bike vélo'),
208
+ e('✈️', 'plane avion'),
209
+ e('🚂', 'train locomotive'),
210
+ e('⛵', 'sailboat voilier bateau'),
211
+ e('🏠', 'house maison home'),
212
+ e('🏭', 'factory usine'),
213
+ e('🏰', 'castle château'),
214
+ e('🗼', 'tower tour'),
215
+ ],
216
+ },
217
+ {
218
+ id: 'symbols',
219
+ label: 'Symbols',
220
+ icon: '❤️',
221
+ entries: [
222
+ e('❤️', 'heart coeur rouge love'),
223
+ e('🧡', 'orange heart coeur'),
224
+ e('💛', 'yellow heart coeur jaune'),
225
+ e('💚', 'green heart coeur vert'),
226
+ e('💙', 'blue heart coeur bleu'),
227
+ e('💜', 'purple heart coeur violet'),
228
+ e('🖤', 'black heart coeur noir'),
229
+ e('🤍', 'white heart coeur blanc'),
230
+ e('💯', 'hundred cent points'),
231
+ e('✅', 'check coche ok validé'),
232
+ e('❌', 'cross croix non erreur'),
233
+ e('⚠️', 'warning attention'),
234
+ e('🚫', 'forbidden interdit'),
235
+ e('♻️', 'recycle recyclage'),
236
+ e('🔴', 'red circle rond rouge'),
237
+ e('🟠', 'orange circle rond'),
238
+ e('🟡', 'yellow circle rond jaune'),
239
+ e('🟢', 'green circle rond vert'),
240
+ e('🔵', 'blue circle rond bleu'),
241
+ e('🟣', 'purple circle rond violet'),
242
+ e('⚫', 'black circle rond noir'),
243
+ e('⚪', 'white circle rond blanc'),
244
+ e('🟥', 'red square carré rouge'),
245
+ e('🟧', 'orange square carré'),
246
+ e('🟨', 'yellow square carré jaune'),
247
+ e('🟩', 'green square carré vert'),
248
+ e('🟦', 'blue square carré bleu'),
249
+ e('🟪', 'purple square carré violet'),
250
+ e('⬛', 'black square carré noir'),
251
+ e('⬜', 'white square carré blanc'),
252
+ e('🔶', 'orange diamond losange'),
253
+ e('🔷', 'blue diamond losange bleu'),
254
+ e('🔺', 'red triangle'),
255
+ e('♠️', 'spade pique'),
256
+ e('♣️', 'club trèfle'),
257
+ e('♥️', 'heart coeur cartes'),
258
+ e('♦️', 'diamond carreau'),
259
+ e('☮️', 'peace paix'),
260
+ e('☯️', 'yin yang'),
261
+ e('♾️', 'infinity infini'),
262
+ e('🔱', 'trident'),
263
+ e('⚓', 'anchor ancre'),
264
+ e('🏁', 'checkered flag drapeau arrivée'),
265
+ e('🚩', 'red flag drapeau rouge'),
266
+ e('🏴‍☠️', 'pirate flag drapeau pirate'),
267
+ e('🇫🇷', 'france drapeau flag'),
268
+ e('🇪🇺', 'europe eu drapeau flag'),
269
+ e('🇺🇸', 'usa états-unis flag drapeau'),
270
+ e('🇬🇧', 'uk royaume-uni flag drapeau'),
271
+ e('🇩🇪', 'germany allemagne flag drapeau'),
272
+ e('🇯🇵', 'japan japon flag drapeau'),
273
+ ],
274
+ },
275
+ ];
276
+ const fold = (s) => s.normalize('NFD').replace(/[̀-ͯ]/g, '').toLowerCase();
277
+ /** Entries matching a free-text query across every group; an empty query
278
+ * returns nothing (the caller shows the grouped grid instead). Accent-
279
+ * insensitive so « éléphant » and « elephant » both find the elephant. */
280
+ export function searchEmoji(query, groups = EMOJI_GROUPS) {
281
+ const q = fold(query.trim());
282
+ if (!q)
283
+ return [];
284
+ const words = q.split(/\s+/);
285
+ return groups.flatMap((g) => g.entries.filter((x) => words.every((w) => fold(x.keys).includes(w))));
286
+ }
package/dist/index.d.ts CHANGED
@@ -48,6 +48,7 @@ export { default as ConfirmModal } from './components/molecules/ConfirmModal.sve
48
48
  export { default as CopyButton } from './components/molecules/CopyButton.svelte';
49
49
  export { default as Drawer } from './components/molecules/Drawer.svelte';
50
50
  export { default as Dropzone } from './components/molecules/Dropzone.svelte';
51
+ export { default as EmojiPicker } from './components/molecules/EmojiPicker.svelte';
51
52
  export { default as EmptyState } from './components/molecules/EmptyState.svelte';
52
53
  export { default as Field } from './components/molecules/Field.svelte';
53
54
  export { default as Fieldset } from './components/molecules/Fieldset.svelte';
@@ -80,6 +81,7 @@ export { default as Truncate } from './components/molecules/Truncate.svelte';
80
81
  export { default as WorkingDir } from './components/molecules/WorkingDir.svelte';
81
82
  export { type Column, default as DataTable, type RowTone, } from './components/organisms/DataTable.svelte';
82
83
  export { default as FilterSearchBar } from './components/organisms/FilterSearchBar.svelte';
84
+ export { EMOJI_GROUPS, type EmojiEntry, type EmojiGroup, searchEmoji } from './emoji';
83
85
  export { FIELD_KEY, type FieldContext, getFieldContext, setFieldContext, warnUnlabelled, } from './field-context';
84
86
  export * as filterQuery from './query';
85
87
  export { type AndNode, activeToken, compilePredicate, defaultOperator, type ExprNode, type FieldDef, type FieldType, type FilterNode, filters, findField, freeText, type LeafNode, type NotNode, OPERATORS, type Operator, type OperatorId, type OrNode, operatorByCode, operatorById, operatorsFor, parse, type Query, type QueryNode, resolveValues, type Schema, type Suggestion, type SuggestKind, type SuggestState, serialize, serializeFilter, suggest, type TextNode, toSql, type ValueContext, type ValueOption, type ValueProvider, walk, } from './query';
@@ -88,6 +90,7 @@ export type { ControlSize } from './size';
88
90
  export { fontScale, SCALE_LEVELS, type ScaleLevel } from './stores/fontscale.svelte';
89
91
  export { type Mode, THEMES, theme } from './stores/theme.svelte';
90
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';
91
94
  export { formatTimestamp, localTimeZone, relativeTime, type TimeInput, type TimestampMode, } from './timestamp';
92
95
  export { canonicalTone, type Tone } from './tone';
93
96
  export { pathCandidates, type TruncateMode, type TruncateOptions, truncate } from './truncate';
package/dist/index.js CHANGED
@@ -54,6 +54,7 @@ export { default as ConfirmModal } from './components/molecules/ConfirmModal.sve
54
54
  export { default as CopyButton } from './components/molecules/CopyButton.svelte';
55
55
  export { default as Drawer } from './components/molecules/Drawer.svelte';
56
56
  export { default as Dropzone } from './components/molecules/Dropzone.svelte';
57
+ export { default as EmojiPicker } from './components/molecules/EmojiPicker.svelte';
57
58
  export { default as EmptyState } from './components/molecules/EmptyState.svelte';
58
59
  // ---- molecules ----
59
60
  export { default as Field } from './components/molecules/Field.svelte';
@@ -90,6 +91,7 @@ export { default as WorkingDir } from './components/molecules/WorkingDir.svelte'
90
91
  // ---- organisms ----
91
92
  export { default as DataTable, } from './components/organisms/DataTable.svelte';
92
93
  export { default as FilterSearchBar } from './components/organisms/FilterSearchBar.svelte';
94
+ export { EMOJI_GROUPS, searchEmoji } from './emoji';
93
95
  export { FIELD_KEY, getFieldContext, setFieldContext, warnUnlabelled, } from './field-context';
94
96
  // ---- query core (headless: schema / parser / AST / suggest / compilers) ----
95
97
  export * as filterQuery from './query';
@@ -99,6 +101,7 @@ export { fontScale, SCALE_LEVELS } from './stores/fontscale.svelte';
99
101
  // ---- stores / actions ----
100
102
  export { THEMES, theme } from './stores/theme.svelte';
101
103
  export { toasts, } from './stores/toast.svelte';
104
+ export { AUTO_THEME, chooseTheme, DEFAULT_THEME_PREFERENCE, pickerValue, preferenceFrom, resolveTheme, } from './theme-mode';
102
105
  export { formatTimestamp, localTimeZone, relativeTime, } from './timestamp';
103
106
  export { canonicalTone } from './tone';
104
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.45.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",