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 `