Files
Dashward/container/runtime/runtime.ts
hzhang e8670ac590 feat: P5 — first widget on screen (clock via SDK)
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<ClockConfig>; 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) <noreply@anthropic.com>
2026-05-23 01:00:49 +01:00

237 lines
7.5 KiB
TypeScript

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<T = unknown>(method: string, args?: unknown): Promise<T>;
_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<number, PendingCall>();
let nextId = 1;
const dashShell: DashShellInternal = {
call<T>(method: string, args?: unknown): Promise<T> {
return new Promise<T>((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<Theme>('getTheme')
.then(applyTheme)
.catch(e => console.warn('[dashward] getTheme failed:', e));
window.addEventListener('dashward:ThemeChanged', evt => {
applyTheme((evt as CustomEvent<unknown>).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<T>(def: WidgetDefinition<T>, instanceId: string): WidgetContext<T> {
const storageKey = `dashward:widget:${instanceId}:config`;
let stored: Partial<T> = {};
try {
const raw = localStorage.getItem(storageKey);
if (raw) stored = JSON.parse(raw) as Partial<T>;
} 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<T>) => {
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<Theme>).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: <R>(method: string, args?: unknown) => dashShell.call<R>(method, args),
},
};
}
function mountWidget<T>(
def: WidgetDefinition<T>,
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<ClockConfig>({
id: 'clock',
defaultConfig: { hour12: false, showSeconds: true },
mount(host, ctx) {
const root = host.element.attachShadow({ mode: 'open' });
root.innerHTML = `
<style>
:host {
display: grid;
place-items: center;
width: 100%;
height: 100%;
}
.time {
font: 200 clamp(2rem, 7vw, 4.5rem) ui-sans-serif, system-ui, sans-serif;
font-variant-numeric: tabular-nums;
letter-spacing: -0.02em;
}
</style>
<div class="time"></div>
`;
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');