sh3-core 0.13.1 → 0.13.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 (172) hide show
  1. package/dist/BrandSlot.svelte +62 -13
  2. package/dist/__test__/setup-dom.js +5 -0
  3. package/dist/actions/MenuButton.svelte +2 -1
  4. package/dist/actions/contextMenuModel.d.ts +1 -1
  5. package/dist/actions/contextMenuModel.js +2 -1
  6. package/dist/actions/dispatcher.svelte.d.ts +1 -1
  7. package/dist/actions/dispatcher.svelte.js +2 -1
  8. package/dist/actions/listActive.d.ts +1 -1
  9. package/dist/actions/listActive.js +2 -1
  10. package/dist/actions/listeners.d.ts +1 -1
  11. package/dist/actions/listeners.js +6 -5
  12. package/dist/actions/menuBarModel.js +3 -2
  13. package/dist/actions/paletteModel.js +2 -1
  14. package/dist/actions/resolveLabel.test.js +14 -0
  15. package/dist/actions/types.d.ts +12 -1
  16. package/dist/actions/types.js +7 -1
  17. package/dist/api.d.ts +3 -0
  18. package/dist/api.js +3 -0
  19. package/dist/app/store/AppUpdateAvailableModal.svelte +87 -0
  20. package/dist/app/store/AppUpdateAvailableModal.svelte.d.ts +11 -0
  21. package/dist/app/store/InstalledView.svelte +8 -54
  22. package/dist/app/store/UninstallAppDialog.svelte +86 -0
  23. package/dist/app/store/UninstallAppDialog.svelte.d.ts +10 -0
  24. package/dist/app/store/permissionConfirm.d.ts +4 -0
  25. package/dist/app/store/permissionConfirm.js +28 -0
  26. package/dist/app/store/storeShard.svelte.d.ts +8 -1
  27. package/dist/app/store/storeShard.svelte.js +42 -9
  28. package/dist/app/store/updatePackage.test.d.ts +1 -0
  29. package/dist/app/store/updatePackage.test.js +34 -0
  30. package/dist/app/store/verbs.d.ts +1 -0
  31. package/dist/app/store/verbs.js +79 -5
  32. package/dist/app/store/verbs.test.d.ts +1 -0
  33. package/dist/app/store/verbs.test.js +56 -0
  34. package/dist/app-appearance/AppAppearanceModal.svelte +174 -0
  35. package/dist/app-appearance/AppAppearanceModal.svelte.d.ts +8 -0
  36. package/dist/app-appearance/appearanceShard.svelte.d.ts +2 -0
  37. package/dist/app-appearance/appearanceShard.svelte.js +61 -0
  38. package/dist/app-appearance/appearanceState.svelte.d.ts +15 -0
  39. package/dist/app-appearance/appearanceState.svelte.js +59 -0
  40. package/dist/app-appearance/appearanceState.test.d.ts +1 -0
  41. package/dist/app-appearance/appearanceState.test.js +30 -0
  42. package/dist/app-appearance/index.d.ts +3 -0
  43. package/dist/app-appearance/index.js +2 -0
  44. package/dist/app-appearance/types.d.ts +11 -0
  45. package/dist/app-appearance/types.js +1 -0
  46. package/dist/apps/lifecycle.js +10 -2
  47. package/dist/apps/types.d.ts +18 -4
  48. package/dist/apps/workspace-rekey.d.ts +1 -0
  49. package/dist/apps/workspace-rekey.js +35 -0
  50. package/dist/apps/workspace-rekey.test.d.ts +1 -0
  51. package/dist/apps/workspace-rekey.test.js +23 -0
  52. package/dist/assets/iconIds.generated.d.ts +2 -0
  53. package/dist/assets/iconIds.generated.js +154 -0
  54. package/dist/auth/admin-users.svelte.d.ts +9 -0
  55. package/dist/auth/admin-users.svelte.js +42 -0
  56. package/dist/auth/admin-users.test.d.ts +1 -0
  57. package/dist/auth/admin-users.test.js +52 -0
  58. package/dist/createShell.js +5 -5
  59. package/dist/documents/config.d.ts +5 -1
  60. package/dist/documents/config.js +16 -8
  61. package/dist/documents/index.d.ts +1 -1
  62. package/dist/documents/index.js +1 -1
  63. package/dist/host-entry.d.ts +1 -1
  64. package/dist/host-entry.js +1 -1
  65. package/dist/host.d.ts +1 -1
  66. package/dist/host.js +9 -2
  67. package/dist/primitives/Button.svelte +50 -4
  68. package/dist/primitives/Button.svelte.d.ts +3 -1
  69. package/dist/primitives/Collapsible.svelte +110 -0
  70. package/dist/primitives/Collapsible.svelte.d.ts +14 -0
  71. package/dist/primitives/widgets/AppPicker.svelte +41 -0
  72. package/dist/primitives/widgets/AppPicker.svelte.d.ts +9 -0
  73. package/dist/primitives/widgets/AppPicker.svelte.test.d.ts +1 -0
  74. package/dist/primitives/widgets/AppPicker.svelte.test.js +26 -0
  75. package/dist/primitives/widgets/AppPicker.test.d.ts +1 -0
  76. package/dist/primitives/widgets/AppPicker.test.js +74 -0
  77. package/dist/primitives/widgets/ColorSwatch.svelte +7 -2
  78. package/dist/primitives/widgets/ColorSwatch.svelte.d.ts +2 -1
  79. package/dist/primitives/widgets/ColorSwatch.svelte.test.d.ts +1 -0
  80. package/dist/primitives/widgets/ColorSwatch.svelte.test.js +31 -0
  81. package/dist/primitives/widgets/Field.svelte +4 -2
  82. package/dist/primitives/widgets/Field.svelte.d.ts +2 -2
  83. package/dist/primitives/widgets/Field.svelte.test.d.ts +1 -0
  84. package/dist/primitives/widgets/Field.svelte.test.js +33 -0
  85. package/dist/primitives/widgets/FilePicker.svelte +2 -2
  86. package/dist/primitives/widgets/FilePicker.svelte.d.ts +2 -2
  87. package/dist/primitives/widgets/FilePicker.svelte.test.d.ts +1 -0
  88. package/dist/primitives/widgets/FilePicker.svelte.test.js +31 -0
  89. package/dist/primitives/widgets/IconPicker.svelte +115 -0
  90. package/dist/primitives/widgets/IconPicker.svelte.d.ts +9 -0
  91. package/dist/primitives/widgets/IconPicker.svelte.test.d.ts +1 -0
  92. package/dist/primitives/widgets/IconPicker.svelte.test.js +43 -0
  93. package/dist/primitives/widgets/IconToggleGroup.svelte +4 -4
  94. package/dist/primitives/widgets/IconToggleGroup.svelte.d.ts +3 -3
  95. package/dist/primitives/widgets/IconToggleGroup.svelte.test.d.ts +1 -0
  96. package/dist/primitives/widgets/IconToggleGroup.svelte.test.js +40 -0
  97. package/dist/primitives/widgets/NumberInput.svelte +19 -9
  98. package/dist/primitives/widgets/NumberInput.svelte.d.ts +2 -2
  99. package/dist/primitives/widgets/NumberInput.svelte.test.d.ts +1 -0
  100. package/dist/primitives/widgets/NumberInput.svelte.test.js +48 -0
  101. package/dist/primitives/widgets/PickerList.d.ts +24 -0
  102. package/dist/primitives/widgets/PickerList.js +21 -0
  103. package/dist/primitives/widgets/PickerList.svelte +150 -0
  104. package/dist/primitives/widgets/PickerList.svelte.d.ts +16 -0
  105. package/dist/primitives/widgets/PickerList.svelte.test.d.ts +1 -0
  106. package/dist/primitives/widgets/PickerList.svelte.test.js +31 -0
  107. package/dist/primitives/widgets/PickerList.test.d.ts +1 -0
  108. package/dist/primitives/widgets/PickerList.test.js +218 -0
  109. package/dist/primitives/widgets/RangeSlider.svelte +11 -4
  110. package/dist/primitives/widgets/RangeSlider.svelte.d.ts +2 -2
  111. package/dist/primitives/widgets/RangeSlider.svelte.test.d.ts +1 -0
  112. package/dist/primitives/widgets/RangeSlider.svelte.test.js +38 -0
  113. package/dist/primitives/widgets/Segmented.svelte +4 -4
  114. package/dist/primitives/widgets/Segmented.svelte.d.ts +3 -3
  115. package/dist/primitives/widgets/Segmented.svelte.test.d.ts +1 -0
  116. package/dist/primitives/widgets/Segmented.svelte.test.js +25 -0
  117. package/dist/primitives/widgets/Select.svelte +4 -4
  118. package/dist/primitives/widgets/Select.svelte.d.ts +3 -3
  119. package/dist/primitives/widgets/Select.svelte.test.d.ts +1 -0
  120. package/dist/primitives/widgets/Select.svelte.test.js +37 -0
  121. package/dist/primitives/widgets/Slider.svelte +4 -2
  122. package/dist/primitives/widgets/Slider.svelte.d.ts +2 -2
  123. package/dist/primitives/widgets/Slider.svelte.test.d.ts +1 -0
  124. package/dist/primitives/widgets/Slider.svelte.test.js +22 -0
  125. package/dist/primitives/widgets/SliderGroup.svelte +4 -2
  126. package/dist/primitives/widgets/SliderGroup.svelte.d.ts +2 -2
  127. package/dist/primitives/widgets/SliderGroup.svelte.test.d.ts +1 -0
  128. package/dist/primitives/widgets/SliderGroup.svelte.test.js +34 -0
  129. package/dist/primitives/widgets/Textarea.svelte +5 -2
  130. package/dist/primitives/widgets/Textarea.svelte.d.ts +2 -2
  131. package/dist/primitives/widgets/Textarea.svelte.test.d.ts +1 -0
  132. package/dist/primitives/widgets/Textarea.svelte.test.js +29 -0
  133. package/dist/primitives/widgets/UserPicker.svelte +53 -0
  134. package/dist/primitives/widgets/UserPicker.svelte.d.ts +9 -0
  135. package/dist/primitives/widgets/UserPicker.svelte.test.d.ts +1 -0
  136. package/dist/primitives/widgets/UserPicker.svelte.test.js +30 -0
  137. package/dist/primitives/widgets/UserPicker.test.d.ts +1 -0
  138. package/dist/primitives/widgets/UserPicker.test.js +115 -0
  139. package/dist/primitives/widgets/_contract.d.ts +27 -0
  140. package/dist/primitives/widgets/_contract.js +10 -0
  141. package/dist/projects/session-state.svelte.d.ts +17 -0
  142. package/dist/projects/session-state.svelte.js +39 -0
  143. package/dist/projects/session-state.test.d.ts +1 -0
  144. package/dist/projects/session-state.test.js +55 -0
  145. package/dist/projects-shard/DeleteProjectDialog.svelte +150 -0
  146. package/dist/projects-shard/DeleteProjectDialog.svelte.d.ts +12 -0
  147. package/dist/projects-shard/DeleteProjectDialog.test.d.ts +1 -0
  148. package/dist/projects-shard/DeleteProjectDialog.test.js +120 -0
  149. package/dist/projects-shard/ProjectManage.svelte +219 -0
  150. package/dist/projects-shard/ProjectManage.svelte.d.ts +8 -0
  151. package/dist/projects-shard/ProjectsSection.svelte +120 -0
  152. package/dist/projects-shard/ProjectsSection.svelte.d.ts +3 -0
  153. package/dist/projects-shard/index.d.ts +4 -0
  154. package/dist/projects-shard/index.js +4 -0
  155. package/dist/projects-shard/projectsApi.d.ts +20 -0
  156. package/dist/projects-shard/projectsApi.js +44 -0
  157. package/dist/projects-shard/projectsApi.test.d.ts +1 -0
  158. package/dist/projects-shard/projectsApi.test.js +71 -0
  159. package/dist/projects-shard/projectsShard.svelte.d.ts +10 -0
  160. package/dist/projects-shard/projectsShard.svelte.js +148 -0
  161. package/dist/sh3core-shard/ShellHome.svelte +83 -39
  162. package/dist/sh3core-shard/appActions.d.ts +13 -0
  163. package/dist/sh3core-shard/appActions.js +181 -0
  164. package/dist/sh3core-shard/appActions.test.d.ts +1 -0
  165. package/dist/sh3core-shard/appActions.test.js +25 -0
  166. package/dist/sh3core-shard/sh3coreShard.svelte.js +2 -0
  167. package/dist/shards/activate-scopeid.test.d.ts +1 -0
  168. package/dist/shards/{activate-tenantid.test.js → activate-scopeid.test.js} +6 -6
  169. package/dist/version.d.ts +1 -1
  170. package/dist/version.js +1 -1
  171. package/package.json +2 -2
  172. /package/dist/{shards/activate-tenantid.test.d.ts → actions/resolveLabel.test.d.ts} +0 -0
@@ -0,0 +1,37 @@
1
+ import { describe, it, expect, vi } from 'vitest';
2
+ import { render, fireEvent } from '@testing-library/svelte';
3
+ import Select from './Select.svelte';
4
+ vi.mock('../../shellRuntime.svelte', () => ({
5
+ shell: { popup: { show: () => ({ close: () => { } }) } },
6
+ }));
7
+ describe('Select event contract', () => {
8
+ const options = [
9
+ { value: 'a', label: 'Apple' },
10
+ { value: 'b', label: 'Banana' },
11
+ { value: 'c', label: 'Cherry' },
12
+ ];
13
+ it('fires onchange via native select in single mode', async () => {
14
+ const onchange = vi.fn();
15
+ const { container } = render(Select, { props: { options, value: 'a', onchange } });
16
+ const native = container.querySelector('select');
17
+ native.value = 'c';
18
+ await fireEvent.change(native);
19
+ expect(onchange).toHaveBeenCalledTimes(1);
20
+ expect(onchange).toHaveBeenCalledWith('c');
21
+ });
22
+ it('defaults to empty array in multi mode', () => {
23
+ const { container } = render(Select, { props: { options, multiple: true } });
24
+ const native = container.querySelector('select');
25
+ // No <option> should carry a `selected` attribute when value defaults to [].
26
+ // (happy-dom may surface a selectedOptions item for the first option in a
27
+ // multi-select that has no explicit selection — assert the attribute the
28
+ // template controls instead.)
29
+ const optionsEls = Array.from(native.querySelectorAll('option'));
30
+ expect(optionsEls.every((o) => !o.hasAttribute('selected'))).toBe(true);
31
+ });
32
+ it('defaults to empty string in single mode', () => {
33
+ const { container } = render(Select, { props: { options } });
34
+ const trigger = container.querySelector('.sh3-select__display');
35
+ expect(trigger === null || trigger === void 0 ? void 0 : trigger.classList.contains('sh3-select__display--placeholder')).toBe(true);
36
+ });
37
+ });
@@ -1,4 +1,5 @@
1
1
  <script lang="ts">
2
+ import type { LiveInputEvents } from './_contract';
2
3
  import { valueToPercent } from './Slider';
3
4
 
4
5
  let {
@@ -12,6 +13,7 @@
12
13
  invalid = false,
13
14
  size = 'md',
14
15
  orientation = 'horizontal',
16
+ oninput,
15
17
  onchange,
16
18
  }: {
17
19
  value?: number;
@@ -24,8 +26,7 @@
24
26
  invalid?: boolean;
25
27
  size?: 'sm' | 'md';
26
28
  orientation?: 'horizontal' | 'vertical';
27
- onchange?: (next: number) => void;
28
- } = $props();
29
+ } & LiveInputEvents<number> = $props();
29
30
 
30
31
  const pct = $derived(valueToPercent(value, min, max));
31
32
  </script>
@@ -50,6 +51,7 @@
50
51
  {disabled}
51
52
  aria-invalid={invalid || undefined}
52
53
  bind:value
54
+ oninput={() => oninput?.(value)}
53
55
  onchange={() => onchange?.(value)}
54
56
  />
55
57
  {#if showValue}
@@ -1,3 +1,4 @@
1
+ import type { LiveInputEvents } from './_contract';
1
2
  type $$ComponentProps = {
2
3
  value?: number;
3
4
  min?: number;
@@ -9,8 +10,7 @@ type $$ComponentProps = {
9
10
  invalid?: boolean;
10
11
  size?: 'sm' | 'md';
11
12
  orientation?: 'horizontal' | 'vertical';
12
- onchange?: (next: number) => void;
13
- };
13
+ } & LiveInputEvents<number>;
14
14
  declare const Slider: import("svelte").Component<$$ComponentProps, {}, "value">;
15
15
  type Slider = ReturnType<typeof Slider>;
16
16
  export default Slider;
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,22 @@
1
+ import { describe, it, expect, vi } from 'vitest';
2
+ import { render, fireEvent } from '@testing-library/svelte';
3
+ import Slider from './Slider.svelte';
4
+ describe('Slider event contract', () => {
5
+ it('fires oninput on native input event', async () => {
6
+ const oninput = vi.fn();
7
+ const { container } = render(Slider, { props: { value: 0, min: 0, max: 100, oninput } });
8
+ const range = container.querySelector('input[type=range]');
9
+ await fireEvent.input(range, { target: { value: '42' } });
10
+ expect(oninput).toHaveBeenCalledTimes(1);
11
+ expect(oninput).toHaveBeenCalledWith(42);
12
+ });
13
+ it('fires onchange on native change event', async () => {
14
+ const onchange = vi.fn();
15
+ const { container } = render(Slider, { props: { value: 0, min: 0, max: 100, onchange } });
16
+ const range = container.querySelector('input[type=range]');
17
+ await fireEvent.input(range, { target: { value: '70' } });
18
+ await fireEvent.change(range, { target: { value: '70' } });
19
+ expect(onchange).toHaveBeenCalledTimes(1);
20
+ expect(onchange).toHaveBeenCalledWith(70);
21
+ });
22
+ });
@@ -1,4 +1,5 @@
1
1
  <script lang="ts">
2
+ import type { LiveInputEvents } from './_contract';
2
3
  import Slider from './Slider.svelte';
3
4
 
4
5
  type Channel = { id: string; label: string; min?: number; max?: number; step?: number };
@@ -10,6 +11,7 @@
10
11
  showValues = false,
11
12
  disabled = false,
12
13
  size = 'md',
14
+ oninput,
13
15
  onchange,
14
16
  }: {
15
17
  orientation?: 'horizontal' | 'vertical';
@@ -18,8 +20,7 @@
18
20
  showValues?: boolean;
19
21
  disabled?: boolean;
20
22
  size?: 'sm' | 'md';
21
- onchange?: (next: Record<string, number>) => void;
22
- } = $props();
23
+ } & LiveInputEvents<Record<string, number>> = $props();
23
24
  </script>
24
25
 
25
26
  <div class="sh3-sg sh3-sg--{orientation}">
@@ -38,6 +39,7 @@
38
39
  () => values[ch.id] ?? 0,
39
40
  (n: number) => values = { ...values, [ch.id]: n }
40
41
  }
42
+ oninput={() => oninput?.(values)}
41
43
  onchange={() => onchange?.(values)}
42
44
  />
43
45
  </div>
@@ -1,3 +1,4 @@
1
+ import type { LiveInputEvents } from './_contract';
1
2
  type Channel = {
2
3
  id: string;
3
4
  label: string;
@@ -12,8 +13,7 @@ type $$ComponentProps = {
12
13
  showValues?: boolean;
13
14
  disabled?: boolean;
14
15
  size?: 'sm' | 'md';
15
- onchange?: (next: Record<string, number>) => void;
16
- };
16
+ } & LiveInputEvents<Record<string, number>>;
17
17
  declare const SliderGroup: import("svelte").Component<$$ComponentProps, {}, "values">;
18
18
  type SliderGroup = ReturnType<typeof SliderGroup>;
19
19
  export default SliderGroup;
@@ -0,0 +1,34 @@
1
+ import { describe, it, expect, vi } from 'vitest';
2
+ import { render, fireEvent } from '@testing-library/svelte';
3
+ import SliderGroup from './SliderGroup.svelte';
4
+ describe('SliderGroup event contract', () => {
5
+ const channels = [
6
+ { id: 'r', label: 'R' },
7
+ { id: 'g', label: 'G' },
8
+ { id: 'b', label: 'B' },
9
+ ];
10
+ it('emits oninput with full record when a child slider moves', async () => {
11
+ const oninput = vi.fn();
12
+ const { container } = render(SliderGroup, {
13
+ props: { channels, values: { r: 0, g: 0, b: 0 }, oninput },
14
+ });
15
+ const ranges = container.querySelectorAll('input[type=range]');
16
+ const gSlider = ranges[1];
17
+ await fireEvent.input(gSlider, { target: { value: '50' } });
18
+ expect(oninput).toHaveBeenCalled();
19
+ const lastCall = oninput.mock.calls[oninput.mock.calls.length - 1][0];
20
+ expect(lastCall).toEqual({ r: 0, g: 50, b: 0 });
21
+ });
22
+ it('emits onchange with full record on commit', async () => {
23
+ const onchange = vi.fn();
24
+ const { container } = render(SliderGroup, {
25
+ props: { channels, values: { r: 0, g: 0, b: 0 }, onchange },
26
+ });
27
+ const bSlider = container.querySelectorAll('input[type=range]')[2];
28
+ await fireEvent.input(bSlider, { target: { value: '75' } });
29
+ await fireEvent.change(bSlider, { target: { value: '75' } });
30
+ expect(onchange).toHaveBeenCalled();
31
+ const lastCall = onchange.mock.calls[onchange.mock.calls.length - 1][0];
32
+ expect(lastCall).toEqual({ r: 0, g: 0, b: 75 });
33
+ });
34
+ });
@@ -1,4 +1,6 @@
1
1
  <script lang="ts">
2
+ import type { LiveInputEvents } from './_contract';
3
+
2
4
  let {
3
5
  value = $bindable(''),
4
6
  label,
@@ -11,6 +13,7 @@
11
13
  required = false,
12
14
  rows = 3,
13
15
  resize = 'vertical',
16
+ oninput,
14
17
  onchange,
15
18
  }: {
16
19
  value?: string;
@@ -24,8 +27,7 @@
24
27
  required?: boolean;
25
28
  rows?: number;
26
29
  resize?: 'none' | 'vertical' | 'both';
27
- onchange?: (next: string) => void;
28
- } = $props();
30
+ } & LiveInputEvents<string> = $props();
29
31
 
30
32
  const showError = $derived(invalid && !!error);
31
33
  const helperText = $derived(showError ? error : helper);
@@ -42,6 +44,7 @@
42
44
  {rows}
43
45
  aria-invalid={invalid || undefined}
44
46
  bind:value
47
+ oninput={() => oninput?.(value)}
45
48
  onblur={() => onchange?.(value)}
46
49
  ></textarea>
47
50
  {#if helperText}<span class="sh3-textarea__helper" class:sh3-textarea__helper--error={showError}>{helperText}</span>{/if}
@@ -1,3 +1,4 @@
1
+ import type { LiveInputEvents } from './_contract';
1
2
  type $$ComponentProps = {
2
3
  value?: string;
3
4
  label?: string;
@@ -10,8 +11,7 @@ type $$ComponentProps = {
10
11
  required?: boolean;
11
12
  rows?: number;
12
13
  resize?: 'none' | 'vertical' | 'both';
13
- onchange?: (next: string) => void;
14
- };
14
+ } & LiveInputEvents<string>;
15
15
  declare const Textarea: import("svelte").Component<$$ComponentProps, {}, "value">;
16
16
  type Textarea = ReturnType<typeof Textarea>;
17
17
  export default Textarea;
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,29 @@
1
+ import { describe, it, expect, vi } from 'vitest';
2
+ import { render, fireEvent } from '@testing-library/svelte';
3
+ import Textarea from './Textarea.svelte';
4
+ describe('Textarea event contract', () => {
5
+ it('fires oninput on every keystroke', async () => {
6
+ const oninput = vi.fn();
7
+ const { container } = render(Textarea, { props: { value: '', oninput } });
8
+ const ta = container.querySelector('textarea');
9
+ await fireEvent.input(ta, { target: { value: 'a' } });
10
+ await fireEvent.input(ta, { target: { value: 'ab' } });
11
+ expect(oninput).toHaveBeenCalledTimes(2);
12
+ expect(oninput).toHaveBeenNthCalledWith(2, 'ab');
13
+ });
14
+ it('fires onchange once on blur with final value', async () => {
15
+ const onchange = vi.fn();
16
+ const { container } = render(Textarea, { props: { value: '', onchange } });
17
+ const ta = container.querySelector('textarea');
18
+ await fireEvent.input(ta, { target: { value: 'done' } });
19
+ await fireEvent.blur(ta);
20
+ expect(onchange).toHaveBeenCalledTimes(1);
21
+ expect(onchange).toHaveBeenCalledWith('done');
22
+ });
23
+ it('propagates rows and resize props', () => {
24
+ const { container } = render(Textarea, { props: { value: '', rows: 5, resize: 'none' } });
25
+ const ta = container.querySelector('textarea');
26
+ expect(ta.getAttribute('rows')).toBe('5');
27
+ expect(ta.style.resize).toBe('none');
28
+ });
29
+ });
@@ -0,0 +1,53 @@
1
+ <script lang="ts">
2
+ import type { CommitOnlyEvents } from './_contract';
3
+ import PickerList from './PickerList.svelte';
4
+ import type { PickerItem } from './PickerList';
5
+ import { usersAdminState, refreshAdminUsers } from '../../auth/admin-users.svelte';
6
+
7
+ let {
8
+ value = $bindable<string[]>([]),
9
+ onchange,
10
+ disabled = false,
11
+ size = 'md',
12
+ }: {
13
+ value?: string[];
14
+ disabled?: boolean;
15
+ size?: 'sm' | 'md';
16
+ } & CommitOnlyEvents<string[]> = $props();
17
+
18
+ // Kick a fetch if the cache is empty. The store dedupes concurrent calls,
19
+ // so multiple UserPicker mounts share one request. After completion
20
+ // subsequent mounts see the cached state.
21
+ if (
22
+ usersAdminState.users.length === 0 &&
23
+ !usersAdminState.loading &&
24
+ !usersAdminState.error
25
+ ) {
26
+ void refreshAdminUsers();
27
+ }
28
+
29
+ const items = $derived<PickerItem[]>(
30
+ usersAdminState.users.map((u) => ({
31
+ id: u.id,
32
+ label: u.displayName || u.username,
33
+ sublabel: u.username,
34
+ })),
35
+ );
36
+
37
+ function handleChange(next: string[]) {
38
+ value = next;
39
+ onchange?.(next);
40
+ }
41
+ </script>
42
+
43
+ <PickerList
44
+ {items}
45
+ {value}
46
+ onchange={handleChange}
47
+ loading={usersAdminState.loading}
48
+ error={usersAdminState.error}
49
+ onRetry={() => void refreshAdminUsers()}
50
+ emptyText="No users found."
51
+ {disabled}
52
+ {size}
53
+ />
@@ -0,0 +1,9 @@
1
+ import type { CommitOnlyEvents } from './_contract';
2
+ type $$ComponentProps = {
3
+ value?: string[];
4
+ disabled?: boolean;
5
+ size?: 'sm' | 'md';
6
+ } & CommitOnlyEvents<string[]>;
7
+ declare const UserPicker: import("svelte").Component<$$ComponentProps, {}, "value">;
8
+ type UserPicker = ReturnType<typeof UserPicker>;
9
+ export default UserPicker;
@@ -0,0 +1,30 @@
1
+ import { describe, it, expect, vi } from 'vitest';
2
+ import { render, fireEvent } from '@testing-library/svelte';
3
+ import UserPicker from './UserPicker.svelte';
4
+ const refresh = vi.fn();
5
+ vi.mock('../../auth/admin-users.svelte', () => ({
6
+ usersAdminState: {
7
+ users: [
8
+ { id: 'u1', displayName: 'Alice', username: 'alice' },
9
+ { id: 'u2', displayName: '', username: 'bob' },
10
+ ],
11
+ loading: false,
12
+ error: null,
13
+ },
14
+ refreshAdminUsers: () => refresh(),
15
+ }));
16
+ describe('UserPicker event contract', () => {
17
+ it('renders cached users with displayName fallback to username', () => {
18
+ const { container } = render(UserPicker, { props: { value: [] } });
19
+ const labels = Array.from(container.querySelectorAll('.sh3-picker__row-label')).map((el) => el.textContent);
20
+ expect(labels).toEqual(['Alice', 'bob']);
21
+ });
22
+ it('fires onchange with id array on toggle', async () => {
23
+ const onchange = vi.fn();
24
+ const { container } = render(UserPicker, { props: { value: [], onchange } });
25
+ const checkboxes = container.querySelectorAll('input[type=checkbox]');
26
+ await fireEvent.click(checkboxes[0]);
27
+ expect(onchange).toHaveBeenCalledTimes(1);
28
+ expect(onchange).toHaveBeenCalledWith(['u1']);
29
+ });
30
+ });
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,115 @@
1
+ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
2
+ import { mount, unmount, tick } from 'svelte';
3
+ import UserPicker from './UserPicker.svelte';
4
+ import { usersAdminState, __resetAdminUsersForTest, } from '../../auth/admin-users.svelte';
5
+ let host;
6
+ let cmp = null;
7
+ beforeEach(() => {
8
+ host = document.createElement('div');
9
+ document.body.appendChild(host);
10
+ __resetAdminUsersForTest();
11
+ vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
12
+ ok: true,
13
+ json: async () => [
14
+ { id: 'u-1', username: 'alice', displayName: 'Alice', role: 'user', createdAt: '', updatedAt: '' },
15
+ { id: 'u-2', username: 'bob', displayName: 'Bob', role: 'admin', createdAt: '', updatedAt: '' },
16
+ ],
17
+ }));
18
+ });
19
+ afterEach(() => {
20
+ if (cmp) {
21
+ unmount(cmp);
22
+ cmp = null;
23
+ }
24
+ host.remove();
25
+ __resetAdminUsersForTest();
26
+ });
27
+ describe('UserPicker', () => {
28
+ it('triggers a fetch on first mount and renders rows once loaded', async () => {
29
+ cmp = mount(UserPicker, { target: host, props: { value: [] } });
30
+ expect(globalThis.fetch).toHaveBeenCalledTimes(1);
31
+ await new Promise((r) => setTimeout(r, 0));
32
+ await tick();
33
+ const rows = host.querySelectorAll('.sh3-picker__row');
34
+ expect(rows.length).toBe(2);
35
+ });
36
+ it('reflects selected user ids as checked rows', async () => {
37
+ // Pre-populate the cache so the mount can render synchronously.
38
+ usersAdminState.users = [
39
+ { id: 'u-1', username: 'alice', displayName: 'Alice', role: 'user', createdAt: '', updatedAt: '' },
40
+ { id: 'u-2', username: 'bob', displayName: 'Bob', role: 'admin', createdAt: '', updatedAt: '' },
41
+ ];
42
+ cmp = mount(UserPicker, { target: host, props: { value: ['u-2'] } });
43
+ await tick();
44
+ const checks = host.querySelectorAll('input[type="checkbox"]');
45
+ const aliceChecked = Array.from(checks).find((c) => { var _a, _b; return (_b = (_a = c.closest('.sh3-picker__row')) === null || _a === void 0 ? void 0 : _a.textContent) === null || _b === void 0 ? void 0 : _b.includes('alice'); }).checked;
46
+ const bobChecked = Array.from(checks).find((c) => { var _a, _b; return (_b = (_a = c.closest('.sh3-picker__row')) === null || _a === void 0 ? void 0 : _a.textContent) === null || _b === void 0 ? void 0 : _b.includes('bob'); }).checked;
47
+ expect(aliceChecked).toBe(false);
48
+ expect(bobChecked).toBe(true);
49
+ });
50
+ it('subsequent mounts reuse the cached state and skip the fetch', async () => {
51
+ cmp = mount(UserPicker, { target: host, props: { value: [] } });
52
+ await new Promise((r) => setTimeout(r, 0));
53
+ await tick();
54
+ expect(globalThis.fetch).toHaveBeenCalledTimes(1);
55
+ const host2 = document.createElement('div');
56
+ document.body.appendChild(host2);
57
+ const cmp2 = mount(UserPicker, { target: host2, props: { value: [] } });
58
+ await tick();
59
+ expect(globalThis.fetch).toHaveBeenCalledTimes(1);
60
+ unmount(cmp2);
61
+ host2.remove();
62
+ });
63
+ it('uses displayName for label and username for sublabel', async () => {
64
+ usersAdminState.users = [
65
+ { id: 'u-1', username: 'alice', displayName: 'Alice', role: 'user', createdAt: '', updatedAt: '' },
66
+ { id: 'u-2', username: 'bob', displayName: 'Bob', role: 'admin', createdAt: '', updatedAt: '' },
67
+ ];
68
+ cmp = mount(UserPicker, { target: host, props: { value: [] } });
69
+ await tick();
70
+ const labels = host.querySelectorAll('.sh3-picker__row-label');
71
+ const subs = host.querySelectorAll('.sh3-picker__row-sub');
72
+ const labelTexts = Array.from(labels).map((l) => l.textContent).sort();
73
+ const subTexts = Array.from(subs).map((l) => l.textContent).sort();
74
+ expect(labelTexts).toEqual(['Alice', 'Bob']);
75
+ expect(subTexts).toEqual(['alice', 'bob']);
76
+ });
77
+ it('fires onchange with the new value array on row click', async () => {
78
+ usersAdminState.users = [
79
+ { id: 'u-1', username: 'alice', displayName: 'Alice', role: 'user', createdAt: '', updatedAt: '' },
80
+ { id: 'u-2', username: 'bob', displayName: 'Bob', role: 'admin', createdAt: '', updatedAt: '' },
81
+ ];
82
+ let received = null;
83
+ cmp = mount(UserPicker, {
84
+ target: host,
85
+ props: {
86
+ value: [],
87
+ onchange: (next) => { received = next; },
88
+ },
89
+ });
90
+ await tick();
91
+ const aliceRow = Array.from(host.querySelectorAll('.sh3-picker__row'))
92
+ .find((r) => { var _a; return (_a = r.textContent) === null || _a === void 0 ? void 0 : _a.includes('alice'); });
93
+ aliceRow.querySelector('input[type="checkbox"]').click();
94
+ await tick();
95
+ expect(received).toEqual(['u-1']);
96
+ });
97
+ it('shows the loading state before the first response', async () => {
98
+ let resolveFetch = null;
99
+ globalThis.fetch.mockReset();
100
+ globalThis.fetch.mockImplementationOnce(() => new Promise((res) => { resolveFetch = res; }));
101
+ cmp = mount(UserPicker, { target: host, props: { value: [] } });
102
+ await tick();
103
+ expect(host.textContent).toContain('Loading');
104
+ resolveFetch({ ok: true, json: async () => [] });
105
+ });
106
+ it('shows an error and a Retry button on fetch failure', async () => {
107
+ globalThis.fetch.mockReset();
108
+ globalThis.fetch.mockResolvedValueOnce({ ok: false, status: 500 });
109
+ cmp = mount(UserPicker, { target: host, props: { value: [] } });
110
+ await new Promise((r) => setTimeout(r, 0));
111
+ await tick();
112
+ expect(host.textContent).toMatch(/500/);
113
+ expect(host.querySelector('.sh3-picker__retry')).not.toBeNull();
114
+ });
115
+ });
@@ -0,0 +1,27 @@
1
+ /**
2
+ * Widget event-contract types — codifies which widgets expose `oninput`
3
+ * (live, during-interaction) vs `onchange`-only (commit-only).
4
+ *
5
+ * Each widget under `widgets/` MUST compose one of these into its prop
6
+ * type. Adding a third bucket requires a new ADR-022 amendment.
7
+ *
8
+ * Internal — not exported from `api.ts`.
9
+ */
10
+ /**
11
+ * Widgets with a continuous interaction phase (typing, dragging).
12
+ * - oninput fires on every internal value change (keystroke, drag tick).
13
+ * - onchange fires once at commit (blur for text inputs, mouseup / native
14
+ * change for sliders).
15
+ * Mirrors HTML <input>'s input vs change distinction.
16
+ */
17
+ export type LiveInputEvents<T> = {
18
+ oninput?: (next: T) => void;
19
+ onchange?: (next: T) => void;
20
+ };
21
+ /**
22
+ * Widgets whose interaction is a single discrete commit (click, pick).
23
+ * - onchange fires once when the value changes. No oninput.
24
+ */
25
+ export type CommitOnlyEvents<T> = {
26
+ onchange?: (next: T) => void;
27
+ };
@@ -0,0 +1,10 @@
1
+ /**
2
+ * Widget event-contract types — codifies which widgets expose `oninput`
3
+ * (live, during-interaction) vs `onchange`-only (commit-only).
4
+ *
5
+ * Each widget under `widgets/` MUST compose one of these into its prop
6
+ * type. Adding a third bucket requires a new ADR-022 amendment.
7
+ *
8
+ * Internal — not exported from `api.ts`.
9
+ */
10
+ export {};
@@ -0,0 +1,17 @@
1
+ export declare const sessionState: {
2
+ activeProjectId: string | null;
3
+ };
4
+ /**
5
+ * Set the active project id. Side-effects when the value actually changes:
6
+ * - the active app (if any) is unloaded — its document handles and zone
7
+ * state are bound to the previous scope and cannot follow.
8
+ * - the breadcrumb pointer is cleared so re-entering an app cannot point
9
+ * at the wrong namespace.
10
+ *
11
+ * Setting the same value is a no-op (no app unload, no breadcrumb clear).
12
+ *
13
+ * The unloadApp call is deferred to a dynamic import so this module stays
14
+ * importable from circular-dependency hot-paths (lifecycle imports
15
+ * sessionState; sessionState here would otherwise import lifecycle eagerly).
16
+ */
17
+ export declare function setActiveProjectId(id: string | null): void;
@@ -0,0 +1,39 @@
1
+ /*
2
+ * Session-zone state for the active project scope.
3
+ *
4
+ * `null` means the user is in their personal scope. Otherwise it carries
5
+ * the active project's id. Read at app launch by `launchApp` to bind
6
+ * `ctx.scopeId`, and read by ShellHome / BrandSlot for UX rendering.
7
+ *
8
+ * Setting the slot through `setActiveProjectId` is the only supported
9
+ * mutation path: it clears the breadcrumb pointer (so resuming an app
10
+ * after a scope change can't open a stale doc handle) and unloads any
11
+ * active app (whose scopeId is bound to the old scope).
12
+ */
13
+ import { activeApp, breadcrumbApp } from '../apps/registry.svelte';
14
+ export const sessionState = $state({
15
+ activeProjectId: null,
16
+ });
17
+ /**
18
+ * Set the active project id. Side-effects when the value actually changes:
19
+ * - the active app (if any) is unloaded — its document handles and zone
20
+ * state are bound to the previous scope and cannot follow.
21
+ * - the breadcrumb pointer is cleared so re-entering an app cannot point
22
+ * at the wrong namespace.
23
+ *
24
+ * Setting the same value is a no-op (no app unload, no breadcrumb clear).
25
+ *
26
+ * The unloadApp call is deferred to a dynamic import so this module stays
27
+ * importable from circular-dependency hot-paths (lifecycle imports
28
+ * sessionState; sessionState here would otherwise import lifecycle eagerly).
29
+ */
30
+ export function setActiveProjectId(id) {
31
+ if (sessionState.activeProjectId === id)
32
+ return;
33
+ const previousActive = activeApp.id;
34
+ sessionState.activeProjectId = id;
35
+ breadcrumbApp.id = null;
36
+ if (previousActive) {
37
+ void import('../apps/lifecycle').then((m) => m.unloadApp(previousActive));
38
+ }
39
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,55 @@
1
+ import { describe, it, expect, beforeEach, vi } from 'vitest';
2
+ import { sessionState, setActiveProjectId } from './session-state.svelte';
3
+ import { breadcrumbApp, activeApp } from '../apps/registry.svelte';
4
+ vi.mock('../apps/lifecycle', () => ({
5
+ unloadApp: vi.fn(),
6
+ }));
7
+ describe('sessionState.activeProjectId', () => {
8
+ beforeEach(async () => {
9
+ sessionState.activeProjectId = null;
10
+ breadcrumbApp.id = null;
11
+ activeApp.id = null;
12
+ const lifecycle = await import('../apps/lifecycle');
13
+ lifecycle.unloadApp.mockClear();
14
+ });
15
+ it('starts as null', () => {
16
+ expect(sessionState.activeProjectId).toBeNull();
17
+ });
18
+ it('setActiveProjectId updates the slot', () => {
19
+ setActiveProjectId('acme-abcd');
20
+ expect(sessionState.activeProjectId).toBe('acme-abcd');
21
+ });
22
+ it('setActiveProjectId clears breadcrumbApp.id when the value actually changes', () => {
23
+ breadcrumbApp.id = 'notes';
24
+ setActiveProjectId('acme-abcd');
25
+ expect(breadcrumbApp.id).toBeNull();
26
+ });
27
+ it('setActiveProjectId(null) also clears breadcrumb', () => {
28
+ sessionState.activeProjectId = 'acme-abcd';
29
+ breadcrumbApp.id = 'notes';
30
+ setActiveProjectId(null);
31
+ expect(breadcrumbApp.id).toBeNull();
32
+ expect(sessionState.activeProjectId).toBeNull();
33
+ });
34
+ it('setting the same value is a no-op (does not clear breadcrumb)', () => {
35
+ setActiveProjectId('acme-abcd');
36
+ breadcrumbApp.id = 'notes';
37
+ setActiveProjectId('acme-abcd');
38
+ expect(breadcrumbApp.id).toBe('notes');
39
+ });
40
+ it('unloads the active app on scope change (its doc handle is scope-bound)', async () => {
41
+ const lifecycle = await import('../apps/lifecycle');
42
+ activeApp.id = 'notes';
43
+ setActiveProjectId('acme-abcd');
44
+ // unloadApp is called via a dynamic import — wait for it to resolve.
45
+ await new Promise((r) => setTimeout(r, 0));
46
+ expect(lifecycle.unloadApp).toHaveBeenCalledWith('notes');
47
+ });
48
+ it('does not call unloadApp when no app is active', async () => {
49
+ const lifecycle = await import('../apps/lifecycle');
50
+ activeApp.id = null;
51
+ setActiveProjectId('acme-abcd');
52
+ await Promise.resolve();
53
+ expect(lifecycle.unloadApp).not.toHaveBeenCalled();
54
+ });
55
+ });