V0.7.0 shell contract (#54)
All checks were successful
All checks were successful
This commit was merged in pull request #54.
This commit is contained in:
448
docs/DESIGN-shell-contract.md
Normal file
448
docs/DESIGN-shell-contract.md
Normal file
@@ -0,0 +1,448 @@
|
||||
# DESIGN: Shell Contract — v0.7.0
|
||||
|
||||
## Status: Proposed
|
||||
|
||||
## Problem
|
||||
|
||||
Extension surfaces render into `#extension-mount` with no shell chrome.
|
||||
A full audit (docs/AUDIT-surfaces.md) found: 3/4 primary surfaces lack a
|
||||
notification bell, 2/4 lack a user menu, 4 different topbar implementations,
|
||||
user menu never updates on package/role changes, toast-and-forget error
|
||||
handling, and empty states that explain nothing.
|
||||
|
||||
## Solution
|
||||
|
||||
### 1. Shell Topbar — Two-Slot Model
|
||||
|
||||
The kernel injects a topbar for all extension surfaces. Two named slots
|
||||
(left + center) let surfaces customize without replacing the entire bar.
|
||||
|
||||
**Layout:**
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────────────────────────────┐
|
||||
│ ← │ [left] │ [center: flex-1] │ 🔔 │ 👤 │
|
||||
└──────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
- **← (home):** Always visible. Navigates to `__BASE__/`. Simple `<a>`.
|
||||
- **Left slot:** Defaults to manifest title. Surfaces override with `setLeft()`.
|
||||
- **Center slot:** Empty by default. Surfaces inject tabs, search, pickers.
|
||||
`flex: 1` — expands to fill available space.
|
||||
- **Bell + User Menu:** Always visible. Kernel-managed.
|
||||
|
||||
**Template change** (`surfaces/extension.html`):
|
||||
|
||||
```html
|
||||
{{define "surface-extension"}}
|
||||
<div id="extension-surface" class="extension-surface"
|
||||
data-surface-id="{{.Surface}}">
|
||||
<div id="shell-topbar" class="sw-topbar sw-topbar--shell"></div>
|
||||
<div id="extension-mount" class="extension-mount" data-ext="{{.Surface}}"></div>
|
||||
</div>
|
||||
{{end}}
|
||||
```
|
||||
|
||||
**SDK API:**
|
||||
|
||||
```js
|
||||
sw.shell.topbar.setLeft(vnode) // Override left slot (default: title)
|
||||
sw.shell.topbar.setSlot(vnode) // Set center slot content
|
||||
sw.shell.topbar.setTitle(str) // Shorthand: setLeft with plain text
|
||||
sw.shell.topbar.hide() // Remove topbar entirely
|
||||
sw.shell.topbar.show() // Restore after hiding
|
||||
```
|
||||
|
||||
### 2. Three Navigation Patterns
|
||||
|
||||
The shell topbar provides the top bar. What happens below it is the
|
||||
surface's business. Three patterns emerge naturally:
|
||||
|
||||
#### Pattern A — Default (simple extensions, Docs)
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────────┐
|
||||
│ ← │ Surface Title │ 🔔 │ 👤 │
|
||||
├──────────────────────────────────────────┤
|
||||
│ │
|
||||
│ Content (full width) │
|
||||
│ │
|
||||
└──────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
Title only, no tabs, no sidebar. Content gets everything.
|
||||
Zero code required — kernel defaults handle it.
|
||||
|
||||
**Used by:** Docs, Notes, Chat, simple extensions.
|
||||
|
||||
#### Pattern B — Flat Tabs (no sidebar, full width)
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────────────────────────┐
|
||||
│ ← │ Title │ Tab1 │ Tab2 │ Tab3 │ Tab4 │ │ 🔔 │ 👤 │
|
||||
├──────────────────────────────────────────────────────────┤
|
||||
│ │
|
||||
│ Content (full width, no sidebar) │
|
||||
│ │
|
||||
└──────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
Tabs in the topbar center slot. No sidebar — content fills the full
|
||||
viewport width. For surfaces with 3–7 sections that don't have sub-items.
|
||||
More real estate for content than a sidebar layout.
|
||||
|
||||
**Surface code:**
|
||||
|
||||
```js
|
||||
sw.shell.topbar.setSlot(html`
|
||||
<div class="sw-topbar__tabs">
|
||||
${sections.map(s => html`
|
||||
<a class="sw-topbar__tab ${active === s.key ? 'active' : ''}"
|
||||
href=${s.href} onClick=${navigate}>${s.label}</a>
|
||||
`)}
|
||||
</div>
|
||||
`);
|
||||
```
|
||||
|
||||
**Used by:** Team Admin (Members / Connections / Workflows / Settings / Activity),
|
||||
Settings, Schedules.
|
||||
|
||||
#### Pattern C — Category Tabs + Sidebar (two-level)
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ ← │ Title │ People │ Workflows │ System │ Mon │ 🔔 │ 👤 │
|
||||
├──────────┬──────────────────────────────────────────────────┤
|
||||
│ Users │ │
|
||||
│ Teams │ Content │
|
||||
│ Groups │ │
|
||||
│ │ │
|
||||
└──────────┴──────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
Major categories in the topbar (via center slot). Surface renders its own
|
||||
sidebar inside the content area for sub-navigation within the active
|
||||
category. The shell topbar doesn't know about the sidebar — it's a
|
||||
surface-level div below `#extension-mount`.
|
||||
|
||||
**Surface code:**
|
||||
|
||||
```js
|
||||
// Topbar: major categories
|
||||
sw.shell.topbar.setLeft(html`
|
||||
<img src="${BASE}/favicon.svg" width="18" height="18" style="vertical-align:-3px" />
|
||||
<span style="margin-left:6px">Administration</span>
|
||||
`);
|
||||
sw.shell.topbar.setSlot(html`
|
||||
<div class="sw-topbar__tabs">
|
||||
${categories.map(c => html`
|
||||
<a class="sw-topbar__tab ${activeCat === c.key ? 'active' : ''}"
|
||||
href=${c.href} onClick=${navigate}>
|
||||
<${CatIcon} paths=${c.icon} /> ${c.label}
|
||||
</a>
|
||||
`)}
|
||||
</div>
|
||||
`);
|
||||
|
||||
// Content area: sidebar is surface-owned
|
||||
return html`
|
||||
<div class="admin-body">
|
||||
<div class="admin-nav">
|
||||
${sidebarSections.map(s => html`...`)}
|
||||
</div>
|
||||
<div class="admin-content">
|
||||
<${SectionComponent} />
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
```
|
||||
|
||||
**Used by:** Admin.
|
||||
|
||||
### 3. Kernel-Provided Tab CSS
|
||||
|
||||
The kernel provides `.sw-topbar__tabs` and `.sw-topbar__tab` CSS so
|
||||
surfaces get consistent tab styling. Not required — surfaces can style
|
||||
their own slot content however they want.
|
||||
|
||||
```css
|
||||
.sw-topbar__tabs {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--sp-1);
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.sw-topbar__tab {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--sp-2);
|
||||
padding: var(--sp-2) var(--sp-3);
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: var(--text-2);
|
||||
text-decoration: none;
|
||||
border-radius: var(--radius-sm);
|
||||
transition: color var(--transition), background var(--transition);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.sw-topbar__tab:hover {
|
||||
color: var(--text);
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
|
||||
.sw-topbar__tab.active {
|
||||
color: var(--text);
|
||||
background: var(--bg-2);
|
||||
}
|
||||
```
|
||||
|
||||
### 4. Primary Surface Migrations
|
||||
|
||||
#### Settings → Pattern B (flat tabs)
|
||||
|
||||
Currently: custom topbar (back + icon + "Settings"), sidebar nav.
|
||||
After: shell topbar with flat tabs, no sidebar. Content full width.
|
||||
|
||||
Settings has 6 sections (General / Appearance / Profile / Teams /
|
||||
Connections / Notifications) — perfect for flat tabs. The sidebar was
|
||||
thin (~140px) and ate width from the content area for no good reason.
|
||||
|
||||
**Migration:**
|
||||
- Delete `settings-topbar` div and CSS.
|
||||
- Delete sidebar nav. Move section links into `sw.shell.topbar.setSlot()`.
|
||||
- Remove `sb_settings_return` sessionStorage — shell home link handles it.
|
||||
- Content area becomes full-width.
|
||||
- Fix Teams section: add "Open Team Admin →" link, show role, add leave action.
|
||||
|
||||
Extension config sections (`__CONFIG_SECTIONS__`) render as additional
|
||||
tabs after the divider. Same as today, just in the topbar instead of sidebar.
|
||||
|
||||
#### Admin → Pattern C (category tabs + sidebar)
|
||||
|
||||
Currently: custom topbar (favicon + "Administration" + category tabs + UserMenu).
|
||||
After: shell topbar with category tabs in center slot, surface-owned sidebar.
|
||||
|
||||
**Migration:**
|
||||
- Delete custom `admin-topbar` div and CSS.
|
||||
- `sw.shell.topbar.setLeft()` with favicon + "Administration".
|
||||
- `sw.shell.topbar.setSlot()` with category tabs (People / Workflows / System / Monitoring).
|
||||
- Bell and user menu come from the shell — delete the explicit `<${UserMenu}>`.
|
||||
- Admin sidebar and content area unchanged — they're below the topbar.
|
||||
- Delete custom `CatIcon` renderer if category tab icons use standard SVG.
|
||||
- Fix hardcoded `favicon.svg` — left slot can use theme-aware image.
|
||||
|
||||
**Result:** Admin looks identical to today but its topbar is kernel-managed.
|
||||
Bell added for free. User menu reactive for free.
|
||||
|
||||
#### Team Admin → Pattern B (flat tabs)
|
||||
|
||||
Currently: custom topbar (back + "Team Admin: {name}"), sidebar nav.
|
||||
After: shell topbar with flat tabs, no sidebar.
|
||||
|
||||
With Groups removed, Team Admin has 5 sections: Members / Connections /
|
||||
Workflows / Settings / Activity. Perfect for flat tabs.
|
||||
|
||||
**Migration:**
|
||||
- Delete `team-admin-topbar` div and CSS.
|
||||
- `sw.shell.topbar.setTitle('Team Admin: ' + team.name)` after team fetch.
|
||||
- Section tabs into `sw.shell.topbar.setSlot()`.
|
||||
- Delete sidebar nav. Content full-width.
|
||||
- Remove `sb_team_admin_return` sessionStorage.
|
||||
- Remove Groups tab entirely (37-line dead-end).
|
||||
- Fix signoff display: `user_id` → `sw.users.displayName()`.
|
||||
|
||||
#### Docs → Pattern A (default)
|
||||
|
||||
Currently: imports `shell/topbar.js` and renders it explicitly.
|
||||
After: shell topbar auto-renders. No surface code needed.
|
||||
|
||||
**Migration:**
|
||||
- Delete `import { Topbar }` and `<${Topbar}>` render.
|
||||
- Shell topbar provides title + bell + user menu automatically.
|
||||
- Docs sidebar (document list) is in the content area, unaffected.
|
||||
|
||||
### 5. User Menu Reactivity
|
||||
|
||||
**The single most impactful fix.**
|
||||
|
||||
**Backend — new WS events:**
|
||||
|
||||
```go
|
||||
// After package install/uninstall/enable/disable:
|
||||
h.hub.BroadcastToUser(userID, "package.changed", map[string]string{
|
||||
"action": "installed", "id": packageID,
|
||||
})
|
||||
|
||||
// After team role/membership change:
|
||||
h.hub.BroadcastToUser(userID, "auth.changed", map[string]string{
|
||||
"reason": "team_role",
|
||||
})
|
||||
```
|
||||
|
||||
**Frontend** (`user-menu.js`):
|
||||
|
||||
```js
|
||||
useEffect(() => {
|
||||
if (!sw?.api?.surfaces?.list) return;
|
||||
|
||||
function fetchSurfaces() {
|
||||
sw.api.surfaces.list().then(data => {
|
||||
const raw = Array.isArray(data) ? data : data?.data || [];
|
||||
setAllSurfaces(raw);
|
||||
}).catch(() => {});
|
||||
}
|
||||
|
||||
fetchSurfaces();
|
||||
|
||||
const off1 = sw.on?.('package.changed', fetchSurfaces);
|
||||
const off2 = sw.on?.('auth.changed', fetchSurfaces);
|
||||
|
||||
return () => {
|
||||
if (typeof off1 === 'function') off1();
|
||||
if (typeof off2 === 'function') off2();
|
||||
};
|
||||
}, [authenticated]);
|
||||
```
|
||||
|
||||
**Event inventory:**
|
||||
|
||||
| Event | Trigger | Payload |
|
||||
|-------|---------|---------|
|
||||
| `package.changed` | Install, uninstall, enable, disable | `{ action, id }` |
|
||||
| `auth.changed` | Team role change, group membership change | `{ reason }` |
|
||||
| `notification.read` | Mark notification read | `{ id }` |
|
||||
| `notification.all_read` | Mark all read | `{}` |
|
||||
|
||||
### 6. Notification Read Broadcast
|
||||
|
||||
**Backend** — after `MarkRead()` / `MarkAllRead()`:
|
||||
|
||||
```go
|
||||
h.hub.BroadcastToUser(userID, "notification.read", map[string]string{"id": id})
|
||||
h.hub.BroadcastToUser(userID, "notification.all_read", nil)
|
||||
```
|
||||
|
||||
**Frontend** — `NotificationBell` listens for `.created`, `.read`, `.all_read`:
|
||||
|
||||
```js
|
||||
const onRead = (e) => {
|
||||
setNotifications(prev => prev.map(n =>
|
||||
n.id === e.id ? { ...n, read_at: new Date().toISOString() } : n
|
||||
));
|
||||
};
|
||||
const onAllRead = () => {
|
||||
setNotifications(prev => prev.map(n => ({
|
||||
...n, read_at: n.read_at || new Date().toISOString()
|
||||
})));
|
||||
};
|
||||
```
|
||||
|
||||
### 7. Shell Announcement Global Dismiss
|
||||
|
||||
On dismiss, write: `localStorage.setItem('armature_dismissed_' + hash(text), '1')`.
|
||||
On mount, check. New announcements (changed text) show again.
|
||||
|
||||
Implementation: inline `<script>` in `base.html` — checks localStorage on
|
||||
DOMContentLoaded. Dismiss button writes the key. Works on every surface
|
||||
including login.
|
||||
|
||||
### 8. Error Handling Pass
|
||||
|
||||
**Pattern** — inline error + retry replaces toast-and-forget:
|
||||
|
||||
```js
|
||||
const [error, setError] = useState(null);
|
||||
|
||||
async function load() {
|
||||
setError(null);
|
||||
try {
|
||||
const data = await sw.api.whatever.list();
|
||||
setItems(data || []);
|
||||
} catch (e) { setError(e.message); }
|
||||
}
|
||||
|
||||
// In render:
|
||||
${error && html`
|
||||
<div class="sw-inline-error">
|
||||
<span>${error}</span>
|
||||
<button class="sw-btn sw-btn--secondary sw-btn--sm"
|
||||
onClick=${load}>Retry</button>
|
||||
</div>
|
||||
`}
|
||||
```
|
||||
|
||||
**New CSS class** (`sw-primitives.css`):
|
||||
|
||||
```css
|
||||
.sw-inline-error {
|
||||
display: flex; align-items: center; gap: var(--sp-3);
|
||||
padding: var(--sp-3) var(--sp-4);
|
||||
background: var(--bg-2); border: 1px solid var(--danger);
|
||||
border-radius: var(--radius); font-size: 13px; color: var(--danger);
|
||||
}
|
||||
```
|
||||
|
||||
**Sections requiring this pass:**
|
||||
|
||||
| Surface | Section | Current | After |
|
||||
|---------|---------|---------|-------|
|
||||
| Admin | Workflows | Toast + empty | Inline error + retry |
|
||||
| Admin | Packages | Toast + empty | Inline error + retry |
|
||||
| Admin | Groups | Toast + empty | Inline error + retry |
|
||||
| Team Admin | Workflows (adopt) | Toast + "No global workflows" | Inline error + retry |
|
||||
| Team Admin | Members | Toast + empty | Inline error + retry |
|
||||
| Settings | General | Console warn | Inline error + retry |
|
||||
| Settings | Teams | Toast + empty | Inline error + retry |
|
||||
| Workflow Demo (pkg) | Main | Silent swallow | Inline error + retry |
|
||||
|
||||
### 9. Empty State Guidance
|
||||
|
||||
Every "No X" empty state gets: one-line explanation, primary action or doc link.
|
||||
|
||||
| Surface | Section | Current | After |
|
||||
|---------|---------|---------|-------|
|
||||
| Admin | Groups | "No groups" | "Groups control access to surfaces and features via permissions." + Create button |
|
||||
| Admin | Workflows | "No workflows" | "Workflows define multi-stage approval processes with team roles and SLA tracking." + Create button |
|
||||
| Admin | Teams | "No teams" | "Teams group users for shared connections, workflows, and access control." + Create button |
|
||||
| Team Admin | Workflows | "No workflows — create one or adopt" | Add: "Adopt copies a global workflow for this team to customize." |
|
||||
| Settings | Notifications | "No notification preferences" | "Preferences appear when notification types are configured by an administrator." |
|
||||
|
||||
## Rebrand Asset Inventory
|
||||
|
||||
### Current → Target
|
||||
|
||||
| File | Current | Target |
|
||||
|------|---------|--------|
|
||||
| `favicon.svg` | Dark icon ✅ | Unchanged |
|
||||
| `favicon-light.svg` | **MISNAMED** (520×80 wordmark) | Square icon, light mode **(NEW)** |
|
||||
| `favicon-32.png` | Dark raster ✅ | Unchanged |
|
||||
| `favicon-256.png` | Dark raster ✅ | Unchanged |
|
||||
| `favicon-light-32.png` | Missing | Light raster **(NEW)** |
|
||||
| `favicon-light-256.png` | Missing | Light raster **(NEW)** |
|
||||
| `favicon.ico` | Dark ✅ | Unchanged |
|
||||
| `wordmark.svg` | Missing | **RENAMED** from current `favicon-light.svg` |
|
||||
| `wordmark-dark.svg` | Missing | Light text #E5E5E5 on transparent **(NEW)** |
|
||||
| `manifest.json` | "Self-hosted AI chat..." | "Self-hosted extension platform..." |
|
||||
|
||||
## Bug Fixes (bundled)
|
||||
|
||||
- **evil-chat:** `finally` cleanup + tighten `409` assertion.
|
||||
- **Workflow demo:** Silent `catch` → inline error + retry.
|
||||
- **Hello dashboard:** Delete `packages/hello-dashboard/`.
|
||||
|
||||
## Changeset Plan
|
||||
|
||||
| CS | Scope | Description |
|
||||
|----|-------|-------------|
|
||||
| CS-1 | Backend (Go) | WS events: `notification.read`, `notification.all_read`, `package.changed`, `auth.changed`. Tests. |
|
||||
| CS-2 | Frontend (JS + HTML) | Shell topbar component, SDK API (`setLeft`/`setSlot`/`setTitle`/`hide`), extension.html template, SDK boot auto-mount. |
|
||||
| CS-3 | Frontend (JS) | User menu reactivity: listen for `package.changed` + `auth.changed`. Notification bell: listen for `.read` + `.all_read`. |
|
||||
| CS-4 | Frontend (JS + HTML) | Surface migrations: Settings → Pattern B, Admin → Pattern C, Team Admin → Pattern B, Docs → Pattern A. Delete custom topbars. |
|
||||
| CS-5 | Frontend (JS + CSS) | Error handling pass + empty state guidance. `sw-inline-error` CSS. All sections from inventory. |
|
||||
| CS-6 | Frontend (JS) | Announcement global dismiss (localStorage). |
|
||||
| CS-7 | Static + docs | Rebrand assets, manifest.json, REBRAND-SPEC.md. |
|
||||
| CS-8 | Frontend (JS) | Bug fixes: evil-chat cleanup, workflow demo error, hello-dashboard deletion. |
|
||||
|
||||
Each changeset independently CI-green.
|
||||
Reference in New Issue
Block a user