Changeset 0.37.4 (#216)
Co-authored-by: gobha <jasafpro@gmail.com> Co-committed-by: gobha <jasafpro@gmail.com>
This commit is contained in:
@@ -107,3 +107,33 @@
|
|||||||
text-align: center;
|
text-align: center;
|
||||||
background: var(--bg-surface);
|
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;
|
||||||
|
}
|
||||||
|
|||||||
103
src/dev.html
103
src/dev.html
@@ -62,6 +62,11 @@
|
|||||||
const { Tabs } = await import('./js/sw/primitives/tabs.js');
|
const { Tabs } = await import('./js/sw/primitives/tabs.js');
|
||||||
const { Dropdown } = await import('./js/sw/primitives/dropdown.js');
|
const { Dropdown } = await import('./js/sw/primitives/dropdown.js');
|
||||||
const { AppShell } = await import('./js/sw/shell/app-shell.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 ────────────────────────────
|
// ── SDK Boot ────────────────────────────
|
||||||
const { boot } = await import('./js/sw/sdk/index.js');
|
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`
|
||||||
|
<div class="gallery">
|
||||||
|
<h1>Shell Demo</h1>
|
||||||
|
<p class="subtitle">Layer 2 — Application shell (v0.37.4)</p>
|
||||||
|
|
||||||
|
<!-- UserMenu -->
|
||||||
|
<div class="section">
|
||||||
|
<h2>UserMenu</h2>
|
||||||
|
<div class="row">
|
||||||
|
<${UserMenu} />
|
||||||
|
<${UserMenu} placement="down-left" />
|
||||||
|
</div>
|
||||||
|
<p style="color:var(--text-3); font-size:0.8rem; margin-top:0.5rem">
|
||||||
|
RBAC-gated flyout. Without backend, shows all items for demo.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Imperative Confirm -->
|
||||||
|
<div class="section">
|
||||||
|
<h2>sw.confirm()</h2>
|
||||||
|
<div class="row">
|
||||||
|
<${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`
|
||||||
|
<span style="font-weight:600; color: ${confirmResult ? 'var(--success)' : 'var(--danger)'}">
|
||||||
|
Result: ${String(confirmResult)}
|
||||||
|
</span>
|
||||||
|
`}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Imperative Prompt -->
|
||||||
|
<div class="section">
|
||||||
|
<h2>sw.prompt()</h2>
|
||||||
|
<div class="row">
|
||||||
|
<${Button} variant="secondary" onClick=${async () => {
|
||||||
|
const val = await sw.prompt('Enter a name:', { defaultValue: 'untitled' });
|
||||||
|
setPromptResult(val);
|
||||||
|
}}>Prompt<//>
|
||||||
|
${promptResult !== null && html`
|
||||||
|
<span style="font-weight:600; color:var(--text-2)">
|
||||||
|
Result: ${JSON.stringify(promptResult)}
|
||||||
|
</span>
|
||||||
|
`}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Surface Viewport -->
|
||||||
|
<div class="section">
|
||||||
|
<h2>SurfaceViewport</h2>
|
||||||
|
<p style="color:var(--text-3); font-size:0.8rem; margin-bottom:0.5rem">
|
||||||
|
UserMenu is placed by the surface (top-right), not the shell.
|
||||||
|
</p>
|
||||||
|
<div style="border:1px dashed var(--border); border-radius:var(--radius, 6px); height:200px; position:relative; overflow:hidden;">
|
||||||
|
<${SurfaceViewport} surface=${html`
|
||||||
|
<div style="padding:1rem; color:var(--text-2); height:100%;">
|
||||||
|
<p>Mock surface content. The shell provides the viewport; the surface owns everything inside.</p>
|
||||||
|
<div style="position:absolute; top:0.5rem; right:0.5rem;">
|
||||||
|
<${UserMenu} placement="down-left" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- App component tree -->
|
||||||
|
<div class="section">
|
||||||
|
<h2>App Component Tree</h2>
|
||||||
|
<div style="font-family:var(--font-mono, monospace); font-size:0.8rem; color:var(--text-2); background:var(--bg-1); padding:0.75rem; border-radius:6px; white-space:pre;">${
|
||||||
|
`<App>
|
||||||
|
<AppShell banner message footer>
|
||||||
|
<SurfaceViewport surface={...} />
|
||||||
|
</AppShell>
|
||||||
|
<ToastContainer />
|
||||||
|
<DialogStack />
|
||||||
|
</App>`
|
||||||
|
}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
function PrimitivesPage() {
|
function PrimitivesPage() {
|
||||||
const [dialogOpen, setDialogOpen] = useState(false);
|
const [dialogOpen, setDialogOpen] = useState(false);
|
||||||
const [drawerOpen, setDrawerOpen] = useState(false);
|
const [drawerOpen, setDrawerOpen] = useState(false);
|
||||||
@@ -424,8 +522,6 @@
|
|||||||
<${Button} variant="secondary" onClick=${() => toast.info('New message received')}>Info<//>
|
<${Button} variant="secondary" onClick=${() => toast.info('New message received')}>Info<//>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<${ToastContainer} />
|
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
@@ -492,10 +588,13 @@
|
|||||||
</nav>
|
</nav>
|
||||||
<div class="dev-page">
|
<div class="dev-page">
|
||||||
${page === 'primitives' && html`<${PrimitivesPage} />`}
|
${page === 'primitives' && html`<${PrimitivesPage} />`}
|
||||||
|
${page === 'shell' && html`<${ShellPage} />`}
|
||||||
${page === 'sdk' && html`<${SDKPage} />`}
|
${page === 'sdk' && html`<${SDKPage} />`}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<//>
|
<//>
|
||||||
|
<${ToastContainer} />
|
||||||
|
<${DialogStack} />
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -18,6 +18,8 @@ import { createDomains } from './api-domains.js';
|
|||||||
import { createEvents } from './events.js';
|
import { createEvents } from './events.js';
|
||||||
import { createPipe } from './pipe.js';
|
import { createPipe } from './pipe.js';
|
||||||
import { createTheme } from './theme.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.
|
* Boot the SDK. Assembles modules, loads auth state, connects WebSocket.
|
||||||
@@ -91,8 +93,20 @@ export async function boot() {
|
|||||||
sw.pipe = pipe;
|
sw.pipe = pipe;
|
||||||
sw.theme = theme;
|
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
|
// Marker for idempotency
|
||||||
sw._sdk = '0.37.3';
|
sw._sdk = '0.37.4';
|
||||||
|
|
||||||
// 8. Expose globally
|
// 8. Expose globally
|
||||||
window.sw = sw;
|
window.sw = sw;
|
||||||
@@ -114,7 +128,7 @@ export async function boot() {
|
|||||||
// 10. Signal ready
|
// 10. Signal ready
|
||||||
events.emit('sdk.ready', {}, { localOnly: true });
|
events.emit('sdk.ready', {}, { localOnly: true });
|
||||||
document.dispatchEvent(new CustomEvent('sw:ready', { detail: { sw } }));
|
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;
|
return sw;
|
||||||
}
|
}
|
||||||
|
|||||||
36
src/js/sw/shell/app.js
Normal file
36
src/js/sw/shell/app.js
Normal 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} />
|
||||||
|
`;
|
||||||
|
}
|
||||||
17
src/js/sw/shell/dialog-stack.js
Normal file
17
src/js/sw/shell/dialog-stack.js
Normal 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} />
|
||||||
|
`;
|
||||||
|
}
|
||||||
15
src/js/sw/shell/surface-viewport.js
Normal file
15
src/js/sw/shell/surface-viewport.js
Normal 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>
|
||||||
|
`;
|
||||||
|
}
|
||||||
79
src/js/sw/shell/user-menu.js
Normal file
79
src/js/sw/shell/user-menu.js
Normal 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)}
|
||||||
|
/>
|
||||||
|
`;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user