All checks were successful
CI/CD / detect-changes (push) Successful in 4s
CI/CD / test-sqlite (push) Has been skipped
CI/CD / test-frontend (push) Has been skipped
CI/CD / test-go-pg (push) Has been skipped
CI/CD / test-runners (push) Has been skipped
CI/CD / e2e-smoke (push) Has been skipped
CI/CD / build-and-deploy (push) Successful in 30s
Co-authored-by: Jeffrey Smith <jasafpro@gmail.com> Co-committed-by: Jeffrey Smith <jasafpro@gmail.com>
449 lines
17 KiB
Markdown
449 lines
17 KiB
Markdown
# DESIGN: Shell Contract — v0.7.0
|
||
|
||
## Status: Proposed
|
||
|
||
## Problem
|
||
|
||
Extension surfaces render into `#extension-mount` with no shell chrome.
|
||
A full audit (v0.7.0 surface audit) 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.
|