From e8670ac590f61ae6d6cc130b82bef2f278328596 Mon Sep 17 00:00:00 2001 From: hzhang Date: Sat, 23 May 2026 01:00:49 +0100 Subject: [PATCH] =?UTF-8?q?feat:=20P5=20=E2=80=94=20first=20widget=20on=20?= =?UTF-8?q?screen=20(clock=20via=20SDK)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The dashboard finally has live content. `runtime.ts` now imports `defineWidget` from `@dashward/widget-sdk` (added as a workspace dependency of the container), defines a tabular-numerals clock widget inline, and mounts it onto the 12-column grid via a generic `mountWidget` + `createContext` pair that produces the SDK's config/system/lifecycle/shell APIs. container/runtime/runtime.ts - mountWidget(def, parent, instanceId, size): creates a grid card, applies grid-column/row span, builds the widget context, calls def.mount; wraps in try/catch so a mount failure visibly degrades the card (red border + error label) instead of breaking the page. - createContext: localStorage-backed config (persists per instanceId), theme reads dataset.theme and subscribes to the dashward:ThemeChanged CustomEvent (DBus signal forwarded from shell), shell.call delegates to window.__dashShell__. onMount fires via microtask; onUnmount handlers stored; visibility is a no-op until P7 (edit mode / workspace tracking). - clockDef: defineWidget; shadow DOM root, large tabular-numerals time string, re-renders on tick + config.onChange. container/runtime/styles.css - Real card style: half-transparent backdrop-filter blur with a light/dark variant, rounded 18px corners, subtle border and shadow. - .widget-error fallback for failed mount. - grid-auto-rows: minmax(120px, auto) so widgets have a sensible minimum row height. container/package.json - `@dashward/widget-sdk` is now a runtime dependency. esbuild bundles it into runtime.js. Verified on ubuntu2504-test VM: clock card renders top-left at 4-wide × 2-tall, ticks every second, follows system theme live via the P4 bridge (light → glass-white card, dark → glass-black card). Layout persistence, edit mode, dynamic disk loading of widget bundles, and iframe crash isolation per design §11 / §14 land in P6+. Co-Authored-By: Claude Opus 4.7 (1M context) --- container/package.json | 3 + container/runtime/runtime.ts | 184 +++++++++++++++++++++++++++++++---- container/runtime/styles.css | 71 +++++++------- pnpm-lock.yaml | 4 + 4 files changed, 206 insertions(+), 56 deletions(-) diff --git a/container/package.json b/container/package.json index f40bcb4..3239f54 100644 --- a/container/package.json +++ b/container/package.json @@ -8,6 +8,9 @@ "build": "node esbuild.config.js", "dev": "node esbuild.config.js --watch" }, + "dependencies": { + "@dashward/widget-sdk": "workspace:*" + }, "devDependencies": { "@girs/gjs": "^4.0.0", "@girs/gtk-3.0": "^3.24.53-4.0.0", diff --git a/container/runtime/runtime.ts b/container/runtime/runtime.ts index 7c98927..1f4e876 100644 --- a/container/runtime/runtime.ts +++ b/container/runtime/runtime.ts @@ -1,6 +1,16 @@ -// Page-side runtime (design §11). P4 establishes the shell-bridge and -// uses it to follow the system color scheme. Real widget loading / edit -// mode / layout persistence arrive in P5+. +import { defineWidget } from '@dashward/widget-sdk'; +import type { Theme, WidgetContext, WidgetDefinition, WidgetHost } from '@dashward/widget-sdk'; + +// ============================================================================= +// Page-side runtime (design §11). P5 mounts the first real widget on the +// grid using the SDK's defineWidget abstraction. Layout persistence, +// edit mode, and dynamic widget loading from disk arrive in later phases. +// ============================================================================= + +// ----------------------------------------------------------------------------- +// 1. Shell bridge — establishes window.__dashShell__ and follows the +// system color-scheme via the DBus round-trip (introduced in P4). +// ----------------------------------------------------------------------------- interface PendingCall { resolve(value: unknown): void; @@ -58,14 +68,13 @@ const dashShell: DashShellInternal = { window.__dashShell__ = dashShell; -// Theme: ask once on load, then follow ThemeChanged from the shell. function applyTheme(theme: unknown): void { - if (typeof theme !== 'string') return; + if (theme !== 'light' && theme !== 'dark') return; document.documentElement.dataset.theme = theme; } dashShell - .call('getTheme') + .call('getTheme') .then(applyTheme) .catch(e => console.warn('[dashward] getTheme failed:', e)); @@ -73,22 +82,155 @@ window.addEventListener('dashward:ThemeChanged', evt => { applyTheme((evt as CustomEvent).detail); }); -// Remove the boot diagnostic now that runtime.js is executing. -document.getElementById('boot')?.remove(); +// ----------------------------------------------------------------------------- +// 2. Widget mounting + context. P5 uses localStorage for config; shell- +// mediated persistence per design §9 lands when the widgets actually +// need cross-session config (clock's defaults are fine). +// ----------------------------------------------------------------------------- -// Placeholder card so the dashboard workspace is visually distinct. -const grid = document.getElementById('grid'); -if (grid) { - const placeholder = document.createElement('section'); - placeholder.className = 'placeholder'; - placeholder.innerHTML = ` -

Dashward

-

P4 — shell bridge online.

-

Theme follows system. Widgets land in P5.

- `; - grid.appendChild(placeholder); +function createContext(def: WidgetDefinition, instanceId: string): WidgetContext { + const storageKey = `dashward:widget:${instanceId}:config`; + + let stored: Partial = {}; + try { + const raw = localStorage.getItem(storageKey); + if (raw) stored = JSON.parse(raw) as Partial; + } catch (e) { + console.warn(`[dashward] failed to read config for ${instanceId}:`, e); + } + let config: T = { ...def.defaultConfig, ...stored }; + + const configHandlers = new Set<(v: T) => void>(); + const unmountHandlers = new Set<() => void>(); + + return { + config: { + get: () => config, + set: async (partial: Partial) => { + config = { ...config, ...partial }; + try { + localStorage.setItem(storageKey, JSON.stringify(config)); + } catch (e) { + console.warn(`[dashward] failed to persist config for ${instanceId}:`, e); + } + for (const cb of configHandlers) cb(config); + }, + onChange: (cb: (v: T) => void) => { + configHandlers.add(cb); + return () => configHandlers.delete(cb); + }, + }, + system: { + getTheme: () => (document.documentElement.dataset.theme as Theme) ?? 'light', + onThemeChange: (cb: (t: Theme) => void) => { + const h = (e: Event) => cb((e as CustomEvent).detail); + window.addEventListener('dashward:ThemeChanged', h); + return () => window.removeEventListener('dashward:ThemeChanged', h); + }, + }, + lifecycle: { + onMount: (cb: () => void) => { + queueMicrotask(cb); + }, + onUnmount: (cb: () => void) => { + unmountHandlers.add(cb); + }, + // P5 doesn't track per-widget visibility; widgets stay alive as long + // as the dashboard page does. Returns a no-op unsubscriber. + onVisibilityChange: () => () => {}, + }, + shell: { + call: (method: string, args?: unknown) => dashShell.call(method, args), + }, + }; } -console.info('[dashward-runtime] P4 bridge mounted'); +function mountWidget( + def: WidgetDefinition, + parent: HTMLElement, + instanceId: string, + size: { w: number; h: number }, +): void { + const element = document.createElement('div'); + element.className = 'widget'; + element.dataset.widget = def.id; + element.style.gridColumn = `span ${size.w}`; + element.style.gridRow = `span ${size.h}`; + parent.appendChild(element); -export {}; + const host: WidgetHost = { element, instanceId }; + const ctx = createContext(def, instanceId); + + try { + def.mount(host, ctx); + } catch (e) { + console.error(`[dashward] widget "${def.id}" mount failed:`, e); + element.classList.add('widget-error'); + element.textContent = `${def.id}: mount failed`; + } +} + +// ----------------------------------------------------------------------------- +// 3. Built-in clock widget (inline in runtime for now; a proper widget +// loader that reads widget.json from disk lands in P6+ along with the +// other builtins). +// ----------------------------------------------------------------------------- + +interface ClockConfig { + hour12: boolean; + showSeconds: boolean; +} + +const clockDef = defineWidget({ + id: 'clock', + defaultConfig: { hour12: false, showSeconds: true }, + + mount(host, ctx) { + const root = host.element.attachShadow({ mode: 'open' }); + root.innerHTML = ` + +
+ `; + const timeEl = root.querySelector('.time') as HTMLDivElement; + + const render = (): void => { + const cfg = ctx.config.get(); + timeEl.textContent = new Date().toLocaleTimeString(undefined, { + hour12: cfg.hour12, + hour: '2-digit', + minute: '2-digit', + second: cfg.showSeconds ? '2-digit' : undefined, + }); + }; + + render(); + const timer = setInterval(render, 1000); + ctx.lifecycle.onUnmount(() => clearInterval(timer)); + ctx.config.onChange(() => render()); + }, +}); + +// ----------------------------------------------------------------------------- +// 4. Boot — remove the diagnostic banner, mount widgets. +// ----------------------------------------------------------------------------- + +document.getElementById('boot')?.remove(); + +const grid = document.getElementById('grid'); +if (grid) { + mountWidget(clockDef, grid, 'clock-1', { w: 4, h: 2 }); +} + +console.info('[dashward-runtime] P5 mounted clock widget'); diff --git a/container/runtime/styles.css b/container/runtime/styles.css index 0b90b56..29b8485 100644 --- a/container/runtime/styles.css +++ b/container/runtime/styles.css @@ -2,6 +2,9 @@ --bg: #f5f5f7; --fg: #1c1c1e; --fg-muted: rgba(28, 28, 30, 0.55); + --card: rgba(255, 255, 255, 0.7); + --card-border: rgba(0, 0, 0, 0.08); + --card-shadow: 0 8px 24px rgba(0, 0, 0, 0.06); --grid-gap: 16px; } @@ -9,9 +12,13 @@ --bg: #0f0f12; --fg: #f0f0f5; --fg-muted: rgba(240, 240, 245, 0.55); + --card: rgba(255, 255, 255, 0.06); + --card-border: rgba(255, 255, 255, 0.08); + --card-shadow: 0 8px 24px rgba(0, 0, 0, 0.4); } -html, body { +html, +body { margin: 0; padding: 0; height: 100%; @@ -24,15 +31,6 @@ body { -webkit-font-smoothing: antialiased; } -#grid { - display: grid; - grid-template-columns: repeat(12, 1fr); - gap: var(--grid-gap); - padding: var(--grid-gap); - min-height: 100%; - box-sizing: border-box; -} - #boot { position: fixed; top: 16px; @@ -46,31 +44,34 @@ body { z-index: 9999; } -.placeholder { - grid-column: 1 / -1; +#grid { + display: grid; + grid-template-columns: repeat(12, 1fr); + grid-auto-rows: minmax(120px, auto); + gap: var(--grid-gap); + padding: var(--grid-gap); + min-height: 100%; + box-sizing: border-box; +} + +.widget { + background: var(--card); + border: 1px solid var(--card-border); + border-radius: 18px; + padding: 20px; + box-shadow: var(--card-shadow); + backdrop-filter: blur(12px); + -webkit-backdrop-filter: blur(12px); + overflow: hidden; + position: relative; + transition: transform 120ms ease, box-shadow 120ms ease; +} + +.widget-error { + border-color: rgba(255, 80, 80, 0.6); + color: rgba(255, 80, 80, 0.95); display: grid; place-items: center; - align-content: center; - gap: 12px; - min-height: 80vh; - text-align: center; -} - -.placeholder h1 { - font-size: clamp(3rem, 8vw, 6rem); - font-weight: 200; - letter-spacing: -0.02em; - margin: 0; -} - -.placeholder .hint { - font-size: 1.25rem; - margin: 0; - opacity: 0.7; -} - -.placeholder .meta { - font-size: 0.9rem; - margin: 0; - color: var(--fg-muted); + font-family: ui-monospace, monospace; + font-size: 12px; } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a14497e..994391b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -16,6 +16,10 @@ importers: version: 5.9.3 container: + dependencies: + '@dashward/widget-sdk': + specifier: workspace:* + version: link:../sdk devDependencies: '@girs/gio-2.0': specifier: ^2.88.0-4.0.0