sh3-core 0.12.0 → 0.13.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 (88) hide show
  1. package/dist/__test__/reset.js +2 -0
  2. package/dist/actions/MenuButton.svelte +2 -1
  3. package/dist/actions/contextMenuModel.js +8 -0
  4. package/dist/actions/contextMenuModel.test.js +22 -2
  5. package/dist/actions/listeners.js +17 -6
  6. package/dist/actions/listeners.test.js +42 -2
  7. package/dist/api.d.ts +16 -0
  8. package/dist/api.js +14 -0
  9. package/dist/apps/lifecycle.js +3 -0
  10. package/dist/apps/lifecycle.test.js +45 -0
  11. package/dist/host.js +12 -0
  12. package/dist/navigation/back-stack.d.ts +29 -0
  13. package/dist/navigation/back-stack.js +87 -0
  14. package/dist/navigation/back-stack.test.d.ts +1 -0
  15. package/dist/navigation/back-stack.test.js +145 -0
  16. package/dist/navigation/index.d.ts +2 -0
  17. package/dist/navigation/index.js +6 -0
  18. package/dist/navigation/platform-web.d.ts +3 -0
  19. package/dist/navigation/platform-web.js +54 -0
  20. package/dist/navigation/platform-web.test.d.ts +1 -0
  21. package/dist/navigation/platform-web.test.js +96 -0
  22. package/dist/overlays/modal.js +7 -0
  23. package/dist/overlays/modal.test.js +35 -0
  24. package/dist/overlays/popup.js +7 -0
  25. package/dist/overlays/popup.test.js +33 -0
  26. package/dist/platform/index.d.ts +15 -0
  27. package/dist/platform/index.js +47 -0
  28. package/dist/primitives/base.css +17 -6
  29. package/dist/primitives/widgets/ColorSwatch.svelte +66 -0
  30. package/dist/primitives/widgets/ColorSwatch.svelte.d.ts +9 -0
  31. package/dist/primitives/widgets/Field.svelte +124 -0
  32. package/dist/primitives/widgets/Field.svelte.d.ts +19 -0
  33. package/dist/primitives/widgets/FilePicker.d.ts +3 -0
  34. package/dist/primitives/widgets/FilePicker.js +19 -0
  35. package/dist/primitives/widgets/FilePicker.svelte +79 -0
  36. package/dist/primitives/widgets/FilePicker.svelte.d.ts +13 -0
  37. package/dist/primitives/widgets/FilePicker.test.d.ts +1 -0
  38. package/dist/primitives/widgets/FilePicker.test.js +44 -0
  39. package/dist/primitives/widgets/IconToggleGroup.d.ts +2 -0
  40. package/dist/primitives/widgets/IconToggleGroup.js +8 -0
  41. package/dist/primitives/widgets/IconToggleGroup.svelte +86 -0
  42. package/dist/primitives/widgets/IconToggleGroup.svelte.d.ts +16 -0
  43. package/dist/primitives/widgets/IconToggleGroup.test.d.ts +1 -0
  44. package/dist/primitives/widgets/IconToggleGroup.test.js +19 -0
  45. package/dist/primitives/widgets/NumberInput.d.ts +6 -0
  46. package/dist/primitives/widgets/NumberInput.js +19 -0
  47. package/dist/primitives/widgets/NumberInput.svelte +167 -0
  48. package/dist/primitives/widgets/NumberInput.svelte.d.ts +17 -0
  49. package/dist/primitives/widgets/NumberInput.test.d.ts +1 -0
  50. package/dist/primitives/widgets/NumberInput.test.js +28 -0
  51. package/dist/primitives/widgets/RangeSlider.d.ts +2 -0
  52. package/dist/primitives/widgets/RangeSlider.js +7 -0
  53. package/dist/primitives/widgets/RangeSlider.svelte +124 -0
  54. package/dist/primitives/widgets/RangeSlider.svelte.d.ts +13 -0
  55. package/dist/primitives/widgets/RangeSlider.test.d.ts +1 -0
  56. package/dist/primitives/widgets/RangeSlider.test.js +14 -0
  57. package/dist/primitives/widgets/Segmented.d.ts +9 -0
  58. package/dist/primitives/widgets/Segmented.js +28 -0
  59. package/dist/primitives/widgets/Segmented.svelte +82 -0
  60. package/dist/primitives/widgets/Segmented.svelte.d.ts +10 -0
  61. package/dist/primitives/widgets/Segmented.test.d.ts +1 -0
  62. package/dist/primitives/widgets/Segmented.test.js +24 -0
  63. package/dist/primitives/widgets/Select.d.ts +11 -0
  64. package/dist/primitives/widgets/Select.js +42 -0
  65. package/dist/primitives/widgets/Select.svelte +163 -0
  66. package/dist/primitives/widgets/Select.svelte.d.ts +14 -0
  67. package/dist/primitives/widgets/Select.test.d.ts +1 -0
  68. package/dist/primitives/widgets/Select.test.js +68 -0
  69. package/dist/primitives/widgets/Slider.d.ts +6 -0
  70. package/dist/primitives/widgets/Slider.js +19 -0
  71. package/dist/primitives/widgets/Slider.svelte +205 -0
  72. package/dist/primitives/widgets/Slider.svelte.d.ts +15 -0
  73. package/dist/primitives/widgets/Slider.test.d.ts +1 -0
  74. package/dist/primitives/widgets/Slider.test.js +31 -0
  75. package/dist/primitives/widgets/SliderGroup.svelte +58 -0
  76. package/dist/primitives/widgets/SliderGroup.svelte.d.ts +18 -0
  77. package/dist/primitives/widgets/Textarea.svelte +81 -0
  78. package/dist/primitives/widgets/Textarea.svelte.d.ts +16 -0
  79. package/dist/primitives/widgets/_select-listbox.svelte +228 -0
  80. package/dist/primitives/widgets/_select-listbox.svelte.d.ts +18 -0
  81. package/dist/shell-shard/Terminal.svelte +1 -4
  82. package/dist/shell-shard/verbs/index.js +2 -0
  83. package/dist/shell-shard/verbs/reset.d.ts +2 -0
  84. package/dist/shell-shard/verbs/reset.js +26 -0
  85. package/dist/tokens.css +32 -0
  86. package/dist/version.d.ts +1 -1
  87. package/dist/version.js +1 -1
  88. package/package.json +1 -1
@@ -0,0 +1,54 @@
1
+ /*
2
+ * Web platform emitter for the navigation back-cascade.
3
+ *
4
+ * Browser back/forward (and mouse X1/X2 buttons, which the browser maps
5
+ * to history navigation) all funnel through `popstate`. The event itself
6
+ * doesn't report direction, so we maintain a three-state "sandwich":
7
+ *
8
+ * position 0: { sh3: 'anchor' } ← popstate here = back was pressed
9
+ * position 1: { sh3: 'main' } ← resting position
10
+ * position 2: { sh3: 'forward-bumper' } ← popstate here = forward was pressed
11
+ *
12
+ * On every consumed signal we re-anchor to 'main' so the user never
13
+ * navigates out of SH3 via the back/forward chord.
14
+ *
15
+ * Page reload re-runs install. The two extra synthetic history entries
16
+ * are accepted noise; no URL changes.
17
+ *
18
+ * Known limitation: third-party `pushState` (HMR, libraries) clobbers the
19
+ * forward-bumper. SH3 framework code must not call pushState directly.
20
+ */
21
+ import { dispatchBack, dispatchForward } from './back-stack';
22
+ let installed = false;
23
+ let listener = null;
24
+ export function installWebEmitter() {
25
+ if (installed)
26
+ return;
27
+ installed = true;
28
+ listener = (e) => {
29
+ var _a;
30
+ const tag = (_a = e.state) === null || _a === void 0 ? void 0 : _a.sh3;
31
+ if (tag === 'anchor') {
32
+ dispatchBack();
33
+ history.forward();
34
+ }
35
+ else if (tag === 'forward-bumper') {
36
+ dispatchForward();
37
+ history.back();
38
+ }
39
+ // tag === 'main' (or undefined) → echo from our own correction; ignore.
40
+ };
41
+ window.addEventListener('popstate', listener);
42
+ history.replaceState({ sh3: 'anchor' }, '');
43
+ history.pushState({ sh3: 'main' }, '');
44
+ history.pushState({ sh3: 'forward-bumper' }, '');
45
+ history.back();
46
+ }
47
+ /** @internal — test cleanup. Removes the listener; does not unwind history. */
48
+ export function __uninstallWebEmitterForTest() {
49
+ if (listener) {
50
+ window.removeEventListener('popstate', listener);
51
+ listener = null;
52
+ }
53
+ installed = false;
54
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,96 @@
1
+ /*
2
+ * Note: happy-dom does not simulate `history.back()` / `history.forward()`
3
+ * (state stays put). We can't drive the listener via the real navigation
4
+ * API in this environment, so we test what we own — listener wiring and
5
+ * dispatch routing — by dispatching synthetic PopStateEvents directly.
6
+ * The trailing `history.back()` in installWebEmitter is a no-op here but
7
+ * is real in browsers and Tauri webviews; manual verification (Task 7 of
8
+ * the plan) covers the actual navigation behavior.
9
+ */
10
+ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
11
+ import { installWebEmitter, __uninstallWebEmitterForTest } from './platform-web';
12
+ import { __resetBackStackForTest, __setLifecycleHandlersForTest, } from './back-stack';
13
+ import { activeApp, breadcrumbApp } from '../apps/registry.svelte';
14
+ beforeEach(() => {
15
+ __resetBackStackForTest();
16
+ activeApp.id = null;
17
+ breadcrumbApp.id = null;
18
+ });
19
+ afterEach(() => {
20
+ __uninstallWebEmitterForTest();
21
+ });
22
+ // happy-dom's PopStateEvent constructor ignores the `state` init dict, so
23
+ // we attach state manually via defineProperty before dispatching.
24
+ function firePopState(state) {
25
+ const e = new PopStateEvent('popstate');
26
+ Object.defineProperty(e, 'state', { value: state });
27
+ window.dispatchEvent(e);
28
+ }
29
+ const fireAnchor = () => firePopState({ sh3: 'anchor' });
30
+ const fireForwardBumper = () => firePopState({ sh3: 'forward-bumper' });
31
+ const fireMain = () => firePopState({ sh3: 'main' });
32
+ describe('platform-web — sentinel install', () => {
33
+ it('installs without throwing and pushes the three sentinel entries', () => {
34
+ const before = history.length;
35
+ installWebEmitter();
36
+ // pushState appends; replaceState doesn't. We expect at least +2 entries
37
+ // (the two pushStates; replaceState replaced the user's current entry).
38
+ expect(history.length).toBeGreaterThanOrEqual(before + 2);
39
+ });
40
+ it('install is idempotent — second call does nothing', () => {
41
+ installWebEmitter();
42
+ const after1 = history.length;
43
+ installWebEmitter();
44
+ expect(history.length).toBe(after1);
45
+ });
46
+ });
47
+ describe('platform-web — back signal', () => {
48
+ it('an anchor-tagged popstate fires dispatchBack', () => {
49
+ activeApp.id = 'app';
50
+ const returnToHome = vi.fn();
51
+ __setLifecycleHandlersForTest({ returnToHome, launchApp: vi.fn() });
52
+ installWebEmitter();
53
+ fireAnchor();
54
+ expect(returnToHome).toHaveBeenCalledTimes(1);
55
+ });
56
+ });
57
+ describe('platform-web — forward signal', () => {
58
+ it('a forward-bumper-tagged popstate fires dispatchForward', () => {
59
+ activeApp.id = null;
60
+ breadcrumbApp.id = 'last-app';
61
+ const launchApp = vi.fn();
62
+ __setLifecycleHandlersForTest({ returnToHome: vi.fn(), launchApp });
63
+ installWebEmitter();
64
+ fireForwardBumper();
65
+ expect(launchApp).toHaveBeenCalledWith('last-app');
66
+ });
67
+ });
68
+ describe('platform-web — main echo is ignored', () => {
69
+ it('a popstate tagged main does not fire dispatch', () => {
70
+ activeApp.id = 'app';
71
+ const returnToHome = vi.fn();
72
+ __setLifecycleHandlersForTest({ returnToHome, launchApp: vi.fn() });
73
+ installWebEmitter();
74
+ fireMain();
75
+ expect(returnToHome).not.toHaveBeenCalled();
76
+ });
77
+ it('a popstate with no state (untagged) does not fire dispatch', () => {
78
+ activeApp.id = 'app';
79
+ const returnToHome = vi.fn();
80
+ __setLifecycleHandlersForTest({ returnToHome, launchApp: vi.fn() });
81
+ installWebEmitter();
82
+ firePopState(null);
83
+ expect(returnToHome).not.toHaveBeenCalled();
84
+ });
85
+ });
86
+ describe('platform-web — uninstall', () => {
87
+ it('after __uninstall, popstates no longer fire dispatch', () => {
88
+ activeApp.id = 'app';
89
+ const returnToHome = vi.fn();
90
+ __setLifecycleHandlersForTest({ returnToHome, launchApp: vi.fn() });
91
+ installWebEmitter();
92
+ __uninstallWebEmitterForTest();
93
+ fireAnchor();
94
+ expect(returnToHome).not.toHaveBeenCalled();
95
+ });
96
+ });
@@ -39,6 +39,7 @@
39
39
  import { mount, unmount } from 'svelte';
40
40
  import ModalFrame from './ModalFrame.svelte';
41
41
  import { getLayerRoot } from './roots';
42
+ import { registerDismissable } from '../navigation/back-stack';
42
43
  const stack = [];
43
44
  let escapeInstalled = false;
44
45
  let backdrop = null;
@@ -116,6 +117,12 @@ function openModal(Content, props, options) {
116
117
  const handle = {
117
118
  close: () => removeEntry(entry),
118
119
  };
120
+ const dismissReg = registerDismissable(() => handle.close());
121
+ const originalClose = handle.close;
122
+ handle.close = () => {
123
+ dismissReg.unregister();
124
+ originalClose();
125
+ };
119
126
  const frame = mount(ModalFrame, {
120
127
  target: host,
121
128
  props: {
@@ -53,3 +53,38 @@ describe('modal — backdrop dismiss policy', () => {
53
53
  expect(layerRoot.querySelector('.sh3-modal-host')).not.toBeNull();
54
54
  });
55
55
  });
56
+ describe('modal — back-cascade integration', () => {
57
+ let layerRoot;
58
+ beforeEach(() => {
59
+ layerRoot = makeLayerRoot();
60
+ });
61
+ afterEach(() => {
62
+ modalManager.closeAll();
63
+ teardownLayerRoot(layerRoot);
64
+ });
65
+ it('opens register a dismissable; back closes the topmost modal', async () => {
66
+ const { dispatchBack, __resetBackStackForTest } = await import('../navigation/back-stack');
67
+ __resetBackStackForTest();
68
+ modalManager.open(DummyFrame, {});
69
+ modalManager.open(DummyFrame, {});
70
+ await tick();
71
+ expect(layerRoot.querySelectorAll('.sh3-modal-host').length).toBe(2);
72
+ dispatchBack();
73
+ await tick();
74
+ expect(layerRoot.querySelectorAll('.sh3-modal-host').length).toBe(1);
75
+ dispatchBack();
76
+ await tick();
77
+ expect(layerRoot.querySelectorAll('.sh3-modal-host').length).toBe(0);
78
+ });
79
+ it('programmatic close unregisters the dismissable (no double-close)', async () => {
80
+ const { dispatchBack, __resetBackStackForTest } = await import('../navigation/back-stack');
81
+ __resetBackStackForTest();
82
+ const handle = modalManager.open(DummyFrame, {});
83
+ await tick();
84
+ handle.close();
85
+ handle.close();
86
+ await tick();
87
+ expect(() => dispatchBack()).not.toThrow();
88
+ expect(layerRoot.querySelectorAll('.sh3-modal-host').length).toBe(0);
89
+ });
90
+ });
@@ -28,6 +28,7 @@
28
28
  import { mount, unmount } from 'svelte';
29
29
  import PopupFrame from './PopupFrame.svelte';
30
30
  import { getLayerRoot } from './roots';
31
+ import { registerDismissable } from '../navigation/back-stack';
31
32
  /**
32
33
  * Convert a PopupAnchor to a DOMRect.
33
34
  * - HTMLElement: uses its live bounding rect.
@@ -91,6 +92,12 @@ function showPopup(Content, options, props) {
91
92
  const handle = {
92
93
  close: () => removeEntry(entry),
93
94
  };
95
+ const dismissReg = registerDismissable(() => handle.close());
96
+ const originalClose = handle.close;
97
+ handle.close = () => {
98
+ dismissReg.unregister();
99
+ originalClose();
100
+ };
94
101
  const frame = mount(PopupFrame, {
95
102
  target: host,
96
103
  props: {
@@ -93,3 +93,36 @@ describe('popup — P.2 HTMLElement anchor regression', () => {
93
93
  anchor.remove();
94
94
  });
95
95
  });
96
+ describe('popup — back-cascade integration', () => {
97
+ let layerRoot;
98
+ beforeEach(() => {
99
+ vi.stubGlobal('innerWidth', 2000);
100
+ vi.stubGlobal('innerHeight', 2000);
101
+ layerRoot = makeLayerRoot();
102
+ });
103
+ afterEach(() => {
104
+ __resetPopupManagerForTest();
105
+ teardownLayerRoot(layerRoot);
106
+ vi.unstubAllGlobals();
107
+ });
108
+ it('opening a popup registers a dismissable; back closes it', async () => {
109
+ const { dispatchBack, __resetBackStackForTest } = await import('../navigation/back-stack');
110
+ __resetBackStackForTest();
111
+ popupManager.show(DummyFrame, { anchor: { x: 100, y: 100 } }, {});
112
+ await tick();
113
+ expect(layerRoot.querySelector('.sh3-popup-host')).not.toBeNull();
114
+ dispatchBack();
115
+ await tick();
116
+ expect(layerRoot.querySelector('.sh3-popup-host')).toBeNull();
117
+ });
118
+ it('programmatic close does not leave a stale dismissable', async () => {
119
+ const { dispatchBack, __resetBackStackForTest } = await import('../navigation/back-stack');
120
+ __resetBackStackForTest();
121
+ const handle = popupManager.show(DummyFrame, { anchor: { x: 100, y: 100 } }, {});
122
+ await tick();
123
+ handle.close();
124
+ await tick();
125
+ expect(() => dispatchBack()).not.toThrow();
126
+ expect(layerRoot.querySelector('.sh3-popup-host')).toBeNull();
127
+ });
128
+ });
@@ -8,3 +8,18 @@ export interface PlatformResult {
8
8
  localOwner: boolean;
9
9
  }
10
10
  export declare function resolvePlatform(): Promise<PlatformResult>;
11
+ /**
12
+ * Erase all persisted SH3 state for this user/device.
13
+ *
14
+ * Wipes:
15
+ * - Tauri plugin-store files (`workspace.json`, `user.json`) when running
16
+ * inside a Tauri webview
17
+ * - localStorage keys with the `sh3:` prefix (web zones + theme overrides;
18
+ * also active inside the Tauri webview)
19
+ * - the `sh3-packages` IndexedDB database (installed bundle registry)
20
+ *
21
+ * Intentionally preserves the `sh3-documents` IndexedDB so user-authored
22
+ * content survives a reset. Callers are expected to follow this with a
23
+ * `location.reload()` so a clean boot picks up the cleared state.
24
+ */
25
+ export declare function wipeUserData(): Promise<void>;
@@ -31,3 +31,50 @@ export async function resolvePlatform() {
31
31
  return { backends: null, localOwner: DEV };
32
32
  }
33
33
  }
34
+ /**
35
+ * Erase all persisted SH3 state for this user/device.
36
+ *
37
+ * Wipes:
38
+ * - Tauri plugin-store files (`workspace.json`, `user.json`) when running
39
+ * inside a Tauri webview
40
+ * - localStorage keys with the `sh3:` prefix (web zones + theme overrides;
41
+ * also active inside the Tauri webview)
42
+ * - the `sh3-packages` IndexedDB database (installed bundle registry)
43
+ *
44
+ * Intentionally preserves the `sh3-documents` IndexedDB so user-authored
45
+ * content survives a reset. Callers are expected to follow this with a
46
+ * `location.reload()` so a clean boot picks up the cleared state.
47
+ */
48
+ export async function wipeUserData() {
49
+ // Tauri stores live on disk via plugin-store; localStorage/IDB don't reach them.
50
+ try {
51
+ const { LazyStore } = await import('@tauri-apps/plugin-store');
52
+ const workspace = new LazyStore('workspace.json', { defaults: {}, autoSave: true });
53
+ const user = new LazyStore('user.json', { defaults: {}, autoSave: true });
54
+ await workspace.clear();
55
+ await user.clear();
56
+ }
57
+ catch (_a) {
58
+ // Not in Tauri — nothing to clear on disk.
59
+ }
60
+ if (typeof localStorage !== 'undefined') {
61
+ const keys = [];
62
+ for (let i = 0; i < localStorage.length; i++) {
63
+ const k = localStorage.key(i);
64
+ if (k && k.startsWith('sh3:'))
65
+ keys.push(k);
66
+ }
67
+ for (const k of keys)
68
+ localStorage.removeItem(k);
69
+ }
70
+ if (typeof indexedDB !== 'undefined') {
71
+ await new Promise((resolve) => {
72
+ const req = indexedDB.deleteDatabase('sh3-packages');
73
+ req.onsuccess = () => resolve();
74
+ // Swallow errors/blocked: a stale lock shouldn't abort the wipe;
75
+ // the next boot will re-create the DB empty either way.
76
+ req.onerror = () => resolve();
77
+ req.onblocked = () => resolve();
78
+ });
79
+ }
80
+ }
@@ -52,7 +52,8 @@ input[type="tel"],
52
52
  input[type="number"],
53
53
  textarea,
54
54
  .shell-base-input {
55
- padding: var(--shell-pad-md) var(--shell-pad-lg);
55
+ padding: 0 var(--shell-field-pad-x);
56
+ height: var(--shell-field-height-md);
56
57
  background: var(--shell-input-bg);
57
58
  color: var(--shell-fg);
58
59
  border: 1px solid var(--shell-border);
@@ -82,6 +83,8 @@ textarea:disabled,
82
83
  textarea {
83
84
  resize: vertical;
84
85
  min-height: calc(var(--shell-line) * 3em);
86
+ height: auto;
87
+ padding: var(--shell-pad-sm) var(--shell-field-pad-x);
85
88
  }
86
89
 
87
90
  /* ── Checkbox & radio ────────────────────────────────────────────────── */
@@ -103,8 +106,10 @@ input[type="radio"].shell-base-radio {
103
106
  .shell-base-check { border-radius: var(--shell-radius-sm); }
104
107
  .shell-base-radio { border-radius: 50%; }
105
108
 
106
- .shell-base-check:checked,
107
- .shell-base-radio:checked {
109
+ /* Need the input[type] prefix to outweigh the base
110
+ `input[type="checkbox"].shell-base-check` rule's specificity. */
111
+ input[type="checkbox"].shell-base-check:checked,
112
+ input[type="radio"].shell-base-radio:checked {
108
113
  background: var(--shell-accent);
109
114
  border-color: var(--shell-accent);
110
115
  }
@@ -145,11 +150,13 @@ input[type="checkbox"].shell-base-switch {
145
150
  width: 28px;
146
151
  height: 16px;
147
152
  margin: 0;
148
- background: var(--shell-border-strong);
153
+ background: var(--shell-bg-sunken);
154
+ border: 1px solid var(--shell-border-strong);
149
155
  border-radius: 999px;
150
156
  cursor: pointer;
151
- transition: background 120ms ease;
157
+ transition: background 120ms ease, border-color 120ms ease, box-shadow 120ms ease;
152
158
  flex-shrink: 0;
159
+ box-sizing: border-box;
153
160
  }
154
161
 
155
162
  .shell-base-switch::before {
@@ -164,7 +171,11 @@ input[type="checkbox"].shell-base-switch {
164
171
  transition: transform 120ms ease;
165
172
  }
166
173
 
167
- .shell-base-switch:checked { background: var(--shell-accent); }
174
+ input[type="checkbox"].shell-base-switch:checked {
175
+ background: var(--shell-accent);
176
+ border-color: var(--shell-accent);
177
+ box-shadow: 0 0 0 2px color-mix(in srgb, var(--shell-accent) 25%, transparent);
178
+ }
168
179
  .shell-base-switch:checked::before {
169
180
  transform: translateX(12px);
170
181
  background: var(--shell-fg-on-accent);
@@ -0,0 +1,66 @@
1
+ <script lang="ts">
2
+ import { shell } from '../../shellRuntime.svelte';
3
+
4
+ let {
5
+ value = $bindable('#000000'),
6
+ label,
7
+ disabled = false,
8
+ size = 'md',
9
+ }: {
10
+ value?: string;
11
+ label?: string;
12
+ disabled?: boolean;
13
+ size?: 'sm' | 'md';
14
+ } = $props();
15
+
16
+ let trigger: HTMLButtonElement | undefined;
17
+
18
+ async function open() {
19
+ if (disabled) return;
20
+ const result = await shell.color.pick({ initial: value, anchor: trigger });
21
+ if (result !== null && result !== undefined) value = result;
22
+ }
23
+ </script>
24
+
25
+ <label class="sh3-swatch" class:sh3-swatch--sm={size === 'sm'}>
26
+ {#if label}<span class="sh3-swatch__label">{label}</span>{/if}
27
+ <button
28
+ type="button"
29
+ class="sh3-swatch__btn"
30
+ bind:this={trigger}
31
+ {disabled}
32
+ onclick={open}
33
+ aria-label="{label ?? 'Color'} (current {value})"
34
+ style:--swatch-color={value}
35
+ ><span class="sh3-swatch__dot"></span><span class="sh3-swatch__hex">{value}</span></button>
36
+ </label>
37
+
38
+ <style>
39
+ .sh3-swatch { display: inline-flex; flex-direction: column; gap: 4px; }
40
+ .sh3-swatch__label { font-size: 0.75rem; color: var(--shell-fg-muted); }
41
+ .sh3-swatch__btn {
42
+ display: inline-flex; align-items: center; gap: 8px;
43
+ height: var(--shell-field-height-md);
44
+ padding: 0 var(--shell-field-pad-x);
45
+ background: var(--shell-input-bg);
46
+ border: 1px solid var(--shell-border);
47
+ border-radius: var(--shell-widget-radius);
48
+ color: var(--shell-fg);
49
+ cursor: pointer;
50
+ font: inherit;
51
+ }
52
+ .sh3-swatch--sm .sh3-swatch__btn { height: var(--shell-field-height-sm); }
53
+ .sh3-swatch__btn:hover:not(:disabled) {
54
+ background: var(--shell-bg-elevated);
55
+ filter: none;
56
+ }
57
+ .sh3-swatch__btn:focus-visible { outline: none; box-shadow: var(--shell-focus-ring); }
58
+ .sh3-swatch__btn:disabled { opacity: 0.55; cursor: not-allowed; }
59
+ .sh3-swatch__dot {
60
+ width: 16px; height: 16px;
61
+ border: 1px solid var(--shell-border-strong);
62
+ border-radius: var(--shell-radius-sm);
63
+ background: var(--swatch-color);
64
+ }
65
+ .sh3-swatch__hex { font-family: var(--shell-font-mono); font-size: 0.75rem; }
66
+ </style>
@@ -0,0 +1,9 @@
1
+ type $$ComponentProps = {
2
+ value?: string;
3
+ label?: string;
4
+ disabled?: boolean;
5
+ size?: 'sm' | 'md';
6
+ };
7
+ declare const ColorSwatch: import("svelte").Component<$$ComponentProps, {}, "value">;
8
+ type ColorSwatch = ReturnType<typeof ColorSwatch>;
9
+ export default ColorSwatch;
@@ -0,0 +1,124 @@
1
+ <script lang="ts">
2
+ import type { Snippet } from 'svelte';
3
+
4
+ let {
5
+ value = $bindable(''),
6
+ type = 'text',
7
+ label,
8
+ placeholder,
9
+ prefix,
10
+ suffix,
11
+ helper,
12
+ error,
13
+ disabled = false,
14
+ invalid = false,
15
+ size = 'md',
16
+ required = false,
17
+ autocomplete,
18
+ }: {
19
+ value?: string;
20
+ type?: 'text' | 'email' | 'password' | 'search' | 'url' | 'tel';
21
+ label?: string;
22
+ placeholder?: string;
23
+ prefix?: Snippet;
24
+ suffix?: Snippet;
25
+ helper?: string;
26
+ error?: string;
27
+ disabled?: boolean;
28
+ invalid?: boolean;
29
+ size?: 'sm' | 'md';
30
+ required?: boolean;
31
+ autocomplete?: AutoFill;
32
+ } = $props();
33
+
34
+ const showError = $derived(invalid && !!error);
35
+ const helperText = $derived(showError ? error : helper);
36
+ </script>
37
+
38
+ <label class="sh3-field" class:sh3-field--invalid={invalid} class:sh3-field--sm={size === 'sm'}>
39
+ {#if label}<span class="sh3-field__label">{label}{#if required}<span aria-hidden="true"> *</span>{/if}</span>{/if}
40
+ <span class="sh3-field__row">
41
+ {#if prefix}<span class="sh3-field__affix">{@render prefix()}</span>{/if}
42
+ <input
43
+ class="sh3-field__input"
44
+ {type}
45
+ {placeholder}
46
+ {disabled}
47
+ {required}
48
+ {autocomplete}
49
+ aria-invalid={invalid || undefined}
50
+ bind:value
51
+ />
52
+ {#if suffix}<span class="sh3-field__affix">{@render suffix()}</span>{/if}
53
+ </span>
54
+ {#if helperText}<span class="sh3-field__helper" class:sh3-field__helper--error={showError}>{helperText}</span>{/if}
55
+ </label>
56
+
57
+ <style>
58
+ .sh3-field {
59
+ display: inline-flex;
60
+ flex-direction: column;
61
+ gap: 4px;
62
+ font-family: var(--shell-font-ui);
63
+ font-size: 0.8125rem;
64
+ color: var(--shell-fg);
65
+ }
66
+ .sh3-field__label {
67
+ color: var(--shell-fg-muted);
68
+ font-size: 0.75rem;
69
+ }
70
+ .sh3-field__row {
71
+ display: inline-flex;
72
+ align-items: stretch;
73
+ background: var(--shell-input-bg);
74
+ border: 1px solid var(--shell-border);
75
+ border-radius: var(--shell-widget-radius);
76
+ height: var(--shell-field-height-md);
77
+ transition: border-color var(--shell-motion-fast) var(--shell-ease-standard),
78
+ box-shadow var(--shell-motion-fast) var(--shell-ease-standard);
79
+ }
80
+ .sh3-field--sm .sh3-field__row { height: var(--shell-field-height-sm); }
81
+ .sh3-field__row:focus-within {
82
+ border-color: var(--shell-input-border-focus);
83
+ box-shadow: var(--shell-focus-ring);
84
+ }
85
+ .sh3-field--invalid .sh3-field__row {
86
+ border-color: var(--shell-error);
87
+ }
88
+ .sh3-field--invalid .sh3-field__row:focus-within {
89
+ box-shadow: 0 0 0 2px color-mix(in srgb, var(--shell-error) 40%, transparent);
90
+ }
91
+ .sh3-field__input {
92
+ flex: 1 1 auto;
93
+ height: 100%;
94
+ padding: 0 var(--shell-field-pad-x);
95
+ background: transparent;
96
+ border: none;
97
+ color: inherit;
98
+ font: inherit;
99
+ outline: none;
100
+ }
101
+ /* The .sh3-field__row owns the focus ring; suppress base.css's
102
+ global input:focus-visible box-shadow so it doesn't double up. */
103
+ .sh3-field__input:focus,
104
+ .sh3-field__input:focus-visible {
105
+ outline: none;
106
+ box-shadow: none;
107
+ border: none;
108
+ }
109
+ .sh3-field__input:disabled { color: var(--shell-fg-muted); cursor: not-allowed; }
110
+ .sh3-field__affix {
111
+ display: inline-flex;
112
+ align-items: center;
113
+ padding: 0 var(--shell-field-pad-x);
114
+ color: var(--shell-fg-muted);
115
+ flex-shrink: 0;
116
+ }
117
+ .sh3-field__affix:first-child { padding-right: 0; }
118
+ .sh3-field__affix:last-child { padding-left: 0; }
119
+ .sh3-field__helper {
120
+ color: var(--shell-fg-muted);
121
+ font-size: 0.75rem;
122
+ }
123
+ .sh3-field__helper--error { color: var(--shell-error); }
124
+ </style>
@@ -0,0 +1,19 @@
1
+ import type { Snippet } from 'svelte';
2
+ type $$ComponentProps = {
3
+ value?: string;
4
+ type?: 'text' | 'email' | 'password' | 'search' | 'url' | 'tel';
5
+ label?: string;
6
+ placeholder?: string;
7
+ prefix?: Snippet;
8
+ suffix?: Snippet;
9
+ helper?: string;
10
+ error?: string;
11
+ disabled?: boolean;
12
+ invalid?: boolean;
13
+ size?: 'sm' | 'md';
14
+ required?: boolean;
15
+ autocomplete?: AutoFill;
16
+ };
17
+ declare const Field: import("svelte").Component<$$ComponentProps, {}, "value">;
18
+ type Field = ReturnType<typeof Field>;
19
+ export default Field;
@@ -0,0 +1,3 @@
1
+ export type FilePickerValue = File | File[] | null;
2
+ export declare function extractValue(files: FileList | null, multiple: boolean): FilePickerValue;
3
+ export declare function displayName(value: FilePickerValue): string;
@@ -0,0 +1,19 @@
1
+ export function extractValue(files, multiple) {
2
+ if (!files || files.length === 0)
3
+ return multiple ? [] : null;
4
+ if (multiple)
5
+ return Array.from(files);
6
+ return files[0];
7
+ }
8
+ export function displayName(value) {
9
+ if (value === null)
10
+ return '';
11
+ if (Array.isArray(value)) {
12
+ if (value.length === 0)
13
+ return '';
14
+ if (value.length === 1)
15
+ return value[0].name;
16
+ return `${value.length} files`;
17
+ }
18
+ return value.name;
19
+ }