sh3-core 0.1.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 (134) hide show
  1. package/dist/Shell.svelte +185 -0
  2. package/dist/Shell.svelte.d.ts +4 -0
  3. package/dist/api.d.ts +22 -0
  4. package/dist/api.js +45 -0
  5. package/dist/apps/lifecycle.d.ts +37 -0
  6. package/dist/apps/lifecycle.js +153 -0
  7. package/dist/apps/registry.svelte.d.ts +37 -0
  8. package/dist/apps/registry.svelte.js +60 -0
  9. package/dist/apps/types.d.ts +61 -0
  10. package/dist/apps/types.js +10 -0
  11. package/dist/assets/icons.svg +1119 -0
  12. package/dist/auth/auth.svelte.d.ts +44 -0
  13. package/dist/auth/auth.svelte.js +119 -0
  14. package/dist/auth/index.d.ts +1 -0
  15. package/dist/auth/index.js +1 -0
  16. package/dist/build.d.ts +29 -0
  17. package/dist/build.js +85 -0
  18. package/dist/contract.d.ts +20 -0
  19. package/dist/contract.js +28 -0
  20. package/dist/documents/backends.d.ts +17 -0
  21. package/dist/documents/backends.js +156 -0
  22. package/dist/documents/config.d.ts +7 -0
  23. package/dist/documents/config.js +27 -0
  24. package/dist/documents/handle.d.ts +6 -0
  25. package/dist/documents/handle.js +154 -0
  26. package/dist/documents/http-backend.d.ts +22 -0
  27. package/dist/documents/http-backend.js +78 -0
  28. package/dist/documents/index.d.ts +6 -0
  29. package/dist/documents/index.js +8 -0
  30. package/dist/documents/notifications.d.ts +9 -0
  31. package/dist/documents/notifications.js +39 -0
  32. package/dist/documents/types.d.ts +97 -0
  33. package/dist/documents/types.js +12 -0
  34. package/dist/host-entry.d.ts +9 -0
  35. package/dist/host-entry.js +15 -0
  36. package/dist/host.d.ts +13 -0
  37. package/dist/host.js +73 -0
  38. package/dist/index.d.ts +2 -0
  39. package/dist/index.js +13 -0
  40. package/dist/layout/DragPreview.svelte +63 -0
  41. package/dist/layout/DragPreview.svelte.d.ts +3 -0
  42. package/dist/layout/LayoutRenderer.svelte +260 -0
  43. package/dist/layout/LayoutRenderer.svelte.d.ts +6 -0
  44. package/dist/layout/SlotContainer.svelte +140 -0
  45. package/dist/layout/SlotContainer.svelte.d.ts +8 -0
  46. package/dist/layout/SlotDropZone.svelte +122 -0
  47. package/dist/layout/SlotDropZone.svelte.d.ts +8 -0
  48. package/dist/layout/drag.svelte.d.ts +45 -0
  49. package/dist/layout/drag.svelte.js +191 -0
  50. package/dist/layout/inspection.d.ts +52 -0
  51. package/dist/layout/inspection.js +157 -0
  52. package/dist/layout/ops.d.ts +78 -0
  53. package/dist/layout/ops.js +281 -0
  54. package/dist/layout/slotHostPool.svelte.d.ts +36 -0
  55. package/dist/layout/slotHostPool.svelte.js +229 -0
  56. package/dist/layout/store.svelte.d.ts +39 -0
  57. package/dist/layout/store.svelte.js +150 -0
  58. package/dist/layout/tree-walk.d.ts +15 -0
  59. package/dist/layout/tree-walk.js +33 -0
  60. package/dist/layout/types.d.ts +108 -0
  61. package/dist/layout/types.js +25 -0
  62. package/dist/overlays/ModalFrame.svelte +87 -0
  63. package/dist/overlays/ModalFrame.svelte.d.ts +10 -0
  64. package/dist/overlays/PopupFrame.svelte +85 -0
  65. package/dist/overlays/PopupFrame.svelte.d.ts +10 -0
  66. package/dist/overlays/ToastItem.svelte +77 -0
  67. package/dist/overlays/ToastItem.svelte.d.ts +9 -0
  68. package/dist/overlays/focusTrap.d.ts +1 -0
  69. package/dist/overlays/focusTrap.js +64 -0
  70. package/dist/overlays/modal.d.ts +9 -0
  71. package/dist/overlays/modal.js +141 -0
  72. package/dist/overlays/popup.d.ts +9 -0
  73. package/dist/overlays/popup.js +108 -0
  74. package/dist/overlays/roots.d.ts +4 -0
  75. package/dist/overlays/roots.js +31 -0
  76. package/dist/overlays/toast.d.ts +6 -0
  77. package/dist/overlays/toast.js +93 -0
  78. package/dist/overlays/types.d.ts +31 -0
  79. package/dist/overlays/types.js +15 -0
  80. package/dist/primitives/.gitkeep +0 -0
  81. package/dist/primitives/ResizableSplitter.svelte +333 -0
  82. package/dist/primitives/ResizableSplitter.svelte.d.ts +35 -0
  83. package/dist/primitives/TabbedPanel.svelte +305 -0
  84. package/dist/primitives/TabbedPanel.svelte.d.ts +50 -0
  85. package/dist/registry/client.d.ts +74 -0
  86. package/dist/registry/client.js +118 -0
  87. package/dist/registry/index.d.ts +13 -0
  88. package/dist/registry/index.js +14 -0
  89. package/dist/registry/installer.d.ts +53 -0
  90. package/dist/registry/installer.js +170 -0
  91. package/dist/registry/integrity.d.ts +32 -0
  92. package/dist/registry/integrity.js +92 -0
  93. package/dist/registry/loader.d.ts +50 -0
  94. package/dist/registry/loader.js +145 -0
  95. package/dist/registry/schema.d.ts +47 -0
  96. package/dist/registry/schema.js +180 -0
  97. package/dist/registry/storage.d.ts +37 -0
  98. package/dist/registry/storage.js +101 -0
  99. package/dist/registry/types.d.ts +245 -0
  100. package/dist/registry/types.js +14 -0
  101. package/dist/registry-shard/RegistryView.svelte +561 -0
  102. package/dist/registry-shard/RegistryView.svelte.d.ts +3 -0
  103. package/dist/registry-shard/registryApp.d.ts +10 -0
  104. package/dist/registry-shard/registryApp.js +24 -0
  105. package/dist/registry-shard/registryShard.svelte.d.ts +45 -0
  106. package/dist/registry-shard/registryShard.svelte.js +125 -0
  107. package/dist/shards/activate.svelte.d.ts +45 -0
  108. package/dist/shards/activate.svelte.js +124 -0
  109. package/dist/shards/registry.d.ts +4 -0
  110. package/dist/shards/registry.js +28 -0
  111. package/dist/shards/types.d.ts +155 -0
  112. package/dist/shards/types.js +20 -0
  113. package/dist/shell-shard/ShellHome.svelte +285 -0
  114. package/dist/shell-shard/ShellHome.svelte.d.ts +3 -0
  115. package/dist/shell-shard/shellShard.svelte.d.ts +2 -0
  116. package/dist/shell-shard/shellShard.svelte.js +47 -0
  117. package/dist/shellRuntime.svelte.d.ts +27 -0
  118. package/dist/shellRuntime.svelte.js +27 -0
  119. package/dist/state/backends.d.ts +26 -0
  120. package/dist/state/backends.js +99 -0
  121. package/dist/state/types.d.ts +38 -0
  122. package/dist/state/types.js +15 -0
  123. package/dist/state/zones.svelte.d.ts +52 -0
  124. package/dist/state/zones.svelte.js +141 -0
  125. package/dist/store/InstalledView.svelte +201 -0
  126. package/dist/store/InstalledView.svelte.d.ts +3 -0
  127. package/dist/store/StoreView.svelte +470 -0
  128. package/dist/store/StoreView.svelte.d.ts +3 -0
  129. package/dist/store/storeApp.d.ts +11 -0
  130. package/dist/store/storeApp.js +26 -0
  131. package/dist/store/storeShard.svelte.d.ts +29 -0
  132. package/dist/store/storeShard.svelte.js +99 -0
  133. package/dist/tokens.css +79 -0
  134. package/package.json +50 -0
@@ -0,0 +1,150 @@
1
+ /*
2
+ * Layout manager — owns both the framework-constant shell home layout
3
+ * and the currently-active app's persisted layout, and swaps between
4
+ * them without tearing down the held tree.
5
+ *
6
+ * The manager is the sole owner of "which layout root is being rendered
7
+ * right now". LayoutRenderer reads `layoutStore.root` (a getter on the
8
+ * active tree); drag.svelte.ts and any other mutation site do the same.
9
+ * Neither needs to know whether the active tree is home or an app.
10
+ *
11
+ * Refcount-hold discipline:
12
+ * The slot host pool is refcount-based with a microtask-deferred
13
+ * destroy. A root swap (app → home) causes the app's SlotContainers
14
+ * to unmount and release their pool entries; home's slots have
15
+ * different ids, so nothing re-acquires the app's pool entries before
16
+ * the microtask fires, and the app's views would be destroyed. To
17
+ * prevent this, attaching an app calls `acquireSlotHost` for every
18
+ * slot id in the app's current layout tree once (in addition to what
19
+ * the renderer does when the tree is active). That hold keeps
20
+ * refcount ≥ 1 across swaps. Detaching an app releases the holds.
21
+ *
22
+ * Home does not need a hold — it is either rendered or unmounted
23
+ * entirely (there is no "held home while rendering app" state).
24
+ *
25
+ * Orphan cleanup:
26
+ * The pre-phase-8 shell wrote to `sh3:workspace:__shell__`. Phase 8
27
+ * switches to per-app keys; the old entry would otherwise sit as dead
28
+ * data. On first load after upgrade, the manager clears that orphan
29
+ * unconditionally (clearing a non-existent entry is a no-op).
30
+ */
31
+ import { createStateZones, peekZone, clearZone } from '../state/zones.svelte';
32
+ import { acquireSlotHost, releaseSlotHost } from './slotHostPool.svelte';
33
+ import { collectSlotRefs } from './tree-walk';
34
+ // ---------- orphan cleanup of pre-phase-8 shell layout key ----------------
35
+ clearZone('workspace', '__shell__');
36
+ // ---------- home layout (framework constant, in-memory only) --------------
37
+ /**
38
+ * The home layout is a single slot wrapping the shell:home view. The
39
+ * slot id is reserved (`shell.home`) and stable so the pool entry for
40
+ * home survives across boot/launch cycles.
41
+ */
42
+ const HOME_LAYOUT = {
43
+ type: 'slot',
44
+ slotId: 'shell.home',
45
+ viewId: 'shell:home',
46
+ };
47
+ let appEntry = $state(null);
48
+ let activeRoot = $state('home');
49
+ // ---------- public (within-framework) API ---------------------------------
50
+ /**
51
+ * Attach an app: create or hydrate its workspace-zone layout proxy,
52
+ * enforce the blueprint version gate, and take a refcount hold on all
53
+ * of the app's slot ids so root swaps don't destroy its pooled hosts.
54
+ * Does NOT switch the active root. Call switchToApp() separately.
55
+ */
56
+ export function attachApp(app) {
57
+ if (appEntry) {
58
+ throw new Error(`Layout manager cannot attach app "${app.manifest.id}": app "${appEntry.appId}" is still attached`);
59
+ }
60
+ const shardId = `__shell__:app:${app.manifest.id}`;
61
+ // Version gate: if a stored blob's layoutVersion doesn't match the
62
+ // app's current declaration, discard it so createStateZones falls
63
+ // back to the defaults (the app's initialLayout).
64
+ const stored = peekZone('workspace', shardId);
65
+ if (stored != null) {
66
+ const asBlob = stored;
67
+ if (asBlob.layoutVersion !== app.manifest.layoutVersion) {
68
+ clearZone('workspace', shardId);
69
+ }
70
+ }
71
+ const state = createStateZones(shardId, {
72
+ workspace: {
73
+ layoutVersion: app.manifest.layoutVersion,
74
+ root: app.initialLayout,
75
+ },
76
+ });
77
+ const proxy = state.workspace;
78
+ // Take a refcount hold on every slot in the app's tree. These holds
79
+ // keep the pooled hosts alive across home⇄app swaps. They are
80
+ // acquired without attaching the returned host anywhere — the
81
+ // LayoutRenderer still acquires its own refs when it mounts the
82
+ // tree, so the active rendering doesn't double-hold harmfully (the
83
+ // pool's destroy logic just sees refcount 2, then 1 on release).
84
+ const refs = collectSlotRefs(proxy.root);
85
+ const heldSlotIds = [];
86
+ for (const { slotId, viewId, label } of refs) {
87
+ acquireSlotHost(slotId, viewId, label);
88
+ heldSlotIds.push(slotId);
89
+ }
90
+ appEntry = { appId: app.manifest.id, proxy, heldSlotIds };
91
+ }
92
+ /**
93
+ * Detach the currently-attached app. Releases its refcount holds; the
94
+ * pool's microtask cleanup drops the pooled hosts if they also have no
95
+ * active renderer refs. Must be called before attaching a different app.
96
+ */
97
+ export function detachApp() {
98
+ if (!appEntry)
99
+ return;
100
+ for (const slotId of appEntry.heldSlotIds) {
101
+ releaseSlotHost(slotId);
102
+ }
103
+ appEntry = null;
104
+ // If we detach while the active root is 'app', the renderer now has
105
+ // nothing to show; callers must switchToHome() before or after
106
+ // detachApp. We don't auto-switch here so the ordering is explicit.
107
+ }
108
+ export function switchToHome() {
109
+ activeRoot = 'home';
110
+ }
111
+ export function switchToApp() {
112
+ if (!appEntry) {
113
+ throw new Error('Cannot switchToApp: no app is attached');
114
+ }
115
+ activeRoot = 'app';
116
+ }
117
+ /**
118
+ * The currently-rendered root. LayoutRenderer reads this through the
119
+ * `layoutStore` export below. Home uses the framework constant;
120
+ * app uses the workspace-zone proxy's `root` (which is reactive, so
121
+ * mutations from splitter/drag/ops reach the renderer unchanged).
122
+ */
123
+ export function activeLayout() {
124
+ if (activeRoot === 'app' && appEntry)
125
+ return appEntry.proxy.root;
126
+ return HOME_LAYOUT;
127
+ }
128
+ export function getActiveRoot() {
129
+ return activeRoot;
130
+ }
131
+ export function getAttachedAppId() {
132
+ var _a;
133
+ return (_a = appEntry === null || appEntry === void 0 ? void 0 : appEntry.appId) !== null && _a !== void 0 ? _a : null;
134
+ }
135
+ // ---------- `layoutStore` back-compat shim -------------------------------
136
+ /**
137
+ * Preserved for callers that still read `layoutStore.root`. The getter
138
+ * delegates to `activeLayout()` so every read walks through the
139
+ * manager. Writes to `layoutStore.root` are disallowed (mutation is
140
+ * expected to happen on the returned tree's nodes in place, as in
141
+ * phase 7 — splitter drags mutate `sizes[i]`, tab clicks mutate
142
+ * `activeTab`, drag-commit calls `ops.ts` functions that mutate
143
+ * children arrays). Nothing in the codebase currently reassigns
144
+ * `layoutStore.root`, so this getter-only shape is sufficient.
145
+ */
146
+ export const layoutStore = {
147
+ get root() {
148
+ return activeLayout();
149
+ },
150
+ };
@@ -0,0 +1,15 @@
1
+ import type { LayoutNode } from './types';
2
+ /**
3
+ * Collect the slot id / view id pairs of every slot leaf (including the
4
+ * slots embedded inside tabs entries) in a layout tree. Used by the
5
+ * layout manager to hold pool refs for a non-rendered but still-resident
6
+ * app tree. Order is a pre-order walk; the returned list may contain
7
+ * the same slot id twice if the tree is malformed, but ops.ts maintains
8
+ * the invariant that slot ids are unique so that is not a concern in
9
+ * practice.
10
+ */
11
+ export declare function collectSlotRefs(tree: LayoutNode): {
12
+ slotId: string;
13
+ viewId: string | null;
14
+ label: string;
15
+ }[];
@@ -0,0 +1,33 @@
1
+ /*
2
+ * Layout tree traversal utilities shared between the layout manager
3
+ * and other consumers (refcount-hold, diagnostic inspection).
4
+ */
5
+ /**
6
+ * Collect the slot id / view id pairs of every slot leaf (including the
7
+ * slots embedded inside tabs entries) in a layout tree. Used by the
8
+ * layout manager to hold pool refs for a non-rendered but still-resident
9
+ * app tree. Order is a pre-order walk; the returned list may contain
10
+ * the same slot id twice if the tree is malformed, but ops.ts maintains
11
+ * the invariant that slot ids are unique so that is not a concern in
12
+ * practice.
13
+ */
14
+ export function collectSlotRefs(tree) {
15
+ const out = [];
16
+ const walk = (node) => {
17
+ if (node.type === 'slot') {
18
+ out.push({ slotId: node.slotId, viewId: node.viewId, label: node.viewId || node.slotId });
19
+ return;
20
+ }
21
+ if (node.type === 'tabs') {
22
+ for (const t of node.tabs) {
23
+ out.push({ slotId: t.slotId, viewId: t.viewId, label: t.label });
24
+ }
25
+ return;
26
+ }
27
+ // split
28
+ for (const c of node.children)
29
+ walk(c);
30
+ };
31
+ walk(tree);
32
+ return out;
33
+ }
@@ -0,0 +1,108 @@
1
+ /** Axis along which a split node divides its children. */
2
+ export type SplitDirection = 'horizontal' | 'vertical';
3
+ /** How a child of a split node is sized. */
4
+ export type SizeMode = 'fr' | 'px';
5
+ /**
6
+ * A layout node that divides its area into two or more children along an axis.
7
+ * Children are sized proportionally (`fr`) or pixel-pinned (`px`). Supports
8
+ * per-child collapsed state so panels can be hidden without removing them.
9
+ */
10
+ export interface SplitNode {
11
+ type: 'split';
12
+ /** Axis along which children are arranged. */
13
+ direction: SplitDirection;
14
+ /**
15
+ * Per-child size. Interpretation depends on the parallel `pinned` entry:
16
+ * - 'fr' (default): proportional weight; reflows on resize.
17
+ * - 'px': pixel-pinned; held fixed while 'fr' siblings absorb deltas.
18
+ */
19
+ sizes: number[];
20
+ /** Per-child sizing mode. Omitted entries default to 'fr'. */
21
+ pinned?: SizeMode[];
22
+ /** Per-child collapsed state. Omitted means all expanded. */
23
+ collapsed?: boolean[];
24
+ /** Ordered child nodes. Length must equal `sizes` length. */
25
+ children: LayoutNode[];
26
+ }
27
+ /**
28
+ * A single tab descriptor inside a `TabsNode`. Each entry corresponds to one
29
+ * slot: `slotId` is the stable identifier the framework uses to key persistent
30
+ * state, `viewId` names the shard view to mount (null means the slot is empty).
31
+ */
32
+ export interface TabEntry {
33
+ /** Stable identifier for the slot. Persisted with the layout. */
34
+ slotId: string;
35
+ /** View id to mount into this slot, or null for an empty slot. */
36
+ viewId: string | null;
37
+ /** Human-readable label shown in the tab strip. */
38
+ label: string;
39
+ /** Optional icon hint (not yet rendered in phase 8). */
40
+ icon?: string;
41
+ }
42
+ /**
43
+ * A layout node that groups one or more slots as tabs, showing one at a time.
44
+ * The active tab index drives which slot is visible; the others stay mounted
45
+ * in the background so they survive tab switches without re-initialization.
46
+ */
47
+ export interface TabsNode {
48
+ type: 'tabs';
49
+ tabs: TabEntry[];
50
+ activeTab: number;
51
+ /**
52
+ * If true, the node is not pruned by cleanupTree when its last tab is
53
+ * closed. The layout renderer shows an empty-state placeholder instead.
54
+ * Typically set by app layout blueprints on structural tab groups.
55
+ */
56
+ persistent?: boolean;
57
+ /**
58
+ * Custom renderer for the empty state when all tabs are closed (only
59
+ * meaningful when `persistent` is true). Called with the container
60
+ * element; the app has full control over what's rendered.
61
+ * Runtime-only — not serialized with the layout tree.
62
+ */
63
+ emptyRenderer?: (container: HTMLElement) => void;
64
+ }
65
+ /**
66
+ * A leaf layout node that holds a single mounted view. `slotId` is the stable
67
+ * identifier used to key view state; `viewId` is the shard-registered view to
68
+ * mount. A null `viewId` means the slot is present in the tree but empty.
69
+ */
70
+ export interface SlotNode {
71
+ type: 'slot';
72
+ /** Stable identifier for this slot. Persisted with the layout. */
73
+ slotId: string;
74
+ /** View id to mount into this slot, or null for an empty slot. */
75
+ viewId: string | null;
76
+ }
77
+ /**
78
+ * Union of all layout node kinds. The recursive tree is composed entirely of
79
+ * these three types: `split` → children, `tabs` → slots, `slot` → leaf.
80
+ */
81
+ export type LayoutNode = SplitNode | TabsNode | SlotNode;
82
+ /**
83
+ * Schema version for persisted layouts. Bump this when the shape of
84
+ * `LayoutNode` (or anything reachable from it) changes incompatibly.
85
+ * A stored entry whose version does not match is discarded on boot and
86
+ * the default tree takes over — phase 7 deliberately does not ship a
87
+ * migration framework, only the hook for one.
88
+ */
89
+ export declare const LAYOUT_SCHEMA_VERSION = 3;
90
+ /**
91
+ * The wire shape of a persisted layout in the workspace state zone.
92
+ * One blob per shell (or per program, once per-program layouts exist);
93
+ * the version field gates compatibility.
94
+ */
95
+ export interface PersistedLayout {
96
+ version: typeof LAYOUT_SCHEMA_VERSION;
97
+ root: LayoutNode;
98
+ }
99
+ /**
100
+ * Per-app layout blob written to the workspace state zone under
101
+ * `__shell__:app:<appId>`. The `layoutVersion` is the app's own
102
+ * `AppManifest.layoutVersion`; on launch a mismatch discards the blob
103
+ * and the app's `initialLayout` is used.
104
+ */
105
+ export interface AppLayoutBlob {
106
+ layoutVersion: number;
107
+ root: LayoutNode;
108
+ }
@@ -0,0 +1,25 @@
1
+ /*
2
+ * Layout tree types — the single source of truth for docked layout topology.
3
+ *
4
+ * See docs/design/layout.md for rationale. The tree is recursive with three
5
+ * node kinds:
6
+ *
7
+ * - split: horizontal or vertical, with proportional sizes and optional
8
+ * per-child pixel pinning.
9
+ * - tabs: a tab group with one active tab at a time. Each tab owns a slot.
10
+ * - slot: leaf. Carries a stable slotId so state can be keyed to it, and
11
+ * a viewId that names the view a shard should mount into it.
12
+ * viewId is null for empty slots.
13
+ *
14
+ * Views are mounted into slots by the shard registry (phase 4). Trees are
15
+ * serialized to the workspace state zone for persistence (phase 7) — see
16
+ * `PersistedLayout` at the bottom of this file.
17
+ */
18
+ /**
19
+ * Schema version for persisted layouts. Bump this when the shape of
20
+ * `LayoutNode` (or anything reachable from it) changes incompatibly.
21
+ * A stored entry whose version does not match is discarded on boot and
22
+ * the default tree takes over — phase 7 deliberately does not ship a
23
+ * migration framework, only the hook for one.
24
+ */
25
+ export const LAYOUT_SCHEMA_VERSION = 3;
@@ -0,0 +1,87 @@
1
+ <script lang="ts">
2
+ /*
3
+ * ModalFrame — the internal wrapper that the modal manager mounts into
4
+ * a per-modal host div inside the layer-4 root.
5
+ *
6
+ * Responsibilities:
7
+ * - Span the full layer as a transparent centering container so every
8
+ * modal box renders in the middle of the shell.
9
+ * - Catch pointer events on the area around the box so clicks outside
10
+ * the top modal do not fall through to modals beneath or to the
11
+ * underlying layout.
12
+ * - Dynamically render the caller's content component with its props.
13
+ * - Install a focus trap on the dialog box for as long as the frame
14
+ * lives, restoring previous focus on teardown.
15
+ *
16
+ * The frame does NOT render a backdrop. The modal manager owns a single
17
+ * shared backdrop element under the layer-4 root whose opacity scales
18
+ * (capped) with stack depth — otherwise per-frame backdrops compound
19
+ * into full black once a few modals are stacked, and every layer also
20
+ * pays its own composite cost.
21
+ *
22
+ * Escape-to-close and backdrop-click policy are decided by the manager,
23
+ * not the frame — the manager holds the stack and knows which modal is
24
+ * on top. The frame just receives a `close` callback and invokes it if
25
+ * the caller wants a built-in close button (there is none by default —
26
+ * content owns its chrome).
27
+ */
28
+
29
+ import type { Component } from 'svelte';
30
+ import { createFocusTrap } from './focusTrap';
31
+
32
+ let {
33
+ Content,
34
+ contentProps,
35
+ close,
36
+ boxStyle,
37
+ }: {
38
+ Content: Component<Record<string, unknown>>;
39
+ contentProps: Record<string, unknown>;
40
+ close: () => void;
41
+ boxStyle?: string;
42
+ } = $props();
43
+
44
+ let box: HTMLDivElement;
45
+
46
+ $effect(() => {
47
+ if (!box) return;
48
+ return createFocusTrap(box);
49
+ });
50
+ </script>
51
+
52
+ <div class="modal-frame" role="presentation">
53
+ <div
54
+ class="modal-box"
55
+ role="dialog"
56
+ aria-modal="true"
57
+ tabindex="-1"
58
+ bind:this={box}
59
+ style={boxStyle}
60
+ >
61
+ <Content {...contentProps} {close} />
62
+ </div>
63
+ </div>
64
+
65
+ <style>
66
+ .modal-frame {
67
+ position: absolute;
68
+ inset: 0;
69
+ display: grid;
70
+ place-items: center;
71
+ /* Transparent but pointer-capturing so clicks around the box are
72
+ swallowed rather than falling through to modals beneath. */
73
+ pointer-events: auto;
74
+ }
75
+ .modal-box {
76
+ background: var(--shell-bg-elevated);
77
+ color: var(--shell-fg);
78
+ border: 1px solid var(--shell-border-strong);
79
+ border-radius: 4px;
80
+ min-width: 320px;
81
+ max-width: min(640px, 90vw);
82
+ max-height: 90vh;
83
+ overflow: auto;
84
+ box-shadow: 0 20px 48px rgba(0, 0, 0, 0.5);
85
+ outline: none;
86
+ }
87
+ </style>
@@ -0,0 +1,10 @@
1
+ import type { Component } from 'svelte';
2
+ type $$ComponentProps = {
3
+ Content: Component<Record<string, unknown>>;
4
+ contentProps: Record<string, unknown>;
5
+ close: () => void;
6
+ boxStyle?: string;
7
+ };
8
+ declare const ModalFrame: Component<$$ComponentProps, {}, "">;
9
+ type ModalFrame = ReturnType<typeof ModalFrame>;
10
+ export default ModalFrame;
@@ -0,0 +1,85 @@
1
+ <script lang="ts">
2
+ /*
3
+ * PopupFrame — positioned wrapper for a popup's content component.
4
+ *
5
+ * Takes an anchor rect in viewport coordinates and places itself at
6
+ * bottom-start with a viewport overflow clamp. The measurement happens
7
+ * after mount via an $effect so we can read the real frame size; an
8
+ * initial render off-screen hides the flicker while we measure.
9
+ *
10
+ * Positioning policy for phase 5:
11
+ * - Preferred: bottom-start (top = anchor.bottom + 4, left = anchor.left)
12
+ * - If the frame's right edge would exit the viewport, shift left so it
13
+ * sits flush with the right edge minus a small margin.
14
+ * - If the frame's bottom edge would exit the viewport, flip to above
15
+ * the anchor (top = anchor.top - frameHeight - 4).
16
+ * - If it still doesn't fit, clamp to the viewport.
17
+ *
18
+ * Additional placements (right-start, left-start, etc.) arrive when a
19
+ * real shard needs them; phase 5 doesn't.
20
+ */
21
+
22
+ import type { Component } from 'svelte';
23
+
24
+ let {
25
+ Content,
26
+ contentProps,
27
+ anchorRect,
28
+ close,
29
+ }: {
30
+ Content: Component<Record<string, unknown>>;
31
+ contentProps: Record<string, unknown>;
32
+ anchorRect: DOMRect;
33
+ close: () => void;
34
+ } = $props();
35
+
36
+ let frame: HTMLDivElement;
37
+ let top = $state(-9999);
38
+ let left = $state(-9999);
39
+
40
+ $effect(() => {
41
+ if (!frame) return;
42
+ const rect = frame.getBoundingClientRect();
43
+ const margin = 4;
44
+ const vw = window.innerWidth;
45
+ const vh = window.innerHeight;
46
+
47
+ let t = anchorRect.bottom + margin;
48
+ let l = anchorRect.left;
49
+
50
+ if (l + rect.width > vw - margin) {
51
+ l = Math.max(margin, vw - rect.width - margin);
52
+ }
53
+ if (t + rect.height > vh - margin) {
54
+ const flipped = anchorRect.top - rect.height - margin;
55
+ t = flipped >= margin ? flipped : Math.max(margin, vh - rect.height - margin);
56
+ }
57
+
58
+ top = t;
59
+ left = l;
60
+ });
61
+ </script>
62
+
63
+ <div
64
+ class="popup-frame"
65
+ role="menu"
66
+ tabindex="-1"
67
+ style="top: {top}px; left: {left}px;"
68
+ bind:this={frame}
69
+ >
70
+ <Content {...contentProps} {close} />
71
+ </div>
72
+
73
+ <style>
74
+ .popup-frame {
75
+ position: absolute;
76
+ background: var(--shell-bg-elevated);
77
+ color: var(--shell-fg);
78
+ border: 1px solid var(--shell-border-strong);
79
+ border-radius: 3px;
80
+ box-shadow: 0 8px 24px rgba(0, 0, 0, 0.4);
81
+ min-width: 120px;
82
+ outline: none;
83
+ pointer-events: auto;
84
+ }
85
+ </style>
@@ -0,0 +1,10 @@
1
+ import type { Component } from 'svelte';
2
+ type $$ComponentProps = {
3
+ Content: Component<Record<string, unknown>>;
4
+ contentProps: Record<string, unknown>;
5
+ anchorRect: DOMRect;
6
+ close: () => void;
7
+ };
8
+ declare const PopupFrame: Component<$$ComponentProps, {}, "">;
9
+ type PopupFrame = ReturnType<typeof PopupFrame>;
10
+ export default PopupFrame;
@@ -0,0 +1,77 @@
1
+ <script lang="ts">
2
+ /*
3
+ * ToastItem — a single auto-dismissing notification.
4
+ *
5
+ * The toast manager mounts one ToastItem per notification into the
6
+ * layer-5 root. The item renders the message, applies level styling,
7
+ * and fades/slides in on mount. The manager (not the item) owns the
8
+ * auto-dismiss timer, so the item stays purely presentational.
9
+ *
10
+ * Dismiss-on-click is built into the item via the close prop so users
11
+ * can dismiss a toast early without waiting for its timer.
12
+ */
13
+
14
+ import type { ToastLevel } from './types';
15
+
16
+ let {
17
+ message,
18
+ level,
19
+ close,
20
+ }: {
21
+ message: string;
22
+ level: ToastLevel;
23
+ close: () => void;
24
+ } = $props();
25
+ </script>
26
+
27
+ <!-- svelte-ignore a11y_click_events_have_key_events -->
28
+ <!-- svelte-ignore a11y_no_static_element_interactions -->
29
+ <!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
30
+ <div
31
+ class="toast toast-{level}"
32
+ role="status"
33
+ aria-live="polite"
34
+ onclick={close}
35
+ >
36
+ <span class="toast-level">{level}</span>
37
+ <span class="toast-message">{message}</span>
38
+ </div>
39
+
40
+ <style>
41
+ .toast {
42
+ pointer-events: auto;
43
+ display: flex;
44
+ align-items: center;
45
+ gap: var(--shell-pad-md);
46
+ padding: var(--shell-pad-sm) var(--shell-pad-md);
47
+ background: var(--shell-bg-elevated);
48
+ color: var(--shell-fg);
49
+ border: 1px solid var(--shell-border-strong);
50
+ border-left-width: 3px;
51
+ border-radius: 3px;
52
+ box-shadow: 0 8px 20px rgba(0, 0, 0, 0.4);
53
+ font-size: 12px;
54
+ min-width: 220px;
55
+ max-width: 360px;
56
+ cursor: pointer;
57
+ animation: toast-in 160ms ease-out both;
58
+ }
59
+ .toast-level {
60
+ text-transform: uppercase;
61
+ font-family: var(--shell-font-mono);
62
+ font-size: 10px;
63
+ letter-spacing: 0.5px;
64
+ color: var(--shell-fg-muted);
65
+ }
66
+ .toast-message { flex: 1; }
67
+
68
+ .toast-info { border-left-color: var(--shell-accent); }
69
+ .toast-success { border-left-color: #5cb176; }
70
+ .toast-warn { border-left-color: #d6a84a; }
71
+ .toast-error { border-left-color: #d06060; }
72
+
73
+ @keyframes toast-in {
74
+ from { opacity: 0; transform: translateY(8px); }
75
+ to { opacity: 1; transform: translateY(0); }
76
+ }
77
+ </style>
@@ -0,0 +1,9 @@
1
+ import type { ToastLevel } from './types';
2
+ type $$ComponentProps = {
3
+ message: string;
4
+ level: ToastLevel;
5
+ close: () => void;
6
+ };
7
+ declare const ToastItem: import("svelte").Component<$$ComponentProps, {}, "">;
8
+ type ToastItem = ReturnType<typeof ToastItem>;
9
+ export default ToastItem;
@@ -0,0 +1 @@
1
+ export declare function createFocusTrap(container: HTMLElement): () => void;
@@ -0,0 +1,64 @@
1
+ /*
2
+ * createFocusTrap — minimal Tab-cycling focus trap for modal frames.
3
+ *
4
+ * On install: remembers the currently focused element, moves focus to the
5
+ * first focusable descendant of `container`, and intercepts Tab/Shift+Tab
6
+ * to cycle within the container.
7
+ *
8
+ * On teardown (the returned disposer): removes the listener and restores
9
+ * focus to the previously active element if it's still connected to the DOM.
10
+ *
11
+ * Phase 5 scope is deliberately narrow: no aria-hidden on siblings, no
12
+ * Inert attribute management, no MutationObserver for dynamic content.
13
+ * Those refinements arrive when accessibility work lands post-prototype.
14
+ */
15
+ const FOCUSABLE_SELECTOR = [
16
+ 'a[href]',
17
+ 'button:not([disabled])',
18
+ 'input:not([disabled])',
19
+ 'select:not([disabled])',
20
+ 'textarea:not([disabled])',
21
+ '[tabindex]:not([tabindex="-1"])',
22
+ ].join(',');
23
+ export function createFocusTrap(container) {
24
+ const previouslyFocused = document.activeElement;
25
+ function getFocusables() {
26
+ return Array.from(container.querySelectorAll(FOCUSABLE_SELECTOR));
27
+ }
28
+ function onKeydown(e) {
29
+ if (e.key !== 'Tab')
30
+ return;
31
+ const focusables = getFocusables();
32
+ if (focusables.length === 0) {
33
+ // Nothing to focus — swallow Tab so it can't escape the modal.
34
+ e.preventDefault();
35
+ return;
36
+ }
37
+ const first = focusables[0];
38
+ const last = focusables[focusables.length - 1];
39
+ const active = document.activeElement;
40
+ if (e.shiftKey && (active === first || !container.contains(active))) {
41
+ e.preventDefault();
42
+ last.focus();
43
+ }
44
+ else if (!e.shiftKey && (active === last || !container.contains(active))) {
45
+ e.preventDefault();
46
+ first.focus();
47
+ }
48
+ }
49
+ container.addEventListener('keydown', onKeydown);
50
+ // Defer initial focus to the next microtask so the container contents
51
+ // (which may still be rendering if createFocusTrap was called mid-mount)
52
+ // have a chance to appear in the DOM.
53
+ queueMicrotask(() => {
54
+ var _a;
55
+ const focusables = getFocusables();
56
+ ((_a = focusables[0]) !== null && _a !== void 0 ? _a : container).focus();
57
+ });
58
+ return () => {
59
+ container.removeEventListener('keydown', onKeydown);
60
+ if (previouslyFocused && document.contains(previouslyFocused)) {
61
+ previouslyFocused.focus();
62
+ }
63
+ };
64
+ }