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` + + `; + } + 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)} + /> + `; +}