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; reject(err: Error): void; } interface DashShellInternal { call(method: string, args?: unknown): Promise; _resolve(id: number, value: unknown): void; _reject(id: number, error: string): void; _onSignal(name: string, payload: unknown): void; } declare global { interface Window { __dashShell__?: DashShellInternal; webkit?: { messageHandlers: { shellCall: { postMessage(data: string): void } } }; } } const pending = new Map(); let nextId = 1; const dashShell: DashShellInternal = { call(method: string, args?: unknown): Promise { return new Promise((resolve, reject) => { const id = nextId++; pending.set(id, { resolve: resolve as (v: unknown) => void, reject }); try { window.webkit!.messageHandlers.shellCall.postMessage( JSON.stringify({ id, method, args }), ); } catch (e) { pending.delete(id); reject(e instanceof Error ? e : new Error(String(e))); } }); }, _resolve(id, value) { const p = pending.get(id); if (!p) return; pending.delete(id); p.resolve(value); }, _reject(id, err) { const p = pending.get(id); if (!p) return; pending.delete(id); p.reject(new Error(err)); }, _onSignal(name, payload) { window.dispatchEvent(new CustomEvent(`dashward:${name}`, { detail: payload })); }, }; window.__dashShell__ = dashShell; function applyTheme(theme: unknown): void { if (theme !== 'light' && theme !== 'dark') return; document.documentElement.dataset.theme = theme; } dashShell .call('getTheme') .then(applyTheme) .catch(e => console.warn('[dashward] getTheme failed:', e)); window.addEventListener('dashward:ThemeChanged', evt => { applyTheme((evt as CustomEvent).detail); }); // ----------------------------------------------------------------------------- // 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). // ----------------------------------------------------------------------------- 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), }, }; } 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); 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');