@dorsk/tsumikit 0.72.0 → 0.73.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.
Files changed (47) hide show
  1. package/README.md +21 -0
  2. package/dist/components/atoms/Icon.svelte +5 -0
  3. package/dist/components/atoms/Icon.svelte.d.ts +5 -0
  4. package/dist/components/atoms/TimeDisplay.svelte +98 -0
  5. package/dist/components/atoms/TimeDisplay.svelte.d.ts +24 -0
  6. package/dist/components/layouts/AppShell.svelte +3 -2
  7. package/dist/components/layouts/ResizablePanel.svelte +5 -4
  8. package/dist/components/molecules/Accordion.svelte +2 -1
  9. package/dist/components/molecules/AttachmentList.svelte +1 -0
  10. package/dist/components/molecules/Breadcrumb.svelte +7 -6
  11. package/dist/components/molecules/Breadcrumb.svelte.d.ts +2 -1
  12. package/dist/components/molecules/CapBar.svelte +3 -1
  13. package/dist/components/molecules/Composer.svelte +27 -5
  14. package/dist/components/molecules/Drawer.svelte +10 -1
  15. package/dist/components/molecules/Dropzone.svelte +2 -1
  16. package/dist/components/molecules/FilterInput.svelte +10 -1
  17. package/dist/components/molecules/Metric.svelte +28 -3
  18. package/dist/components/molecules/Modal.svelte +20 -4
  19. package/dist/components/molecules/Popover.svelte +7 -3
  20. package/dist/components/molecules/SectionHeader.svelte +29 -14
  21. package/dist/components/molecules/SeekSlider.svelte +370 -0
  22. package/dist/components/molecules/SeekSlider.svelte.d.ts +37 -0
  23. package/dist/components/molecules/SegmentedControl.svelte +10 -6
  24. package/dist/components/molecules/SelectButton.svelte +33 -22
  25. package/dist/components/molecules/Tabs.svelte +11 -5
  26. package/dist/components/molecules/Timestamp.svelte +1 -1
  27. package/dist/components/molecules/Toaster.svelte +70 -13
  28. package/dist/components/molecules/Tooltip.svelte +19 -10
  29. package/dist/components/molecules/VolumeSlider.svelte +367 -0
  30. package/dist/components/molecules/VolumeSlider.svelte.d.ts +30 -0
  31. package/dist/components/molecules/combobox-keyboard.js +2 -1
  32. package/dist/index.d.ts +4 -0
  33. package/dist/index.js +4 -0
  34. package/dist/media-time.d.ts +6 -0
  35. package/dist/media-time.js +25 -0
  36. package/dist/query/edit.js +1 -16
  37. package/dist/query/parser.js +68 -28
  38. package/dist/query/query.js +9 -5
  39. package/dist/query/quotes.d.ts +24 -0
  40. package/dist/query/quotes.js +78 -0
  41. package/dist/query/single.js +1 -3
  42. package/dist/query/suggest.js +25 -32
  43. package/dist/stores/fontscale.svelte.js +3 -2
  44. package/dist/stores/storage.d.ts +2 -0
  45. package/dist/stores/storage.js +16 -0
  46. package/dist/stores/theme.svelte.js +4 -7
  47. package/package.json +1 -1
@@ -0,0 +1,25 @@
1
+ /** Seconds as `m:ss`, or `h:mm:ss` once the value (or `ref`) reaches an hour. */
2
+ export function formatMediaTime(seconds, ref = seconds) {
3
+ if (!Number.isFinite(seconds))
4
+ return '--:--';
5
+ const neg = seconds < 0;
6
+ const total = Math.floor(Math.abs(seconds));
7
+ const h = Math.floor(total / 3600);
8
+ const m = Math.floor((total % 3600) / 60);
9
+ const s = total % 60;
10
+ const pad = (n) => String(n).padStart(2, '0');
11
+ const hours = h > 0 || (Number.isFinite(ref) && Math.abs(ref) >= 3600);
12
+ const body = hours ? `${h}:${pad(m)}:${pad(s)}` : `${m}:${pad(s)}`;
13
+ return neg ? `-${body}` : body;
14
+ }
15
+ /** True when a duration cannot be scrubbed: unknown, live, or empty. */
16
+ export function isLiveDuration(duration) {
17
+ return duration == null || !Number.isFinite(duration) || duration <= 0;
18
+ }
19
+ /** Spoken position: "1:02 of 23:45", or just the position when live. */
20
+ export function describeMediaTime(value, duration, format = formatMediaTime) {
21
+ if (isLiveDuration(duration))
22
+ return format(value);
23
+ const d = duration;
24
+ return `${format(value, d)} of ${format(d, d)}`;
25
+ }
@@ -8,6 +8,7 @@
8
8
  // step opens, so spaces land inside the quotes — the already-supported phrase
9
9
  // form — without any grammar change. UI-free and framework-agnostic.
10
10
  // ─────────────────────────────────────────────────────────────────────────
11
+ import { openQuoteAt } from './quotes';
11
12
  import { findField } from './schema';
12
13
  // A field name immediately followed by a string operator (`:` contains / `!:`
13
14
  // not-contains), anchored at the caret.
@@ -32,22 +33,6 @@ export function autoQuoteEdit(schema, value, pos) {
32
33
  return null;
33
34
  return { value: `${value.slice(0, pos)}""${value.slice(pos)}`, pos: pos + 1 };
34
35
  }
35
- // Scan from the start tracking quote state (spaces inside quotes are NOT
36
- // boundaries), so the caret's quote context is correct even for `"star wars"`.
37
- function openQuoteAt(value, pos) {
38
- let quote = null;
39
- for (let i = 0; i < pos; i++) {
40
- const c = value[i];
41
- if (quote) {
42
- if (c === quote)
43
- quote = null;
44
- }
45
- else if (isQuote(c)) {
46
- quote = c;
47
- }
48
- }
49
- return quote;
50
- }
51
36
  /** True when the caret sits inside an open quote. */
52
37
  export function insideQuoteAtCaret(value, pos) {
53
38
  return openQuoteAt(value, pos) !== null;
@@ -21,11 +21,16 @@
21
21
  // genre in (house, techno) → any of
22
22
  // year..2021..2024 → range (field..a..b) also field:a..b
23
23
  // free floating words → TextNode (full-text)
24
+ // "daft punk" → one TextNode phrase (quotes stripped)
25
+ //
26
+ // `..` only splits a value on fields that accept the range operator, so
27
+ // `cwd:../foo` stays one string. Inside quotes `\"` and `\\` are escapes.
24
28
  //
25
29
  // Error recovery: unmatched `)` is ignored, a missing `)` closes at end of
26
30
  // input, a dangling AND/OR/NOT with no operand collapses to its operand, and an
27
31
  // empty group yields nothing. The parser never throws.
28
32
  // ─────────────────────────────────────────────────────────────────────────
33
+ import { readQuoted } from './quotes';
29
34
  import { findField, operatorByCode, operatorsFor } from './schema';
30
35
  const IDENT = /[A-Za-z_][\w-]*/y;
31
36
  // Longest operator codes first so `>=` wins over `>`.
@@ -41,18 +46,16 @@ function skipWsInline(sc) {
41
46
  while (sc.i < sc.s.length && (sc.s[sc.i] === ' ' || sc.s[sc.i] === '\t'))
42
47
  sc.i++;
43
48
  }
44
- /** Read a value token: a "quoted string", or a bare run up to whitespace. */
45
- function readValue(sc) {
49
+ /**
50
+ * Read a value token: a "quoted string", or a bare run up to whitespace. With
51
+ * `range` the bare run also stops at `..` so `a..b` splits into two values.
52
+ */
53
+ function readValue(sc, range = false) {
46
54
  skipWsInline(sc);
47
55
  if (sc.s[sc.i] === '"' || sc.s[sc.i] === "'") {
48
- const quote = sc.s[sc.i];
49
- const start = ++sc.i;
50
- while (sc.i < sc.s.length && sc.s[sc.i] !== quote)
51
- sc.i++;
52
- const value = sc.s.slice(start, sc.i);
53
- if (sc.s[sc.i] === quote)
54
- sc.i++; // consume closing quote
55
- return { value, end: sc.i };
56
+ const { value, end } = readQuoted(sc.s, sc.i);
57
+ sc.i = end;
58
+ return { value, end };
56
59
  }
57
60
  const start = sc.i;
58
61
  while (sc.i < sc.s.length &&
@@ -60,8 +63,7 @@ function readValue(sc) {
60
63
  sc.s[sc.i] !== ',' &&
61
64
  sc.s[sc.i] !== ')' &&
62
65
  sc.s[sc.i] !== '(' &&
63
- // stop at a `..` range separator so `a..b` splits into two values
64
- !sc.s.startsWith('..', sc.i))
66
+ !(range && sc.s.startsWith('..', sc.i)))
65
67
  sc.i++;
66
68
  return { value: sc.s.slice(start, sc.i), end: sc.i };
67
69
  }
@@ -99,8 +101,41 @@ function peekKeyword(sc) {
99
101
  }
100
102
  export function parse(input, schema) {
101
103
  const sc = { s: input, i: 0 };
102
- const root = parseOr(sc, schema);
103
- return { root };
104
+ const pieces = [];
105
+ for (;;) {
106
+ const before = sc.i;
107
+ const node = parseOr(sc, schema);
108
+ if (node)
109
+ pieces.push(node);
110
+ skipWs(sc);
111
+ if (sc.i >= sc.s.length)
112
+ break;
113
+ if (sc.s[sc.i] === ')')
114
+ sc.i++;
115
+ else if (sc.i === before)
116
+ break;
117
+ }
118
+ return { root: joinAnd(pieces) };
119
+ }
120
+ /** AND the top-level pieces left around stray `)` as if the paren were not there. */
121
+ function joinAnd(pieces) {
122
+ const children = [];
123
+ for (const piece of pieces) {
124
+ for (const node of piece.kind === 'and' ? piece.children : [piece]) {
125
+ const last = children[children.length - 1];
126
+ if (node.kind === 'text' && last?.kind === 'text') {
127
+ last.value += ` ${node.value}`;
128
+ last.span[1] = node.span[1];
129
+ }
130
+ else
131
+ children.push(node);
132
+ }
133
+ }
134
+ if (children.length === 0)
135
+ return null;
136
+ if (children.length === 1)
137
+ return children[0];
138
+ return { kind: 'and', children };
104
139
  }
105
140
  // orExpr := andExpr ( OR andExpr )*
106
141
  function parseOr(sc, schema) {
@@ -218,8 +253,15 @@ function readLeaf(sc, schema) {
218
253
  return node;
219
254
  }
220
255
  }
221
- // Fallback: consume one word (up to whitespace or a paren) as free text.
256
+ // Fallback: a "quoted phrase", or one word (up to whitespace or a paren), as free text.
222
257
  const wStart = sc.i;
258
+ if (sc.s[sc.i] === '"') {
259
+ const { value, end } = readQuoted(sc.s, sc.i);
260
+ sc.i = end;
261
+ if (!value)
262
+ return null;
263
+ return { kind: 'text', value, span: [wStart, end] };
264
+ }
223
265
  while (sc.i < sc.s.length && !/\s/.test(sc.s[sc.i]) && sc.s[sc.i] !== '(' && sc.s[sc.i] !== ')')
224
266
  sc.i++;
225
267
  const word = sc.s.slice(wStart, sc.i);
@@ -270,21 +312,19 @@ function readFilter(sc, field, clauseStart, opPos, opMatch, inKeyword) {
270
312
  return null;
271
313
  }
272
314
  sc.i = opPos + opMatch.len;
273
- const first = readValue(sc);
315
+ const rangeOp = legal.find((o) => o.id === 'range');
316
+ const first = readValue(sc, !!rangeOp);
274
317
  // Range form: a..b
275
- if (sc.s.startsWith('..', sc.i)) {
318
+ if (rangeOp && sc.s.startsWith('..', sc.i)) {
276
319
  sc.i += 2;
277
- const second = readValue(sc);
278
- const rangeOp = operatorsFor(field).find((o) => o.id === 'range');
279
- if (rangeOp) {
280
- return {
281
- kind: 'filter',
282
- field: field.name,
283
- op: 'range',
284
- values: [first.value, second.value],
285
- span: [clauseStart, sc.i],
286
- };
287
- }
320
+ const second = readValue(sc, true);
321
+ return {
322
+ kind: 'filter',
323
+ field: field.name,
324
+ op: 'range',
325
+ values: [first.value, second.value],
326
+ span: [clauseStart, sc.i],
327
+ };
288
328
  }
289
329
  return {
290
330
  kind: 'filter',
@@ -5,9 +5,10 @@
5
5
  // (our fake in-memory backend). All three walk the boolean expression tree with
6
6
  // correct precedence / grouping / short-circuiting.
7
7
  // ─────────────────────────────────────────────────────────────────────────
8
+ import { quote, quoteIfNeeded } from './quotes';
8
9
  import { operatorById } from './schema';
9
- function quoteIfNeeded(v) {
10
- return /[\s,()]/.test(v) || v === '' ? `"${v}"` : v;
10
+ function quoteText(v) {
11
+ return /[()"]|^'/.test(v) ? quote(v) : v;
11
12
  }
12
13
  // ── Serialiser (AST → canonical textual query, round-trips through parse) ────
13
14
  /** Precedence rank: higher binds tighter. Leaves are atomic. */
@@ -33,7 +34,7 @@ function serializeBare(node) {
33
34
  case 'filter':
34
35
  return serializeFilter(node);
35
36
  case 'text':
36
- return node.value;
37
+ return quoteText(node.value);
37
38
  case 'not':
38
39
  // Child must bind at least as tight as NOT, else parenthesise.
39
40
  return `NOT ${serializeNode(node.child, 3)}`;
@@ -70,13 +71,16 @@ const SQL_OP = {
70
71
  function sqlLiteral(s) {
71
72
  return `'${s.replace(/'/g, "''")}'`;
72
73
  }
74
+ function sqlLike(v) {
75
+ return `${sqlLiteral(`%${v.replace(/[\\%_]/g, '\\$&')}%`)} ESCAPE '\\'`;
76
+ }
73
77
  function filterToSql(f) {
74
78
  const col = f.field;
75
79
  switch (f.op) {
76
80
  case 'contains':
77
- return `${col} ILIKE '%${f.values[0]}%'`;
81
+ return `${col} ILIKE ${sqlLike(f.values[0] ?? '')}`;
78
82
  case 'not_contains':
79
- return `${col} NOT ILIKE '%${f.values[0]}%'`;
83
+ return `${col} NOT ILIKE ${sqlLike(f.values[0] ?? '')}`;
80
84
  case 'in':
81
85
  return `${col} IN (${f.values.map(sqlLiteral).join(', ')})`;
82
86
  case 'range':
@@ -0,0 +1,24 @@
1
+ export declare const QUOTES: Set<string>;
2
+ export declare function isBoundary(c: string | undefined): boolean;
3
+ /** True when a quote character at index `i` of `s` opens a quoted string. */
4
+ export declare function quoteOpensAt(s: string, i: number): boolean;
5
+ /**
6
+ * Walk `s` up to `end` tracking quote state; returns the quote character still
7
+ * open at `end`, or null. Escaped quotes do not close the string.
8
+ */
9
+ export declare function openQuoteAt(s: string, end: number): string | null;
10
+ /**
11
+ * Read a quoted string starting at the opening quote `s[start]`. Returns the
12
+ * unescaped value and the index just past the closing quote (or the end of
13
+ * input when it is missing).
14
+ */
15
+ export declare function readQuoted(s: string, start: number): {
16
+ value: string;
17
+ end: number;
18
+ };
19
+ /** Wrap `v` in double quotes, escaping what the reader needs to see escaped. */
20
+ export declare function quote(v: string): string;
21
+ /** Quote a value unless it survives the parser bare. */
22
+ export declare function quoteIfNeeded(v: string): string;
23
+ /** Strip surrounding quotes (a missing closing quote is tolerated) and unescape. */
24
+ export declare function unquote(v: string): string;
@@ -0,0 +1,78 @@
1
+ // Quoting rules shared by the parser, the serialiser and the editor helpers.
2
+ //
3
+ // A quote opens a string only where a value may start: at the start of a token
4
+ // or right after an operator (`title:"…"`). `"` also opens a free-text phrase at
5
+ // a token start; `'` does not, so `don't` and `'tis` stay plain words. Inside a
6
+ // quoted string `\"` (or `\'` under `'`) and `\\` are escapes; any other
7
+ // backslash is literal, so `"C:\foo"` keeps its backslash.
8
+ export const QUOTES = new Set(['"', "'"]);
9
+ const OP_TAIL = new Set([':', '=', '<', '>']);
10
+ export function isBoundary(c) {
11
+ return c === undefined || /\s/.test(c) || c === '(' || c === ')' || c === ',';
12
+ }
13
+ /** True when a quote character at index `i` of `s` opens a quoted string. */
14
+ export function quoteOpensAt(s, i) {
15
+ const c = s[i];
16
+ if (!QUOTES.has(c))
17
+ return false;
18
+ const prev = s[i - 1];
19
+ if (OP_TAIL.has(prev))
20
+ return true;
21
+ return c === '"' && isBoundary(prev);
22
+ }
23
+ /**
24
+ * Walk `s` up to `end` tracking quote state; returns the quote character still
25
+ * open at `end`, or null. Escaped quotes do not close the string.
26
+ */
27
+ export function openQuoteAt(s, end) {
28
+ let quote = null;
29
+ for (let i = 0; i < end; i++) {
30
+ const c = s[i];
31
+ if (quote) {
32
+ if (c === '\\' && (s[i + 1] === quote || s[i + 1] === '\\'))
33
+ i++;
34
+ else if (c === quote)
35
+ quote = null;
36
+ }
37
+ else if (quoteOpensAt(s, i)) {
38
+ quote = c;
39
+ }
40
+ }
41
+ return quote;
42
+ }
43
+ /**
44
+ * Read a quoted string starting at the opening quote `s[start]`. Returns the
45
+ * unescaped value and the index just past the closing quote (or the end of
46
+ * input when it is missing).
47
+ */
48
+ export function readQuoted(s, start) {
49
+ const quote = s[start];
50
+ let value = '';
51
+ let i = start + 1;
52
+ while (i < s.length && s[i] !== quote) {
53
+ if (s[i] === '\\' && (s[i + 1] === quote || s[i + 1] === '\\'))
54
+ i++;
55
+ value += s[i];
56
+ i++;
57
+ }
58
+ if (s[i] === quote)
59
+ i++;
60
+ return { value, end: i };
61
+ }
62
+ /** Wrap `v` in double quotes, escaping what the reader needs to see escaped. */
63
+ export function quote(v) {
64
+ const full = /\\\\|\\"|\\$/.test(v);
65
+ const body = (full ? v.replace(/\\/g, '\\\\') : v).replace(/"/g, '\\"');
66
+ return `"${body}"`;
67
+ }
68
+ const NEEDS_QUOTE = /[\s,()"]|\.\.|^'/;
69
+ /** Quote a value unless it survives the parser bare. */
70
+ export function quoteIfNeeded(v) {
71
+ return v === '' || NEEDS_QUOTE.test(v) ? quote(v) : v;
72
+ }
73
+ /** Strip surrounding quotes (a missing closing quote is tolerated) and unescape. */
74
+ export function unquote(v) {
75
+ if (!QUOTES.has(v[0]))
76
+ return v;
77
+ return readQuoted(v, 0).value;
78
+ }
@@ -4,10 +4,8 @@
4
4
  // the field's own provider, and the equivalent query is synthesised only for
5
5
  // the `inline` / `below` snippets and the `onchange` AST.
6
6
  // ─────────────────────────────────────────────────────────────────────────
7
+ import { quoteIfNeeded } from './quotes';
7
8
  import { defaultOperator, findField, resolveValues } from './schema';
8
- function quoteIfNeeded(v) {
9
- return /[\s,()]/.test(v) ? `"${v}"` : v;
10
- }
11
9
  /**
12
10
  * The textual query a bare `value` stands for, using `key`'s default operator —
13
11
  * `"/srv/my app"` under key `cwd` becomes `cwd:"/srv/my app"`. Empty values (and
@@ -6,6 +6,7 @@
6
6
  //
7
7
  // This is pure logic, deliberately UI-free so the Svelte organism stays dumb.
8
8
  // ─────────────────────────────────────────────────────────────────────────
9
+ import { quoteIfNeeded, quoteOpensAt, unquote } from './quotes';
9
10
  import { findField, operatorsFor, resolveValues, } from './schema';
10
11
  // Longest first so `>=`/`!:` win over `>`/`:`.
11
12
  const OP_CODES = ['!:', '!=', '>=', '<=', ':', '=', '>', '<'];
@@ -15,46 +16,44 @@ const OP_CODES = ['!:', '!=', '>=', '<=', ':', '=', '>', '<'];
15
16
  * (`album:"ok computer`) stays one token and keeps the caret in the value step
16
17
  * rather than collapsing to an empty token (which would wrongly re-offer
17
18
  * fields). Breaking on parens means `(artist:` offers operators for a clean
18
- * `artist`, and autocomplete fires at the right spot inside a group.
19
+ * `artist`, and autocomplete fires at the right spot inside a group. A quote
20
+ * only opens where a value can start, so `don't` is a plain word.
19
21
  */
20
- function isBoundary(c) {
22
+ function isTokenBoundary(c) {
21
23
  return /\s/.test(c) || c === '(' || c === ')';
22
24
  }
23
25
  export function activeToken(s, pos) {
24
- // Walk from the start so we can track quote state; the last unquoted
25
- // boundary before the caret is the token start.
26
26
  let start = 0;
27
- let inQuote = false;
28
- let q = '';
29
- for (let i = 0; i < pos; i++) {
27
+ let q = null;
28
+ const step = (i) => {
30
29
  const c = s[i];
31
- if (inQuote) {
30
+ if (q) {
31
+ if (c === '\\' && (s[i + 1] === q || s[i + 1] === '\\'))
32
+ return i + 2;
32
33
  if (c === q)
33
- inQuote = false;
34
+ q = null;
34
35
  }
35
- else if (c === '"' || c === "'") {
36
- inQuote = true;
36
+ else if (quoteOpensAt(s, i)) {
37
37
  q = c;
38
38
  }
39
- else if (isBoundary(c)) {
39
+ else if (isTokenBoundary(c)) {
40
40
  start = i + 1;
41
41
  }
42
- }
42
+ return i + 1;
43
+ };
44
+ // Walk from the start so we can track quote state; the last unquoted
45
+ // boundary before the caret is the token start.
46
+ let i = 0;
47
+ while (i < pos)
48
+ i = step(i);
43
49
  // Extend forward through the rest of the token, still respecting quotes.
44
50
  let end = pos;
45
- for (let i = pos; i < s.length; i++) {
46
- const c = s[i];
47
- if (!inQuote && isBoundary(c))
51
+ i = pos;
52
+ while (i < s.length) {
53
+ if (!q && isTokenBoundary(s[i]))
48
54
  break;
49
- if (inQuote) {
50
- if (c === q)
51
- inQuote = false;
52
- }
53
- else if (c === '"' || c === "'") {
54
- inQuote = true;
55
- q = c;
56
- }
57
- end = i + 1;
55
+ i = step(i);
56
+ end = i;
58
57
  }
59
58
  return { start, end, raw: s.slice(start, end) };
60
59
  }
@@ -83,12 +82,6 @@ function splitToken(raw) {
83
82
  value: raw.slice(best + bestCode.length),
84
83
  };
85
84
  }
86
- function unquote(v) {
87
- return v.replace(/^["']|["']$/g, '');
88
- }
89
- function quoteIfNeeded(v) {
90
- return /[\s,()]/.test(v) ? `"${v}"` : v;
91
- }
92
85
  function fieldSuggestions(schema, frag, span) {
93
86
  const q = frag.toLowerCase();
94
87
  const items = schema.fields
@@ -125,7 +118,7 @@ function operatorSuggestions(field, span) {
125
118
  }
126
119
  function buildValueState(field, opCode, span, options) {
127
120
  const items = options.map((o) => {
128
- const insert = `${field.name}${opCode}${quoteIfNeeded(o.value)}`;
121
+ const insert = `${field.name}${opCode}${o.value === '' ? '' : quoteIfNeeded(o.value)}`;
129
122
  return {
130
123
  label: o.label,
131
124
  hint: o.hint,
@@ -1,4 +1,5 @@
1
1
  import { browser } from '../env';
2
+ import { readStorage, writeStorage } from './storage';
2
3
  // OPT-IN. Most apps don't need this: the kit is rem-based and never resets the
3
4
  // root font-size, so the user's browser/OS font-size preference and browser
4
5
  // zoom already scale the UI correctly. Reach for this control only in
@@ -28,7 +29,7 @@ class FontScale {
28
29
  current = $derived(levelById(this.levelId).value);
29
30
  constructor() {
30
31
  if (browser) {
31
- const raw = localStorage.getItem(KEY);
32
+ const raw = readStorage(KEY);
32
33
  if (raw && SCALE_LEVELS.some((l) => l.id === raw))
33
34
  this.levelId = raw;
34
35
  this.apply();
@@ -42,7 +43,7 @@ class FontScale {
42
43
  set(id) {
43
44
  this.levelId = SCALE_LEVELS.some((l) => l.id === id) ? id : DEFAULT_LEVEL;
44
45
  if (browser)
45
- localStorage.setItem(KEY, this.levelId);
46
+ writeStorage(KEY, this.levelId);
46
47
  this.apply();
47
48
  }
48
49
  }
@@ -0,0 +1,2 @@
1
+ export declare function readStorage(key: string): string | null;
2
+ export declare function writeStorage(key: string, value: string): void;
@@ -0,0 +1,16 @@
1
+ // localStorage throws SecurityError when site data is blocked (Safari private
2
+ // mode, sandboxed or third-party iframes); callers fall back to their defaults.
3
+ export function readStorage(key) {
4
+ try {
5
+ return localStorage.getItem(key);
6
+ }
7
+ catch {
8
+ return null;
9
+ }
10
+ }
11
+ export function writeStorage(key, value) {
12
+ try {
13
+ localStorage.setItem(key, value);
14
+ }
15
+ catch { }
16
+ }
@@ -1,5 +1,6 @@
1
1
  import { browser } from '../env';
2
2
  import { AUTO_THEME, chooseTheme, DEFAULT_THEME_PREFERENCE, preferenceFrom, resolveTheme, } from '../theme-mode';
3
+ import { readStorage, writeStorage } from './storage';
3
4
  // Theme registry. Built-ins live in THEMES (+ one [data-theme="id"] block in
4
5
  // styles/themes.css); consumers append their own with theme.register() and ship
5
6
  // the matching block in their own stylesheet. `themeColor` drives the mobile
@@ -87,7 +88,7 @@ class Theme {
87
88
  this.systemDark = e.matches;
88
89
  this.paint();
89
90
  });
90
- this.saved = localStorage.getItem(KEY);
91
+ this.saved = readStorage(KEY);
91
92
  this.resolve();
92
93
  }
93
94
  }
@@ -151,12 +152,8 @@ class Theme {
151
152
  }
152
153
  persist() {
153
154
  this.saved = JSON.stringify(this.pref);
154
- if (browser) {
155
- try {
156
- localStorage.setItem(KEY, this.saved);
157
- }
158
- catch { }
159
- }
155
+ if (browser)
156
+ writeStorage(KEY, this.saved);
160
157
  this.onchange?.(this.pref);
161
158
  }
162
159
  apply() {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dorsk/tsumikit",
3
- "version": "0.72.0",
3
+ "version": "0.73.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",