diff --git a/src/css/sw-shell.css b/src/css/sw-shell.css
index cac7e09..d9f72ce 100644
--- a/src/css/sw-shell.css
+++ b/src/css/sw-shell.css
@@ -107,3 +107,33 @@
text-align: center;
background: var(--bg-surface);
}
+
+/* ── Surface viewport ────────────────────── */
+
+.sw-surface-viewport {
+ flex: 1;
+ position: relative;
+ overflow: auto;
+ display: flex;
+ flex-direction: column;
+}
+
+/* ── User menu trigger ───────────────────── */
+
+.sw-user-menu__trigger {
+ display: inline-flex;
+ align-items: center;
+ background: none;
+ border: none;
+ cursor: pointer;
+ padding: 2px;
+ border-radius: 50%;
+ transition: box-shadow var(--transition, 0.15s ease);
+}
+.sw-user-menu__trigger:hover {
+ box-shadow: 0 0 0 2px var(--accent-dim);
+}
+.sw-user-menu__trigger:focus-visible {
+ outline: 2px solid var(--accent);
+ outline-offset: 2px;
+}
diff --git a/src/dev.html b/src/dev.html
index 5665495..f01c710 100644
--- a/src/dev.html
+++ b/src/dev.html
@@ -62,6 +62,11 @@
const { Tabs } = await import('./js/sw/primitives/tabs.js');
const { Dropdown } = await import('./js/sw/primitives/dropdown.js');
const { AppShell } = await import('./js/sw/shell/app-shell.js');
+ const { UserMenu } = await import('./js/sw/shell/user-menu.js');
+ const { SurfaceViewport } = await import('./js/sw/shell/surface-viewport.js');
+ const { DialogStack } = await import('./js/sw/shell/dialog-stack.js');
+ const { confirm } = await import('./js/sw/primitives/confirm.js');
+ const { prompt } = await import('./js/sw/primitives/prompt.js');
// ── SDK Boot ────────────────────────────
const { boot } = await import('./js/sw/sdk/index.js');
@@ -209,6 +214,99 @@
`;
}
+ // ── Shell Demo Page ──────────────────────
+ function ShellPage() {
+ const [confirmResult, setConfirmResult] = useState(null);
+ const [promptResult, setPromptResult] = useState(null);
+
+ return html`
+
+
Shell Demo
+
Layer 2 — Application shell (v0.37.4)
+
+
+
+
UserMenu
+
+ <${UserMenu} />
+ <${UserMenu} placement="down-left" />
+
+
+ RBAC-gated flyout. Without backend, shows all items for demo.
+
+
+
+
+
+
sw.confirm()
+
+ <${Button} variant="secondary" onClick=${async () => {
+ const ok = await sw.confirm('Delete this item?', { destructive: true });
+ setConfirmResult(ok);
+ }}>Confirm (destructive)/>
+ <${Button} variant="secondary" onClick=${async () => {
+ const ok = await sw.confirm('Proceed with this action?');
+ setConfirmResult(ok);
+ }}>Confirm (normal)/>
+ ${confirmResult !== null && html`
+
+ Result: ${String(confirmResult)}
+
+ `}
+
+
+
+
+
+
sw.prompt()
+
+ <${Button} variant="secondary" onClick=${async () => {
+ const val = await sw.prompt('Enter a name:', { defaultValue: 'untitled' });
+ setPromptResult(val);
+ }}>Prompt/>
+ ${promptResult !== null && html`
+
+ Result: ${JSON.stringify(promptResult)}
+
+ `}
+
+
+
+
+
+
SurfaceViewport
+
+ UserMenu is placed by the surface (top-right), not the shell.
+
+
+ <${SurfaceViewport} surface=${html`
+
+
Mock surface content. The shell provides the viewport; the surface owns everything inside.
+
+ <${UserMenu} placement="down-left" />
+
+
+ `} />
+
+
+
+
+
+
+ `;
+ }
+
function PrimitivesPage() {
const [dialogOpen, setDialogOpen] = useState(false);
const [drawerOpen, setDrawerOpen] = useState(false);
@@ -424,8 +522,6 @@
<${Button} variant="secondary" onClick=${() => toast.info('New message received')}>Info/>
-
- <${ToastContainer} />
`;
}
@@ -492,10 +588,13 @@
${page === 'primitives' && html`<${PrimitivesPage} />`}
+ ${page === 'shell' && html`<${ShellPage} />`}
${page === 'sdk' && html`<${SDKPage} />`}
/>
+ <${ToastContainer} />
+ <${DialogStack} />
`;
}
diff --git a/src/js/sw/sdk/index.js b/src/js/sw/sdk/index.js
index b7623df..9bda96c 100644
--- a/src/js/sw/sdk/index.js
+++ b/src/js/sw/sdk/index.js
@@ -18,6 +18,8 @@ import { createDomains } from './api-domains.js';
import { createEvents } from './events.js';
import { createPipe } from './pipe.js';
import { createTheme } from './theme.js';
+import { confirm } from '../primitives/confirm.js';
+import { prompt } from '../primitives/prompt.js';
/**
* Boot the SDK. Assembles modules, loads auth state, connects WebSocket.
@@ -91,8 +93,20 @@ export async function boot() {
sw.pipe = pipe;
sw.theme = theme;
+ // Shell helpers — imperative confirm/prompt backed by DialogStack
+ sw.confirm = confirm;
+ sw.prompt = prompt;
+
+ // UserMenu render helper — surfaces call sw.userMenu(container, opts)
+ sw.userMenu = function (container, opts = {}) {
+ import('../shell/user-menu.js').then(({ UserMenu }) => {
+ const { render } = preact;
+ render(html`<${UserMenu} ...${opts} />`, container);
+ });
+ };
+
// Marker for idempotency
- sw._sdk = '0.37.3';
+ sw._sdk = '0.37.4';
// 8. Expose globally
window.sw = sw;
@@ -114,7 +128,7 @@ export async function boot() {
// 10. Signal ready
events.emit('sdk.ready', {}, { localOnly: true });
document.dispatchEvent(new CustomEvent('sw:ready', { detail: { sw } }));
- console.log('[sw] SDK v0.37.3 ready');
+ console.log('[sw] SDK v0.37.4 ready');
return sw;
}
diff --git a/src/js/sw/shell/app.js b/src/js/sw/shell/app.js
new file mode 100644
index 0000000..946d4e7
--- /dev/null
+++ b/src/js/sw/shell/app.js
@@ -0,0 +1,36 @@
+/**
+ * App — root component composing the full shell tree
+ *
+ * ┌───────────────────────────────┐
+ * │ AppShell (banners, layout) │
+ * │ └─ SurfaceViewport │
+ * ├───────────────────────────────┤
+ * │ ToastContainer (fixed z:10k) │
+ * │ DialogStack (fixed z:5k) │
+ * └───────────────────────────────┘
+ *
+ * ToastContainer and DialogStack live outside AppShell
+ * so they float above all shell content.
+ *
+ * Props:
+ * banner — { text, variant } or null
+ * message — { text, variant } or null
+ * footer — VNode or null
+ * surface — VNode or null (active surface content)
+ */
+const { html } = window;
+
+import { AppShell } from './app-shell.js';
+import { SurfaceViewport } from './surface-viewport.js';
+import { DialogStack } from './dialog-stack.js';
+import { ToastContainer } from '../primitives/toast.js';
+
+export function App({ banner, message, footer, surface }) {
+ return html`
+ <${AppShell} banner=${banner} message=${message} footer=${footer}>
+ <${SurfaceViewport} surface=${surface} />
+ />
+ <${ToastContainer} />
+ <${DialogStack} />
+ `;
+}
diff --git a/src/js/sw/shell/dialog-stack.js b/src/js/sw/shell/dialog-stack.js
new file mode 100644
index 0000000..abac27f
--- /dev/null
+++ b/src/js/sw/shell/dialog-stack.js
@@ -0,0 +1,17 @@
+/**
+ * DialogStack — mounts imperative dialog hosts (ConfirmHost, PromptHost)
+ *
+ * Must be rendered once at the root level and never unmounted,
+ * so sw.confirm() and sw.prompt() work globally.
+ */
+const { html } = window;
+
+import { ConfirmHost } from '../primitives/confirm.js';
+import { PromptHost } from '../primitives/prompt.js';
+
+export function DialogStack() {
+ return html`
+ <${ConfirmHost} />
+ <${PromptHost} />
+ `;
+}
diff --git a/src/js/sw/shell/surface-viewport.js b/src/js/sw/shell/surface-viewport.js
new file mode 100644
index 0000000..479449a
--- /dev/null
+++ b/src/js/sw/shell/surface-viewport.js
@@ -0,0 +1,15 @@
+/**
+ * SurfaceViewport — container where the active surface renders
+ *
+ * Deliberately thin for v0.37.4. Error boundaries and
+ * surface transitions will be added in later versions.
+ */
+const { html } = window;
+
+export function SurfaceViewport({ surface = null, className = '' }) {
+ return html`
+
+ ${surface}
+
+ `;
+}
diff --git a/src/js/sw/shell/user-menu.js b/src/js/sw/shell/user-menu.js
new file mode 100644
index 0000000..08ce4eb
--- /dev/null
+++ b/src/js/sw/shell/user-menu.js
@@ -0,0 +1,79 @@
+/**
+ * UserMenu — avatar button + flyout menu
+ *
+ * RBAC-gated items based on sw.can() / sw.isAdmin / sw.isTeamAdmin().
+ * Surfaces mount this wherever they want; the shell does not own placement.
+ *
+ * Props:
+ * placement — Menu direction: 'down-right' (default) | 'down-left'
+ * onAction — Override handler; if omitted, uses default routing
+ */
+const { html } = window;
+const { useState, useRef, useMemo } = hooks;
+
+import { Avatar } from '../primitives/avatar.js';
+import { Menu } from '../primitives/menu.js';
+
+export function UserMenu({ placement = 'down-right', onAction }) {
+ const [open, setOpen] = useState(false);
+ const btnRef = useRef(null);
+
+ const sw = window.sw;
+ const user = sw?.auth?.user;
+ const authenticated = sw?.auth?.isAuthenticated;
+
+ const items = useMemo(() => {
+ const list = [
+ { label: 'Settings', action: 'settings', icon: '\u2699\ufe0f' },
+ ];
+
+ // Admin — requires admin role
+ if (!authenticated || sw?.isAdmin) {
+ list.push({ label: 'Admin', action: 'admin', icon: '\ud83d\udee1\ufe0f' });
+ }
+
+ // Team Admin — requires admin role on at least one team
+ const hasTeamAdmin = sw?.auth?.teams?.some(t => t.my_role === 'admin');
+ if (!authenticated || hasTeamAdmin) {
+ list.push({ label: 'Team Admin', action: 'team-admin', icon: '\ud83d\udc65' });
+ }
+
+ // Debug — admin only
+ if (!authenticated || sw?.isAdmin) {
+ list.push({ label: 'Debug', action: 'debug', icon: '\ud83d\udc1b' });
+ }
+
+ list.push({ divider: true });
+ list.push({ label: 'Sign Out', action: 'sign-out' });
+
+ return list;
+ }, [user, authenticated]);
+
+ function handleSelect(action) {
+ setOpen(false);
+ if (onAction) return onAction(action);
+ if (action === 'sign-out') {
+ sw?.auth?.logout();
+ } else {
+ sw?.emit('shell.navigate', { target: action });
+ }
+ }
+
+ const displayName = user?.display_name || user?.username || 'Demo User';
+ const avatar = user?.avatar || null;
+
+ return html`
+
+ <${Menu}
+ open=${open}
+ anchor=${btnRef.current}
+ direction=${placement}
+ items=${items}
+ onSelect=${handleSelect}
+ onClose=${() => setOpen(false)}
+ />
+ `;
+}