From 0315c76092369c5795e8d1a6b5ec1528c711b633 Mon Sep 17 00:00:00 2001
From: ZingerLittleBee <6970999@gmail.com>
Date: Thu, 28 May 2026 20:01:46 +0800
Subject: [PATCH 01/64] docs(spec): custom widget system + theme consolidation
design
---
.../2026-05-28-custom-widget-system-design.md | 476 ++++++++++++++++++
1 file changed, 476 insertions(+)
create mode 100644 docs/superpowers/specs/2026-05-28-custom-widget-system-design.md
diff --git a/docs/superpowers/specs/2026-05-28-custom-widget-system-design.md b/docs/superpowers/specs/2026-05-28-custom-widget-system-design.md
new file mode 100644
index 00000000..36486e9b
--- /dev/null
+++ b/docs/superpowers/specs/2026-05-28-custom-widget-system-design.md
@@ -0,0 +1,476 @@
+# Custom Widget System Design
+
+**Status:** Draft
+**Date:** 2026-05-28
+**Branch:** `minnetonka-v5`
+**Related:** SPA Themes (`docs/superpowers/specs/2026-05-26-custom-spa-themes-design.md`), existing dashboard widget system in `apps/web/src/components/dashboard/`, color theme system in `apps/web/src/themes/`.
+
+## Goal
+
+Replace the hard-coded 14-widget catalog and the three coexisting color-theme mechanisms with a single, pluggable **Widget Module** abstraction. Users — primarily admins — should be able to (a) write their own widgets as a single `.widget.js` file with a stable SDK, (b) install widgets from a URL or by uploading a file/zip, and (c) author SPA Themes that bundle CSS variables and widget collections in one package.
+
+The end state has one mental model: a Widget Module is an ESM file that calls `defineWidget({ ... })`. Built-in widgets, URL-installed widgets, and uploaded widgets are all the same shape. SPA Themes become the single delivery channel for both visual customization (CSS variables) and bundled widgets.
+
+## Non-goals
+
+- Official Marketplace / community widget registry (a curated documentation page lists examples instead).
+- Sandboxed execution (iframe / ShadowRealm). Widgets run in the main page context under a **trusted admin** model, equivalent to admin replacing the bundled SPA on disk.
+- Data migration. Project is in dev; old `dashboard_widget.widget_type` values, old `custom_theme` rows, and old localStorage keys are dropped without conversion.
+- Per-user widget installation. Widget modules are system-wide; only admins can install/uninstall.
+- Hot-reload during widget development (rely on rebuild + reinstall cycle; doc site shows a local dev workflow).
+- Backwards compatibility shims for the legacy `themes/` directory or `custom_theme` entity.
+
+---
+
+## 1. Architecture
+
+```
+┌──────────────────────────────────────────────────────────────┐
+│ Source │
+│ • Builtin (rust-embed, registered on server boot) │
+│ • URL (admin pastes https://.../foo.widget.js) │
+│ • Upload (admin drops .js or .zip collection) │
+└────────────────────────┬─────────────────────────────────────┘
+ │ stored as BLOB in widget_module table
+ ▼
+┌──────────────────────────────────────────────────────────────┐
+│ Backend: widget_module table + /api/widgets/* + /api/widgets │
+│ /{id}/code.js (raw ESM with cache-control + sha256 etag) │
+└────────────────────────┬─────────────────────────────────────┘
+ │ on SPA boot: GET /api/widgets
+ ▼
+┌──────────────────────────────────────────────────────────────┐
+│ Browser Loader │
+│ for each module: │
+│ fetch(code_url) │
+│ blob = new Blob([code], {type:'text/javascript'}) │
+│ mod = await import(URL.createObjectURL(blob)) │
+│ Registry.register(mod.default) // = WidgetModule │
+└────────────────────────┬─────────────────────────────────────┘
+ │
+ ▼
+┌──────────────────────────────────────────────────────────────┐
+│ Widget Registry (singleton in SPA) │
+│ get(id) / list() / register(module) / unregister(id) │
+└────────────────────────┬─────────────────────────────────────┘
+ │ resolved at render time
+ ▼
+┌──────────────────────────────────────────────────────────────┐
+│ Dashboard Grid (react-grid-layout, unchanged) │
+│ instance = { id, module_id, config_json, grid_x/y/w/h } │
+│ WidgetRenderer: Registry.get(module_id) → render via SDK │
+└──────────────────────────────────────────────────────────────┘
+
+ ▲
+ │ stable hook API
+ │
+┌──────────────────────────────────────────────────────────────┐
+│ Widget SDK (@serverbee/widget-sdk) │
+│ defineWidget({...}) │
+│ useServers() useServer(id) useMetric(id, path) │
+│ useHistory(id, path, range) useCapability(id, cap) │
+│ useTheme() useConfigUpdate() │
+│ z.* — schema for configSchema → auto ConfigDialog │
+│ │
+│ Runtime injection via import-map: │
+│ @serverbee/widget-sdk → /runtime/widget-sdk.js (shim) │
+│ shim re-exports globalThis.__SERVERBEE_SDK__ │
+└──────────────────────────────────────────────────────────────┘
+```
+
+### Key replacements
+
+| Old | New |
+|---|---|
+| `apps/web/src/lib/widget-types.ts` with 14 hard-coded types | One `.widget.tsx` module file per built-in widget, registered at server boot via rust-embed |
+| `dashboard_widget.widget_type` (free string) | `dashboard_widget.module_id` (FK-like string referencing `widget_module.id`) |
+| `apps/web/src/themes/` (8 preset CSS files + `preset-vars.ts`) | **Deleted.** CSS variables are declared inside a SPA Theme manifest |
+| `custom_theme` entity + OKLCH editor + `/api/settings/themes` | **Deleted.** Authoring happens by writing `manifest.json` |
+| Three coexisting theme mechanisms (preset / custom / SPA) | One: SPA Theme |
+| Data subscriptions duplicated in every widget | All data flows through SDK hooks |
+
+---
+
+## 2. Widget SDK
+
+The SDK is the single contract between widget authors and the host. Stability of this surface is the project's primary long-term commitment to widget authors.
+
+### 2.1 `defineWidget`
+
+```ts
+// @serverbee/widget-sdk
+import { defineWidget, z, useMetric } from '@serverbee/widget-sdk'
+
+const ConfigSchema = z.object({
+ serverId: z.serverId().describe('Target server'),
+ metric: z.metricPath().default('cpu.usage'),
+ threshold: z.number().min(0).max(100).default(80),
+})
+
+export default defineWidget({
+ id: 'com.example.cpu-gauge', // reverse-DNS, globally unique
+ name: 'CPU Gauge',
+ version: '1.0.0',
+ category: 'Real-time', // 'Real-time' | 'Charts' | 'Status'
+ sizing: {
+ defaultW: 3, defaultH: 3,
+ minW: 2, minH: 2,
+ strategy: 'aspect-square', // 'fixed' | 'free' | 'aspect-square' | 'content-height'
+ },
+ requiredCaps: [], // e.g. ['CAP_DOCKER']
+ configSchema: ConfigSchema,
+ component: ({ config }) => {
+ const value = useMetric(config.serverId, config.metric)
+ return
+ },
+})
+```
+
+`defineWidget` returns a `WidgetModule` value. The loader expects each `.widget.js` to `export default` exactly one `WidgetModule`. A `.zip` collection may contain many files, each one widget.
+
+### 2.2 Data subscription hooks
+
+| Hook | Returns | Underlying source |
+|---|---|---|
+| `useServers()` | `ServerSummary[]` (id, name, online, lastSeen) | Reuses `useServersWs` store |
+| `useServer(id)` | Full `ServerMetrics \| undefined` for one server | WS broadcast |
+| `useMetric(id, path)` | Single value extracted by dot/bracket path (`'cpu.usage'`, `'disks[0].used'`) | Derived from `useServer` |
+| `useHistory(id, path, range)` | `{ ts, value }[]` time series | TanStack Query → REST historical endpoint |
+| `useCapability(id, cap)` | `boolean` | Derived from `useServer` capability bitmask |
+| `useTheme()` | `{ mode: 'light' \| 'dark', cssVar: (name) => string }` | ThemeProvider context |
+| `useConfigUpdate()` | `(patch: Partial) => void` | Dispatches into dashboard editor state |
+
+All hooks accept `serverId | 'aggregate' | null`. With `null` they return `undefined`, letting components render a uniform placeholder.
+
+### 2.3 `configSchema` → auto-generated form
+
+The SDK ships a Zod-like mini-validator (`z`) with the standard primitives plus four widget-specific extensions:
+
+- `z.serverId()` — renders a server picker
+- `z.metricPath()` — renders a metric-path picker (browses the live ServerMetrics shape)
+- `z.color()` — renders a color picker
+- `z.duration()` — renders a duration input (e.g. `5m`, `1h`)
+
+`renderConfigForm(schema, value, onChange)` consumes the schema and produces the entire ConfigDialog UI. Widget authors write no form code.
+
+Rationale for not pulling full `zod`: keeps SDK runtime under ~10KB gzipped, avoids two zod versions colliding when authors also use zod in their own code.
+
+### 2.4 Component contract
+
+```ts
+type WidgetProps = {
+ config: TConfig // validated + defaulted
+ size: { w: number; h: number } // current grid pixel size
+ isEditing: boolean // dashboard is in edit mode
+}
+```
+
+No additional globals. All host capabilities are exposed through SDK hooks. Widgets may freely use React 19, JSX, and any pure-frontend npm dependencies they bundle themselves.
+
+### 2.5 SDK runtime injection
+
+```html
+
+
+```
+
+`/runtime/widget-sdk.js` is a thin shim emitted during SPA build that re-exports `globalThis.__SERVERBEE_SDK__`. The shim's URL is versioned (`/runtime/widget-sdk-{hash}.js`) so SPA upgrades invalidate the import-map entry deterministically.
+
+Effect:
+- Widget authors `import { ... } from '@serverbee/widget-sdk'` like any npm package, with full TypeScript type-completion.
+- At runtime each loaded widget shares the host's React/SDK singletons — no duplicate React copies, no hook-rule violations.
+- Updating the SDK ships once with the main SPA; all widgets follow.
+
+### 2.6 Version compatibility
+
+Each `defineWidget` call implicitly carries the `sdkVersion` it was built against (injected by the SDK build). The Registry validates on registration:
+
+- Major mismatch → reject load, surface error in admin UI ("widget built for SDK v2, host is v3 — please update widget").
+- Minor older → load with `console.warn`.
+- Patch difference → silent.
+
+Deprecations live for at least one minor cycle before removal.
+
+---
+
+## 3. Distribution & Installation
+
+### 3.1 `widget_module` table
+
+```rust
+pub struct Model {
+ pub id: String, // 'com.example.cpu-gauge'
+ pub version: String, // semver
+ pub source_type: SourceType, // Builtin | Url | Upload
+ pub source_url: Option, // original URL for Url, original filename for Upload
+ pub manifest_json: String, // cached metadata extracted at install time
+ pub code_sha256: String, // content fingerprint
+ pub code_blob: Vec, // ESM bytes; empty for Builtin (served via rust-embed)
+ pub installed_by: Option, // user_id, null for Builtin
+ pub installed_at: DateTimeWithTimeZone,
+ pub enabled: bool,
+}
+```
+
+Decision: **widget code lives in SQLite, not on a remote CDN.** This keeps offline VPS installs functional, lets the backend serve with cache headers, prevents silent upstream tampering after install, and gives admins one place to audit.
+
+### 3.2 Loader sequence
+
+```
+1. SPA boots → GET /api/widgets
+ → returns [{ id, version, manifest, code_url, sha256 }, ...]
+2. For each module in parallel:
+ fetch(code_url) // cache-friendly, etag = sha256
+ blob = new Blob([text], { type: 'text/javascript' })
+ mod = await import(URL.createObjectURL(blob))
+ Registry.register(mod.default)
+3. Failures are isolated: a broken module surfaces in the admin UI as
+ "Failed to load" without blocking other widgets or the dashboard.
+```
+
+### 3.3 Installation paths
+
+| Source | Admin UX | Backend behavior |
+|---|---|---|
+| **Builtin** | n/a — registered at server boot from rust-embed | `INSERT … ON CONFLICT(id) DO UPDATE` on every boot |
+| **URL** | Settings → Widgets → "Add by URL" → paste raw JS URL → backend fetches → preview manifest → confirm install | Server-side fetch with allowlist for `http(s)`, body size cap, MIME check; static ESM parse (via `oxc` or `swc`) to extract `defineWidget({...})` literal for manifest |
+| **Upload (.js)** | Settings → Widgets → drag file → preview manifest → confirm install | Same parse pipeline as URL, skipping fetch |
+| **Upload (.zip)** | Settings → Widgets → drag zip → preview collection (list of widgets) → confirm install | Unzip with zip-slip guard, read `collection.json`, register each listed `.widget.js` |
+
+### 3.4 Zip collection format
+
+```
+my-collection.zip
+├── collection.json
+├── widgets/
+│ ├── cpu-gauge.widget.js
+│ ├── mem-chart.widget.js
+│ └── disk-io.widget.js
+└── assets/ # optional static assets (icons etc.)
+ └── icon.svg
+```
+
+`collection.json`:
+
+```json
+{
+ "id": "com.example.my-pack",
+ "name": "Example Widget Pack",
+ "version": "1.0.0",
+ "author": "Jane Doe",
+ "description": "CPU, memory, and disk widgets.",
+ "widgets": [
+ "widgets/cpu-gauge.widget.js",
+ "widgets/mem-chart.widget.js",
+ "widgets/disk-io.widget.js"
+ ],
+ "license": "MIT"
+}
+```
+
+Each listed file must be valid ESM exporting a single `defineWidget({ id, version, ... })`. The backend extracts the id and version statically and refuses zips with duplicate ids.
+
+### 3.5 Permissions
+
+- **Admin** — install, uninstall, enable/disable widget modules; install via URL or upload.
+- **Member** — see installed widgets in the picker, use them on dashboards. Cannot install/uninstall.
+- `requiredCaps` is enforced at render time. When the selected server lacks a capability, the widget renders a disabled placeholder card explaining which capability is missing.
+
+### 3.6 Marketplace (out of scope)
+
+No official marketplace. Documentation maintains a hand-curated "Community Widgets" page with example URLs. Future extension point: a `marketplace_url` setting whose JSON manifest the SPA can iterate over for browsing — not built now.
+
+---
+
+## 4. Theme System Consolidation
+
+### 4.1 What gets deleted
+
+| Path | Action |
+|---|---|
+| `apps/web/src/themes/` (entire directory: 8 preset `.css` files, `preset-vars.ts`, `index.ts`) | Delete |
+| `apps/web/src/api/themes.ts` | Delete |
+| `apps/web/src/components/theme/` (theme-card, theme-editor, theme-preview, oklch-picker, delete-theme-dialog) | Delete |
+| `apps/web/src/routes/_authed/settings/appearance/themes.*` (all custom-theme subroutes) | Delete |
+| `apps/web/src/lib/theme-ref.ts` (the `preset:` / `custom:` prefix parser) | Delete |
+| `crates/server/src/entity/custom_theme.rs` | Delete |
+| Custom theme CRUD in `crates/server/src/router/api/theme.rs` | Delete (leave file if it still hosts unrelated handlers; otherwise remove) |
+| sea-orm migration to `DROP TABLE custom_theme` | New migration; `down()` is a no-op per project convention |
+
+### 4.2 What stays
+
+- `apps/web/src/components/spa-theme/` (SPA Theme admin UI) — primary path going forward
+- `apps/web/src/api/spa-themes.ts`
+- `crates/server/src/entity/spa_theme.rs`
+- `ThemeProvider` — heavily simplified
+
+### 4.3 SPA Theme manifest, extended
+
+```json
+{
+ "id": "com.example.dark-night",
+ "name": "Dark Night",
+ "version": "1.2.0",
+ "author": "Jane Doe",
+ "description": "Dark monochrome with custom gauge widgets.",
+
+ "cssVars": {
+ "light": {
+ "--background": "oklch(1 0 0)",
+ "--foreground": "oklch(0.145 0 0)",
+ "--primary": "oklch(0.205 0 0)"
+ },
+ "dark": {
+ "--background": "oklch(0.145 0 0)",
+ "--foreground": "oklch(0.985 0 0)",
+ "--primary": "oklch(0.985 0 0)"
+ }
+ },
+
+ "widgets": [
+ "widgets/special-gauge.widget.js"
+ ],
+
+ "preview": "preview.png"
+}
+```
+
+A theme may declare **only** `cssVars` (pure recolor), **only** `widgets` (a widget pack), or both. This unifies the two contributor mental models — "I want a different look" and "I want new widgets" — into a single artifact and a single upload UX.
+
+Zip structure:
+
+```
+my-theme.zip
+├── manifest.json
+├── preview.png (optional)
+└── widgets/ (optional, present when manifest.widgets non-empty)
+ └── special-gauge.widget.js
+```
+
+### 4.4 Simplified `ThemeProvider`
+
+```ts
+type ThemeContext = {
+ mode: 'light' | 'dark' | 'system' // localStorage 'theme-mode'
+ setMode: (m) => void
+ activeSpaThemeId: string | null // server-side via /api/settings/active-spa-theme
+}
+```
+
+Activation flow:
+
+1. SPA boots → `GET /api/settings/active-spa-theme` → load manifest.
+2. Inject `manifest.cssVars.light` into `