@astronox/core 0.2.1 → 0.2.3

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 (88) hide show
  1. package/package.json +7 -1
  2. package/src/admin/cache.ts +217 -0
  3. package/src/admin/contracts.ts +28 -0
  4. package/src/admin/index.ts +3 -0
  5. package/src/admin/module.ts +28 -0
  6. package/src/auth/contracts.ts +53 -0
  7. package/src/auth/firebase/client.ts +83 -0
  8. package/src/auth/firebase/contracts.ts +8 -0
  9. package/src/auth/firebase/index.ts +1 -0
  10. package/src/auth/index.ts +2 -0
  11. package/src/auth/module.ts +99 -0
  12. package/src/client/formSubmission.ts +177 -0
  13. package/src/client/index.ts +4 -0
  14. package/src/client/preferences.ts +400 -0
  15. package/src/components/actions/Button/Button.astro +42 -6
  16. package/src/components/actions/Button/Button.css +19 -0
  17. package/src/components/actions/IdentityProviderAction/IdentityProviderAction.astro +35 -0
  18. package/src/components/actions/ScrollToTop/ScrollToTop.css +11 -0
  19. package/src/components/content/CardBody/CardBody.astro +3 -0
  20. package/src/components/content/CardBody/CardBody.css +16 -2
  21. package/src/components/content/CardFooter/CardFooter.astro +9 -0
  22. package/src/components/content/CardFooter/CardFooter.css +45 -3
  23. package/src/components/content/CardLink/CardLink.astro +12 -3
  24. package/src/components/content/CardLink/CardLink.css +32 -0
  25. package/src/components/content/MarkdownContent/MarkdownContent.css +14 -4
  26. package/src/components/content/MarkdownContent/MarkdownInline.astro +1 -1
  27. package/src/components/content/MarkdownContent/contracts.ts +1 -1
  28. package/src/components/content/MarkdownContent/markdownContentRenderer.ts +552 -0
  29. package/src/components/data-display/TextPair/TextPair.css +8 -2
  30. package/src/components/feedback/PreferenceBootstrap/PreferenceBootstrap.astro +403 -0
  31. package/src/components/forms/Checkbox/Checkbox.astro +6 -0
  32. package/src/components/forms/ChoiceGroup/ChoiceGroup.astro +57 -0
  33. package/src/components/forms/ChoiceGroup/ChoiceGroup.css +108 -0
  34. package/src/components/forms/ChoiceGroup/contracts.ts +9 -0
  35. package/src/components/forms/DateTimeInput/DateTimeInput.astro +14 -0
  36. package/src/components/forms/PreparedFormBootstrap/PreparedFormBootstrap.astro +203 -0
  37. package/src/components/forms/PreparedFormBootstrap/contracts.ts +15 -0
  38. package/src/components/forms/TextInput/TextInput.astro +7 -1
  39. package/src/components/forms/Textarea/Textarea.astro +35 -0
  40. package/src/components/forms/Textarea/Textarea.css +62 -0
  41. package/src/components/icons/BackIcon.astro +26 -0
  42. package/src/components/icons/DeleteIcon.astro +22 -0
  43. package/src/components/icons/StatusIndicatorIcon.astro +16 -0
  44. package/src/components/index.ts +18 -0
  45. package/src/components/media/CanvasFrame/CanvasFrame.astro +1 -17
  46. package/src/components/media/EmbedFrame/EmbedFrame.astro +1 -19
  47. package/src/components/media/EmbedFrame/EmbedFrame.css +0 -2
  48. package/src/components/navigation/Breadcrumbs/Breadcrumbs.astro +5 -1
  49. package/src/components/navigation/Breadcrumbs/Breadcrumbs.css +12 -17
  50. package/src/components/navigation/Footer/Footer.astro +102 -0
  51. package/src/components/navigation/Footer/Footer.css +155 -0
  52. package/src/components/navigation/Footer/contracts.ts +17 -0
  53. package/src/components/navigation/SubNavigation/SubNavigation.css +0 -5
  54. package/src/composition/Grid/Grid.css +16 -0
  55. package/src/composition/SurfaceGroup/SurfaceGroup.css +21 -7
  56. package/src/composition/SurfaceGroup/stickySurfaceGroupController.ts +3 -0
  57. package/src/data/firestore/client/contracts.ts +78 -0
  58. package/src/data/firestore/client/firebase.ts +56 -0
  59. package/src/data/firestore/client/index.ts +3 -0
  60. package/src/data/firestore/client/repository.ts +111 -0
  61. package/src/display/contracts.ts +44 -1
  62. package/src/display/index.ts +1 -0
  63. package/src/display/tableProgressive.ts +62 -0
  64. package/src/layouts/BaseLayout.astro +8 -1
  65. package/src/patterns/Combobox/Combobox.astro +23 -0
  66. package/src/patterns/Combobox/comboboxController.ts +69 -0
  67. package/src/patterns/Fullscreen/FullscreenControl.astro +4 -14
  68. package/src/patterns/Fullscreen/FullscreenControl.css +0 -26
  69. package/src/patterns/Modal/Modal.astro +1 -1
  70. package/src/patterns/Modal/Modal.css +38 -1
  71. package/src/patterns/Table/Table.astro +122 -7
  72. package/src/patterns/Table/TableBody.astro +8 -0
  73. package/src/patterns/Table/TableBody.css +3 -3
  74. package/src/patterns/Table/TableCell.css +11 -1
  75. package/src/patterns/Table/TableCellValue.astro +31 -0
  76. package/src/patterns/Table/TableFooter.astro +3 -1
  77. package/src/patterns/Table/TableHeader.astro +1 -0
  78. package/src/patterns/Table/TablePagination.astro +63 -47
  79. package/src/patterns/Table/TableSkeleton.astro +18 -22
  80. package/src/patterns/Table/TableSkeleton.css +30 -5
  81. package/src/patterns/Table/tableClientController.ts +253 -0
  82. package/src/patterns/Table/tableFilterController.ts +23 -31
  83. package/src/patterns/Table/tableProgressiveController.ts +615 -227
  84. package/src/patterns/Table/tableSortController.ts +25 -7
  85. package/src/styles/base.css +24 -7
  86. package/src/styles/interaction.css +16 -0
  87. package/src/styles/style.css +3 -0
  88. package/src/styles/tokens.css +5 -0
@@ -0,0 +1,177 @@
1
+ export type FormSubmissionMode = 'create' | 'edit';
2
+
3
+ export type FormSubmissionState = {
4
+ dirty: boolean;
5
+ pending: boolean;
6
+ };
7
+
8
+ export type FormSubmissionResult<TValue> = {
9
+ status: 'saved' | 'unchanged';
10
+ value: TValue;
11
+ };
12
+
13
+ export type FormSubmissionControllerConfig<TValue> = {
14
+ form: HTMLFormElement;
15
+ mode: FormSubmissionMode;
16
+ readValue(): TValue;
17
+ save(value: TValue): Promise<void>;
18
+ onSuccess(result: FormSubmissionResult<TValue>): void | Promise<void>;
19
+ onError?(reason: unknown): void;
20
+ onStateChange?(state: FormSubmissionState): void;
21
+ changeEvents?: readonly string[];
22
+ equals?(left: TValue, right: TValue): boolean;
23
+ };
24
+
25
+ export type FormSubmissionController = {
26
+ destroy(): void;
27
+ refresh(): void;
28
+ };
29
+
30
+ const defaultChangeEvents = ['input', 'change'] as const;
31
+
32
+ export function createFormSubmissionController<TValue>({
33
+ form,
34
+ mode,
35
+ readValue,
36
+ save,
37
+ onSuccess,
38
+ onError,
39
+ onStateChange,
40
+ changeEvents = defaultChangeEvents,
41
+ equals = areStructurallyEqual,
42
+ }: FormSubmissionControllerConfig<TValue>): FormSubmissionController {
43
+ let baseline!: TValue;
44
+ if (mode === 'edit') baseline = readValue();
45
+ let dirty = mode === 'create';
46
+ let pending = false;
47
+ let destroyed = false;
48
+
49
+ const publishState = (): void => {
50
+ onStateChange?.({ dirty, pending });
51
+ };
52
+
53
+ const refresh = (): void => {
54
+ if (destroyed || pending) return;
55
+
56
+ let nextDirty = true;
57
+ if (mode === 'edit') {
58
+ try {
59
+ nextDirty = !equals(baseline, readValue());
60
+ } catch {
61
+ // An incomplete value is still a change; native validation owns errors.
62
+ }
63
+ }
64
+
65
+ if (nextDirty === dirty) return;
66
+ dirty = nextDirty;
67
+ publishState();
68
+ };
69
+
70
+ const handleChange = (): void => refresh();
71
+
72
+ const handleSubmit = async (event: SubmitEvent): Promise<void> => {
73
+ event.preventDefault();
74
+ if (pending || !form.reportValidity()) return;
75
+
76
+ let value: TValue;
77
+ try {
78
+ value = readValue();
79
+ } catch (reason) {
80
+ onError?.(reason);
81
+ return;
82
+ }
83
+
84
+ if (mode === 'edit' && equals(baseline, value)) {
85
+ await onSuccess({ status: 'unchanged', value });
86
+ return;
87
+ }
88
+
89
+ pending = true;
90
+ publishState();
91
+
92
+ try {
93
+ await save(value);
94
+ baseline = value;
95
+ dirty = false;
96
+ pending = false;
97
+ publishState();
98
+ await onSuccess({ status: 'saved', value });
99
+ } catch (reason) {
100
+ pending = false;
101
+ refresh();
102
+ publishState();
103
+ onError?.(reason);
104
+ }
105
+ };
106
+
107
+ for (const eventName of new Set(changeEvents)) {
108
+ form.addEventListener(eventName, handleChange);
109
+ }
110
+ form.addEventListener('submit', handleSubmit);
111
+ publishState();
112
+
113
+ return {
114
+ destroy(): void {
115
+ if (destroyed) return;
116
+ destroyed = true;
117
+ for (const eventName of new Set(changeEvents)) {
118
+ form.removeEventListener(eventName, handleChange);
119
+ }
120
+ form.removeEventListener('submit', handleSubmit);
121
+ },
122
+ refresh,
123
+ };
124
+ }
125
+
126
+ function areStructurallyEqual<TValue>(left: TValue, right: TValue): boolean {
127
+ return compareValues(left, right, new WeakMap<object, object>());
128
+ }
129
+
130
+ function compareValues(
131
+ left: unknown,
132
+ right: unknown,
133
+ visited: WeakMap<object, object>,
134
+ ): boolean {
135
+ if (Object.is(left, right)) return true;
136
+ if (left instanceof Date || right instanceof Date) {
137
+ return (
138
+ left instanceof Date &&
139
+ right instanceof Date &&
140
+ left.getTime() === right.getTime()
141
+ );
142
+ }
143
+ if (!left || !right || typeof left !== 'object' || typeof right !== 'object') {
144
+ return false;
145
+ }
146
+
147
+ const knownRight = visited.get(left);
148
+ if (knownRight) return knownRight === right;
149
+ visited.set(left, right);
150
+
151
+ if (Array.isArray(left) || Array.isArray(right)) {
152
+ if (!Array.isArray(left) || !Array.isArray(right)) return false;
153
+ return (
154
+ left.length === right.length &&
155
+ left.every((value, index) => compareValues(value, right[index], visited))
156
+ );
157
+ }
158
+
159
+ const leftPrototype = Object.getPrototypeOf(left);
160
+ const rightPrototype = Object.getPrototypeOf(right);
161
+ if (leftPrototype !== rightPrototype) return false;
162
+
163
+ const leftKeys = Object.keys(left).sort();
164
+ const rightKeys = Object.keys(right).sort();
165
+ if (
166
+ leftKeys.length !== rightKeys.length ||
167
+ leftKeys.some((key, index) => key !== rightKeys[index])
168
+ ) {
169
+ return false;
170
+ }
171
+
172
+ const leftRecord = left as Record<string, unknown>;
173
+ const rightRecord = right as Record<string, unknown>;
174
+ return leftKeys.every((key) =>
175
+ compareValues(leftRecord[key], rightRecord[key], visited),
176
+ );
177
+ }
@@ -1,3 +1,7 @@
1
1
  export * from './clipboard';
2
2
  export * from './focus';
3
+ export * from './formSubmission';
4
+ export * from './preferences';
3
5
  export * from './scroll';
6
+ export * from '../components/content/MarkdownContent/markdownContentRenderer';
7
+ export * from '../patterns/Table/tableClientController';
@@ -0,0 +1,400 @@
1
+ export type PreferenceScalar = boolean | number | string;
2
+
3
+ export type PreferenceSchema =
4
+ | { type: 'boolean' }
5
+ | {
6
+ type: 'integer';
7
+ min?: number;
8
+ max?: number;
9
+ values?: readonly number[];
10
+ }
11
+ | { type: 'number'; min?: number; max?: number }
12
+ | { type: 'string'; values?: readonly string[] }
13
+ | {
14
+ type: 'object';
15
+ properties: Readonly<Record<string, PreferenceSchema>>;
16
+ }
17
+ | { type: 'array'; items: PreferenceSchema };
18
+
19
+ export type PreferenceMigration = {
20
+ fromVersion: number;
21
+ fromKey?: string;
22
+ rename?: Readonly<Record<string, string>>;
23
+ defaults?: Readonly<Record<string, unknown>>;
24
+ };
25
+
26
+ export type PreferenceDefinition<T> = {
27
+ id: string;
28
+ key: string;
29
+ version: number;
30
+ defaultValue: T;
31
+ schema: PreferenceSchema;
32
+ migrations?: readonly PreferenceMigration[];
33
+ };
34
+
35
+ export type AnyPreferenceDefinition = PreferenceDefinition<unknown>;
36
+
37
+ export type PreferenceBinding = {
38
+ id: string;
39
+ path?: string;
40
+ };
41
+
42
+ export type PreferenceCondition = PreferenceBinding & {
43
+ equals: PreferenceScalar;
44
+ };
45
+
46
+ export type PreferenceNumberBounds = {
47
+ min: number;
48
+ max: number;
49
+ when?: PreferenceCondition;
50
+ };
51
+
52
+ export type PreferenceRegistry = {
53
+ get<T>(definition: PreferenceDefinition<T>): T;
54
+ set<T>(definition: PreferenceDefinition<T>, value: T): void;
55
+ reset<T>(definition: PreferenceDefinition<T>): void;
56
+ subscribe<T>(
57
+ definition: PreferenceDefinition<T>,
58
+ listener: (value: T) => void,
59
+ ): () => void;
60
+ destroy(): void;
61
+ };
62
+
63
+ export type PreferenceRegistryConfig = {
64
+ definitions: readonly AnyPreferenceDefinition[];
65
+ };
66
+
67
+ type PreferenceSnapshot = {
68
+ values: Record<string, unknown>;
69
+ rawValues: Record<string, string>;
70
+ };
71
+
72
+ type PreferenceChangeDetail = {
73
+ id: string;
74
+ source: string;
75
+ value: unknown;
76
+ };
77
+
78
+ const snapshotGlobalKey = '__astronoxPreferenceSnapshot';
79
+ const preferenceChangeEvent = 'astronox:preference-change';
80
+ let registrySequence = 0;
81
+
82
+ export function definePreference<T>(
83
+ definition: PreferenceDefinition<T>,
84
+ ): PreferenceDefinition<T> {
85
+ assertDefinition(definition);
86
+ if (!matchesPreferenceSchema(definition.defaultValue, definition.schema)) {
87
+ throw new TypeError(`Invalid default value for preference ${definition.id}`);
88
+ }
89
+ return definition;
90
+ }
91
+
92
+ export function createPreferenceRegistry({
93
+ definitions,
94
+ }: PreferenceRegistryConfig): PreferenceRegistry {
95
+ const source = `registry:${++registrySequence}`;
96
+ const definitionsById = new Map<string, AnyPreferenceDefinition>();
97
+ const definitionsByStorageKey = new Map<string, AnyPreferenceDefinition>();
98
+ for (const definition of definitions) {
99
+ assertDefinition(definition);
100
+ if (definitionsById.has(definition.id)) {
101
+ throw new Error(`Duplicate preference definition: ${definition.id}`);
102
+ }
103
+ const storageKey = getPreferenceStorageKey(definition);
104
+ if (definitionsByStorageKey.has(storageKey)) {
105
+ throw new Error(`Duplicate preference storage key: ${storageKey}`);
106
+ }
107
+ definitionsById.set(definition.id, definition);
108
+ definitionsByStorageKey.set(storageKey, definition);
109
+ }
110
+
111
+ const snapshot = resolveSnapshot(definitions);
112
+ const listeners = new Map<string, Set<(value: unknown) => void>>();
113
+
114
+ const requireDefinition = <T>(
115
+ definition: PreferenceDefinition<T>,
116
+ ): PreferenceDefinition<T> => {
117
+ if (!definitionsById.has(definition.id)) {
118
+ throw new Error(
119
+ `Preference ${definition.id} does not belong to this registry`,
120
+ );
121
+ }
122
+ return definition;
123
+ };
124
+
125
+ const notify = (id: string, value: unknown): void => {
126
+ for (const listener of listeners.get(id) ?? []) listener(value);
127
+ };
128
+
129
+ const apply = (
130
+ definition: AnyPreferenceDefinition,
131
+ value: unknown,
132
+ options: {
133
+ persist: boolean;
134
+ announce: boolean;
135
+ notifyIfEqual?: boolean;
136
+ },
137
+ ): void => {
138
+ if (!matchesPreferenceSchema(value, definition.schema)) return;
139
+ const raw = JSON.stringify(value);
140
+ if (snapshot.rawValues[definition.id] === raw) {
141
+ if (options.notifyIfEqual) {
142
+ notify(definition.id, structuredCloneValue(value));
143
+ }
144
+ return;
145
+ }
146
+
147
+ const cloned = structuredCloneValue(value);
148
+ snapshot.values[definition.id] = cloned;
149
+ snapshot.rawValues[definition.id] = raw;
150
+ syncGlobalSnapshot(definition.id, cloned, raw);
151
+ if (options.persist) {
152
+ writeStorage(getPreferenceStorageKey(definition), raw);
153
+ }
154
+ notify(definition.id, cloned);
155
+ if (options.announce) {
156
+ document.dispatchEvent(
157
+ new CustomEvent<PreferenceChangeDetail>(preferenceChangeEvent, {
158
+ detail: { id: definition.id, source, value: cloned },
159
+ }),
160
+ );
161
+ }
162
+ };
163
+
164
+ const handlePreferenceChange = (event: Event): void => {
165
+ const detail = (event as CustomEvent<PreferenceChangeDetail>).detail;
166
+ if (detail?.source === source) return;
167
+ const definition = detail ? definitionsById.get(detail.id) : undefined;
168
+ if (definition) {
169
+ apply(definition, detail.value, {
170
+ persist: false,
171
+ announce: false,
172
+ notifyIfEqual: true,
173
+ });
174
+ }
175
+ };
176
+
177
+ const handleStorage = (event: StorageEvent): void => {
178
+ const definition = event.key
179
+ ? definitionsByStorageKey.get(event.key)
180
+ : undefined;
181
+ if (!definition) return;
182
+ const value = parseStoredValue(event.newValue);
183
+ if (!matchesPreferenceSchema(value, definition.schema)) return;
184
+ apply(definition, value, { persist: false, announce: true });
185
+ };
186
+
187
+ document.addEventListener(preferenceChangeEvent, handlePreferenceChange);
188
+ window.addEventListener('storage', handleStorage);
189
+
190
+ return {
191
+ get<T>(definition: PreferenceDefinition<T>): T {
192
+ requireDefinition(definition);
193
+ return structuredCloneValue(snapshot.values[definition.id] as T);
194
+ },
195
+ set<T>(definition: PreferenceDefinition<T>, value: T): void {
196
+ requireDefinition(definition);
197
+ if (!matchesPreferenceSchema(value, definition.schema)) {
198
+ throw new TypeError(`Invalid value for preference ${definition.id}`);
199
+ }
200
+ apply(definition, value, { persist: true, announce: true });
201
+ },
202
+ reset<T>(definition: PreferenceDefinition<T>): void {
203
+ requireDefinition(definition);
204
+ apply(definition, definition.defaultValue, {
205
+ persist: true,
206
+ announce: true,
207
+ });
208
+ },
209
+ subscribe<T>(
210
+ definition: PreferenceDefinition<T>,
211
+ listener: (value: T) => void,
212
+ ): () => void {
213
+ requireDefinition(definition);
214
+ const definitionListeners = listeners.get(definition.id) ?? new Set();
215
+ const wrapped = (value: unknown): void => listener(value as T);
216
+ definitionListeners.add(wrapped);
217
+ listeners.set(definition.id, definitionListeners);
218
+ return () => {
219
+ definitionListeners.delete(wrapped);
220
+ if (definitionListeners.size === 0) listeners.delete(definition.id);
221
+ };
222
+ },
223
+ destroy(): void {
224
+ document.removeEventListener(preferenceChangeEvent, handlePreferenceChange);
225
+ window.removeEventListener('storage', handleStorage);
226
+ listeners.clear();
227
+ },
228
+ };
229
+ }
230
+
231
+ function getPreferenceStorageKey(
232
+ definition: Pick<AnyPreferenceDefinition, 'key' | 'version'>,
233
+ ): string {
234
+ return `${definition.key}:v${definition.version}`;
235
+ }
236
+
237
+ function matchesPreferenceSchema(
238
+ value: unknown,
239
+ schema: PreferenceSchema,
240
+ ): boolean {
241
+ if (schema.type === 'boolean') return typeof value === 'boolean';
242
+ if (schema.type === 'string') {
243
+ return (
244
+ typeof value === 'string' &&
245
+ (!schema.values || schema.values.includes(value))
246
+ );
247
+ }
248
+ if (schema.type === 'number' || schema.type === 'integer') {
249
+ if (
250
+ typeof value !== 'number' ||
251
+ !Number.isFinite(value) ||
252
+ (schema.type === 'integer' && !Number.isInteger(value)) ||
253
+ (schema.min !== undefined && value < schema.min) ||
254
+ (schema.max !== undefined && value > schema.max)
255
+ ) {
256
+ return false;
257
+ }
258
+ return (
259
+ schema.type !== 'integer' ||
260
+ !schema.values ||
261
+ schema.values.includes(value)
262
+ );
263
+ }
264
+ if (schema.type === 'array') {
265
+ return (
266
+ Array.isArray(value) &&
267
+ value.every((item) => matchesPreferenceSchema(item, schema.items))
268
+ );
269
+ }
270
+ if (!isRecord(value)) return false;
271
+
272
+ return Object.entries(schema.properties).every(([key, propertySchema]) =>
273
+ matchesPreferenceSchema(value[key], propertySchema),
274
+ );
275
+ }
276
+
277
+ function resolveSnapshot(
278
+ definitions: readonly AnyPreferenceDefinition[],
279
+ ): PreferenceSnapshot {
280
+ const snapshot = getGlobalSnapshot() ?? { values: {}, rawValues: {} };
281
+ setGlobalSnapshot(snapshot);
282
+
283
+ for (const definition of definitions) {
284
+ const bootstrapped = snapshot.values[definition.id];
285
+ if (matchesPreferenceSchema(bootstrapped, definition.schema)) {
286
+ snapshot.rawValues[definition.id] = JSON.stringify(bootstrapped);
287
+ continue;
288
+ }
289
+
290
+ const value = readPreference(definition);
291
+ snapshot.values[definition.id] = value;
292
+ snapshot.rawValues[definition.id] = JSON.stringify(value);
293
+ }
294
+ return snapshot;
295
+ }
296
+
297
+ function readPreference<T>(definition: PreferenceDefinition<T>): T {
298
+ const current = parseStoredValue(
299
+ readStorage(getPreferenceStorageKey(definition)),
300
+ );
301
+ if (matchesPreferenceSchema(current, definition.schema)) return current as T;
302
+
303
+ for (const migration of [...(definition.migrations ?? [])].toSorted(
304
+ (left, right) => right.fromVersion - left.fromVersion,
305
+ )) {
306
+ const key = `${migration.fromKey ?? definition.key}:v${migration.fromVersion}`;
307
+ const migrated = applyMigration(
308
+ parseStoredValue(readStorage(key)),
309
+ migration,
310
+ );
311
+ if (matchesPreferenceSchema(migrated, definition.schema)) {
312
+ writeStorage(
313
+ getPreferenceStorageKey(definition),
314
+ JSON.stringify(migrated),
315
+ );
316
+ return migrated as T;
317
+ }
318
+ }
319
+
320
+ const fallback = structuredCloneValue(definition.defaultValue);
321
+ writeStorage(getPreferenceStorageKey(definition), JSON.stringify(fallback));
322
+ return fallback;
323
+ }
324
+
325
+ function applyMigration(
326
+ value: unknown,
327
+ migration: PreferenceMigration,
328
+ ): unknown {
329
+ if (!isRecord(value)) return value;
330
+ const migrated: Record<string, unknown> = { ...value };
331
+ for (const [from, to] of Object.entries(migration.rename ?? {})) {
332
+ if (from in migrated && !(to in migrated)) migrated[to] = migrated[from];
333
+ delete migrated[from];
334
+ }
335
+ for (const [key, fallback] of Object.entries(migration.defaults ?? {})) {
336
+ if (!(key in migrated)) migrated[key] = fallback;
337
+ }
338
+ return migrated;
339
+ }
340
+
341
+ function getGlobalSnapshot(): PreferenceSnapshot | undefined {
342
+ return (globalThis as Record<string, unknown>)[snapshotGlobalKey] as
343
+ | PreferenceSnapshot
344
+ | undefined;
345
+ }
346
+
347
+ function setGlobalSnapshot(snapshot: PreferenceSnapshot): void {
348
+ (globalThis as Record<string, unknown>)[snapshotGlobalKey] = snapshot;
349
+ }
350
+
351
+ function syncGlobalSnapshot(id: string, value: unknown, raw: string): void {
352
+ const globalSnapshot = getGlobalSnapshot();
353
+ if (!globalSnapshot) return;
354
+ globalSnapshot.values[id] = value;
355
+ globalSnapshot.rawValues[id] = raw;
356
+ }
357
+
358
+ function readStorage(key: string): string | null {
359
+ try {
360
+ return window.localStorage.getItem(key);
361
+ } catch {
362
+ return null;
363
+ }
364
+ }
365
+
366
+ function writeStorage(key: string, value: string): void {
367
+ try {
368
+ window.localStorage.setItem(key, value);
369
+ } catch {
370
+ // The in-memory snapshot remains authoritative for this document.
371
+ }
372
+ }
373
+
374
+ function parseStoredValue(raw: string | null): unknown {
375
+ if (raw === null) return undefined;
376
+ try {
377
+ return JSON.parse(raw);
378
+ } catch {
379
+ return undefined;
380
+ }
381
+ }
382
+
383
+ function assertDefinition(definition: AnyPreferenceDefinition): void {
384
+ if (!definition.id.trim() || !definition.key.trim()) {
385
+ throw new Error('Preference ids and keys must be non-empty');
386
+ }
387
+ if (!Number.isInteger(definition.version) || definition.version < 1) {
388
+ throw new RangeError('Preference versions must be positive integers');
389
+ }
390
+ }
391
+
392
+ function structuredCloneValue<T>(value: T): T {
393
+ return typeof structuredClone === 'function'
394
+ ? structuredClone(value)
395
+ : (JSON.parse(JSON.stringify(value)) as T);
396
+ }
397
+
398
+ function isRecord(value: unknown): value is Record<string, unknown> {
399
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
400
+ }
@@ -57,9 +57,21 @@ const resolvedTone = tone ?? dataTone;
57
57
  rel={resolvedRel}
58
58
  target={target}
59
59
  >
60
- {hasLeading ? <slot name="leading" /> : null}
60
+ {
61
+ hasLeading ? (
62
+ <span class="nox-button__affix">
63
+ <slot name="leading" />
64
+ </span>
65
+ ) : null
66
+ }
61
67
  <slot />
62
- {hasTrailing ? <slot name="trailing" /> : null}
68
+ {
69
+ hasTrailing ? (
70
+ <span class="nox-button__affix">
71
+ <slot name="trailing" />
72
+ </span>
73
+ ) : null
74
+ }
63
75
  </a>
64
76
  ) : href ? (
65
77
  <span
@@ -74,9 +86,21 @@ const resolvedTone = tone ?? dataTone;
74
86
  data-button-size={size}
75
87
  data-tone={resolvedTone}
76
88
  >
77
- {hasLeading ? <slot name="leading" /> : null}
89
+ {
90
+ hasLeading ? (
91
+ <span class="nox-button__affix">
92
+ <slot name="leading" />
93
+ </span>
94
+ ) : null
95
+ }
78
96
  <slot />
79
- {hasTrailing ? <slot name="trailing" /> : null}
97
+ {
98
+ hasTrailing ? (
99
+ <span class="nox-button__affix">
100
+ <slot name="trailing" />
101
+ </span>
102
+ ) : null
103
+ }
80
104
  </span>
81
105
  ) : (
82
106
  <button
@@ -92,9 +116,21 @@ const resolvedTone = tone ?? dataTone;
92
116
  disabled={disabled}
93
117
  type={type}
94
118
  >
95
- {hasLeading ? <slot name="leading" /> : null}
119
+ {
120
+ hasLeading ? (
121
+ <span class="nox-button__affix">
122
+ <slot name="leading" />
123
+ </span>
124
+ ) : null
125
+ }
96
126
  <slot />
97
- {hasTrailing ? <slot name="trailing" /> : null}
127
+ {
128
+ hasTrailing ? (
129
+ <span class="nox-button__affix">
130
+ <slot name="trailing" />
131
+ </span>
132
+ ) : null
133
+ }
98
134
  </button>
99
135
  )
100
136
  }
@@ -80,6 +80,12 @@
80
80
  block-size: 2rem;
81
81
  }
82
82
 
83
+ .nox-button[data-button-icon-only] > [data-tone] {
84
+ display: grid;
85
+ place-items: center;
86
+ color: var(--nox-tone-color);
87
+ }
88
+
83
89
  .nox-button[data-button-icon-only][data-button-size='sm'] svg {
84
90
  inline-size: 1.5rem;
85
91
  block-size: 1.5rem;
@@ -90,6 +96,19 @@
90
96
  block-size: 2.25rem;
91
97
  }
92
98
 
99
+ .nox-button__affix {
100
+ display: grid;
101
+ inline-size: 1.25rem;
102
+ block-size: 1.25rem;
103
+ place-items: center;
104
+ }
105
+
106
+ .nox-button__affix svg {
107
+ display: block;
108
+ inline-size: 100%;
109
+ block-size: 100%;
110
+ }
111
+
93
112
  .nox-button[data-button-borderless] {
94
113
  border: 0;
95
114
  border-radius: 0;