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>
This commit is contained in:
@@ -8,6 +8,9 @@
|
|||||||
"build": "node esbuild.config.js",
|
"build": "node esbuild.config.js",
|
||||||
"dev": "node esbuild.config.js --watch"
|
"dev": "node esbuild.config.js --watch"
|
||||||
},
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@dashward/widget-sdk": "workspace:*"
|
||||||
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@girs/gjs": "^4.0.0",
|
"@girs/gjs": "^4.0.0",
|
||||||
"@girs/gtk-3.0": "^3.24.53-4.0.0",
|
"@girs/gtk-3.0": "^3.24.53-4.0.0",
|
||||||
|
|||||||
@@ -1,6 +1,16 @@
|
|||||||
// Page-side runtime (design §11). P4 establishes the shell-bridge and
|
import { defineWidget } from '@dashward/widget-sdk';
|
||||||
// uses it to follow the system color scheme. Real widget loading / edit
|
import type { Theme, WidgetContext, WidgetDefinition, WidgetHost } from '@dashward/widget-sdk';
|
||||||
// mode / layout persistence arrive in P5+.
|
|
||||||
|
// =============================================================================
|
||||||
|
// 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 {
|
interface PendingCall {
|
||||||
resolve(value: unknown): void;
|
resolve(value: unknown): void;
|
||||||
@@ -58,14 +68,13 @@ const dashShell: DashShellInternal = {
|
|||||||
|
|
||||||
window.__dashShell__ = dashShell;
|
window.__dashShell__ = dashShell;
|
||||||
|
|
||||||
// Theme: ask once on load, then follow ThemeChanged from the shell.
|
|
||||||
function applyTheme(theme: unknown): void {
|
function applyTheme(theme: unknown): void {
|
||||||
if (typeof theme !== 'string') return;
|
if (theme !== 'light' && theme !== 'dark') return;
|
||||||
document.documentElement.dataset.theme = theme;
|
document.documentElement.dataset.theme = theme;
|
||||||
}
|
}
|
||||||
|
|
||||||
dashShell
|
dashShell
|
||||||
.call<string>('getTheme')
|
.call<Theme>('getTheme')
|
||||||
.then(applyTheme)
|
.then(applyTheme)
|
||||||
.catch(e => console.warn('[dashward] getTheme failed:', e));
|
.catch(e => console.warn('[dashward] getTheme failed:', e));
|
||||||
|
|
||||||
@@ -73,22 +82,155 @@ window.addEventListener('dashward:ThemeChanged', evt => {
|
|||||||
applyTheme((evt as CustomEvent<unknown>).detail);
|
applyTheme((evt as CustomEvent<unknown>).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.
|
function createContext<T>(def: WidgetDefinition<T>, instanceId: string): WidgetContext<T> {
|
||||||
const grid = document.getElementById('grid');
|
const storageKey = `dashward:widget:${instanceId}:config`;
|
||||||
if (grid) {
|
|
||||||
const placeholder = document.createElement('section');
|
let stored: Partial<T> = {};
|
||||||
placeholder.className = 'placeholder';
|
try {
|
||||||
placeholder.innerHTML = `
|
const raw = localStorage.getItem(storageKey);
|
||||||
<h1>Dashward</h1>
|
if (raw) stored = JSON.parse(raw) as Partial<T>;
|
||||||
<p class="hint">P4 — shell bridge online.</p>
|
} catch (e) {
|
||||||
<p class="meta">Theme follows system. Widgets land in P5.</p>
|
console.warn(`[dashward] failed to read config for ${instanceId}:`, e);
|
||||||
`;
|
}
|
||||||
grid.appendChild(placeholder);
|
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),
|
||||||
|
},
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
console.info('[dashward-runtime] P4 bridge mounted');
|
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);
|
||||||
|
|
||||||
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<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');
|
||||||
|
|||||||
@@ -2,6 +2,9 @@
|
|||||||
--bg: #f5f5f7;
|
--bg: #f5f5f7;
|
||||||
--fg: #1c1c1e;
|
--fg: #1c1c1e;
|
||||||
--fg-muted: rgba(28, 28, 30, 0.55);
|
--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;
|
--grid-gap: 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -9,9 +12,13 @@
|
|||||||
--bg: #0f0f12;
|
--bg: #0f0f12;
|
||||||
--fg: #f0f0f5;
|
--fg: #f0f0f5;
|
||||||
--fg-muted: rgba(240, 240, 245, 0.55);
|
--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;
|
margin: 0;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
@@ -24,15 +31,6 @@ body {
|
|||||||
-webkit-font-smoothing: antialiased;
|
-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 {
|
#boot {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
top: 16px;
|
top: 16px;
|
||||||
@@ -46,31 +44,34 @@ body {
|
|||||||
z-index: 9999;
|
z-index: 9999;
|
||||||
}
|
}
|
||||||
|
|
||||||
.placeholder {
|
#grid {
|
||||||
grid-column: 1 / -1;
|
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;
|
display: grid;
|
||||||
place-items: center;
|
place-items: center;
|
||||||
align-content: center;
|
font-family: ui-monospace, monospace;
|
||||||
gap: 12px;
|
font-size: 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);
|
|
||||||
}
|
}
|
||||||
|
|||||||
4
pnpm-lock.yaml
generated
4
pnpm-lock.yaml
generated
@@ -16,6 +16,10 @@ importers:
|
|||||||
version: 5.9.3
|
version: 5.9.3
|
||||||
|
|
||||||
container:
|
container:
|
||||||
|
dependencies:
|
||||||
|
'@dashward/widget-sdk':
|
||||||
|
specifier: workspace:*
|
||||||
|
version: link:../sdk
|
||||||
devDependencies:
|
devDependencies:
|
||||||
'@girs/gio-2.0':
|
'@girs/gio-2.0':
|
||||||
specifier: ^2.88.0-4.0.0
|
specifier: ^2.88.0-4.0.0
|
||||||
|
|||||||
Reference in New Issue
Block a user