Changeset 0.37.4 (#216)

Co-authored-by: gobha <jasafpro@gmail.com>
Co-committed-by: gobha <jasafpro@gmail.com>
This commit is contained in:
2026-03-21 01:15:09 +00:00
committed by xcaliber
parent fc43618501
commit 05b5affdac
7 changed files with 294 additions and 4 deletions

36
src/js/sw/shell/app.js Normal file
View File

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

View File

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

View File

@@ -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`
<div class="sw-surface-viewport ${className}">
${surface}
</div>
`;
}

View File

@@ -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`
<button class="sw-user-menu__trigger" ref=${btnRef}
onClick=${() => setOpen(!open)} aria-label="User menu">
<${Avatar} src=${avatar} name=${displayName} size="sm" />
</button>
<${Menu}
open=${open}
anchor=${btnRef.current}
direction=${placement}
items=${items}
onSelect=${handleSelect}
onClose=${() => setOpen(false)}
/>
`;
}