Files
Dashward/UBUNTU-DASHBOARD-SPACE.md
hzhang 3bf3aa1989 chore: P0 skeleton
Bootstrap the Dashward repo per arch/UBUNTU-DASHBOARD-SPACE.md:

- pnpm-workspaces monorepo (sdk, extension, container, widgets-builtin/*)
- GNOME extension stub (metadata.json, src/*.ts placeholders for warden,
  guard, supervisor, entry UX, DBus service)
- WebKit container stub (GJS main + page-side runtime + dashboard.html)
- TypeScript widget SDK (defineWidget + types)
- Builtin clock widget as the first SDK consumer example
- DBus interface XML (proto/shell.iface.xml) and shared types
- esbuild configs for extension and container; tsc for SDK
- Design doc copied in at repo root for discoverability

No functional logic yet -- all components are placeholders that compose
in extension.ts so the build chain can be exercised. P1 (workspace
warden) starts next.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 23:00:02 +01:00

29 KiB
Raw Permalink Blame History

Ubuntu Dashboard Space — Design

Status: design. Goal: a macOS-Dashboard-style dedicated rightmost workspace on GNOME/Ubuntu that hosts user-defined widgets. Widgets are written against a TypeScript SDK and rendered as web components inside an embedded WebKit view; the GNOME Shell extension is the workspace warden and the IPC bridge, nothing heavier.

Codename: Dashward (decided 2026-05-22; see §19).


1. Goals & non-goals

Goals

  • A workspace that is created by the extension at enable time, lives at the rightmost position, and is destroyed at disable time. It is not one of the user's existing workspaces, even temporarily.
  • Regular windows cannot enter it — neither by overview drag, by super+shift+arrow, by wmctrl/xdotool, nor by being newly mapped there. Attempts get bounced to the prior workspace immediately.
  • Widgets are web components in TS. A buggy widget cannot take down GNOME Shell.
  • Hot-reload during widget development.
  • Light/dark theme follows the system.
  • Zero hand-edits to user dotfiles outside ~/.local/share/dashward/ and the extension install dir.

Non-goals (v1)

  • Cross-distro support beyond Ubuntu 25.04 / GNOME 48. KDE, XFCE, Cinnamon are out of scope until v1 lands and stabilizes.
  • A widget marketplace, signing, or sandboxing beyond what WebKit's process model already provides. Widgets are trusted code at v1.
  • macOS-style "ripple in" widget add animation. Edit mode is utilitarian.
  • Multi-monitor span: dashboard appears on the primary monitor only; other monitors get the regular last-workspace view.
  • Replacing the regular workspace switcher UX. The dashboard is reached by a dedicated gesture/shortcut, not by being workspace N+1 in the existing switcher.

2. Hard constraints (carried from chat)

C# Constraint Resolution
C1 Dashboard does not reuse any existing workspace §5: append a new workspace at enable, mark it, remove at disable
C2 Dashboard is always rightmost §5: position invariant maintained on every workspace mutation
C3 Regular windows cannot be dragged into dashboard §6: layered defense on window-added, overview drop target, gesture handlers
C4 Widgets in TypeScript §9: SDK is a TS package; widgets are TS modules bundled to ESM
C5 Avoid the GridSpaces lockout failure mode §17: VM-only iteration loop; extension never touches gnome-extensions enable on host until VM-green

3. Reality check (probed 2026-05-22 on ubuntu2504-test)

Component Version Note
Ubuntu 25.04 (Plucky)
GNOME Shell 48.0-1ubuntu1 ESM extension format
Mutter 48.1 (libmutter-16-0) API gen 16, Meta.WorkspaceManager stable
gjs 1.82.1 SpiderMonkey 128 era — ESM, top-level await OK
WebKit2GTK 4.1 (GTK3) 2.50.4 + gir1.2-webkit2-4.1 pre-installed Chosen for the container — zero new GIR deps
WebKitGTK 6.0 (GTK4) 2.50.4 lib present, GIR not installed Migration target post-v1
Session Wayland (gdm Ubuntu on Wayland default)
Pre-existing ext ding, tiling-assistant, ubuntu-appindicators, ubuntu-dock tiling-assistant is the relevant precedent for window manipulation

Implication: we can build v1 with only what's already on disk — no new apt packages required. (Build chain is in dev only; not shipped onto the VM at runtime.)


4. Architecture

Three processes, two IPC channels:

┌─────────────────────────────────────────────────────────────────────────┐
│                     gnome-shell  (GJS, in-process)                       │
│  ┌──────────────────────────────────────────────────────────────────┐   │
│  │  Dashward extension                                              │   │
│  │   • WorkspaceWarden  (create/destroy + position invariant)        │   │
│  │   • WindowGuard      (block window-added on dashboard ws)         │   │
│  │   • EntryUX          (gesture, shortcut, panel indicator)         │   │
│  │   • ContainerSupervisor (spawn/respawn web container, pin window) │   │
│  │   • DBusService      "top.hangmanlab.Dashward.Shell"             │   │
│  └─────────┬────────────────────────────────────────────────────────┘   │
└────────────┼────────────────────────────────────────────────────────────┘
             │ DBus session bus
             ▼
┌─────────────────────────────────────────────────────────────────────────┐
│        dashward-container  (GJS + WebKit2 4.1, separate process)       │
│   • One GtkWindow, fullscreen, undecorated, app_id = container          │
│   • WebKitWebView loads file://…/dashboard.html                          │
│   • DBus client: pushes shell events into the page via                  │
│     UserContentManager.evaluate_javascript()                            │
│   • UserContentManager script-message handlers receive page→shell calls │
└─────────────────────────────────────────────────────────────────────────┘
             │ WebKit user-content-manager bridge
             ▼
┌─────────────────────────────────────────────────────────────────────────┐
│        dashboard.html  (WebKit content process, sandboxed by WebKit)     │
│   • Runtime: dashboard-runtime.js (host shell, layout grid, edit mode)   │
│   • SDK: @dashward/widget-sdk (TS, bundled in)                        │
│   • Widgets: each loaded into its own <iframe> for crash isolation      │
└─────────────────────────────────────────────────────────────────────────┘

Why the container is its own process: GJS code crashing inside gnome-shell kills the session. Container is forked under Gio.Subprocess; when it crashes, supervisor sees the exit, hides the dashboard slot's content, and respawns with backoff. Shell stays up.

Why widgets are in <iframe>s and not just <div>s: a widget that loops, throws unhandled, or leaks memory only affects its iframe — the host page and other widgets keep running. Iframes also give us a clean origin boundary for widget asset paths.


5. Workspace lifecycle

Enable

  1. Snapshot user state:
    • org.gnome.mutter dynamic-workspaces → save under ~/.local/state/dashward/saved-settings.json.
    • org.gnome.desktop.wm.preferences num-workspaces → save.
    • Current workspace count N (from Meta.WorkspaceManager).
  2. Set dynamic-workspaces = false (we need a fixed slot at the end that doesn't get GC'd when empty).
  3. Set num-workspaces = N + 1.
  4. Take the new last workspace (index N) as the dashboard slot. Tag it: stash its Meta.Workspace reference under wm._dashSpaceWs and record the workspace's index in WorkspaceWarden.index.
  5. Spawn the container; pin its window to wm._dashSpaceWs (see §7).
  6. Install signal handlers (§6).

Steady state — invariants

After every workspace mutation (workspace appended/removed/reordered) the warden re-checks:

  • WorkspaceWarden.ws still exists in WorkspaceManager.get_workspaces(). If not (something forcibly removed it), recreate at the end. Log.
  • Its index equals WorkspaceManager.get_n_workspaces() - 1. If not, move it to the end via reorder_workspace(ws, N-1).

Disable

  1. Kill container.
  2. Remove the dashboard workspace via WorkspaceManager.remove_workspace(ws, current_time).
  3. Restore num-workspaces and dynamic-workspaces to snapshotted values.
  4. Disconnect all signals; clear wm._dashSpaceWs.

Edge cases

  • User changes num-workspaces externally while extension is enabled: warden treats this as the new "N", appends dashboard at N (effectively num-workspaces = newN + 1). Saved setting is updated.
  • User logs out / shell restart (Wayland: only logout; X11: alt-F2 r): disable() is called by Shell. If we somehow miss it (crash), a best-effort cleanup runs on next enable(): detect workspace tagged with our marker (Meta.Workspace.get_work_area_all_monitors is not enough; we tag via a sticky window — see §7) and remove.
  • Other extensions also manage workspaces (e.g. tiling-assistant adjusts but doesn't add/remove; should be compatible. WorkspaceMatrix would conflict — flag in README as incompatible).

6. Window-defense rules

Layer 1: window-added bounce-back

ws.connectObject('window-added', (_, window) => {
  if (window === containerWindow) return;          // whitelist
  if (window.is_override_redirect()) return;        // tooltip, menu, etc.
  const fallback = priorWorkspaceFor(window);
  window.change_workspace(fallback);
}, this);

priorWorkspaceFor heuristic: the window's get_user_time() workspace if known, else workspace N-1 (dashboard index minus 1), else workspace 0.

This single hook catches:

  • new windows mapped to dashboard (window-created race: even if the window initially picks dashboard via _NET_WM_DESKTOP, window-added fires immediately when it joins);
  • programmatic moves via wmctrl -r ... -t N, xdotool windowmove, Meta.Window.change_workspace;
  • keyboard super+shift+→ on the rightmost workspace.

Layer 2: overview drop refusal

In the activities overview, workspace thumbnails are drop targets via WorkspaceThumbnail + _acceptDrop. We monkey-patch the dashboard thumbnail's acceptDrop to return false (and the visual to show "no drop" cursor). Cleaner than letting Layer 1 bounce it back after the visual drop completed.

Layer 3: hide from regular switcher

The dashboard workspace renders specially in the overview: instead of the normal thumbnail, we draw a placeholder card with the Dashward icon and "Dashboard" label. This is purely cosmetic (Layers 1+2 already enforce); the goal is user clarity — they don't try to drag, because the slot visibly isn't a regular workspace.

Implementation: override WorkspaceThumbnail for the specific workspace via WorkspacesView._workspaces[N-1] patching, or simpler: hide it from the thumbnail strip entirely. The dashboard is accessed via gesture / shortcut (§14), not by clicking its thumbnail. Default: hide from strip.

Layer 4: workspace switcher (Super+Page_Up/Down)

By default this cycles through 0..N. We want Super+Page_Down on the last real workspace to not land on dashboard. Implementation: hook Meta.KeyBindingAction.WORKSPACE_DOWN and clamp target to N-1. (User still gets to dashboard via dedicated gesture/shortcut.)


7. Web container process

Window properties

  • Wayland app_id: top.hangmanlab.dashward.container (set via GLib.set_prgname + Gtk.Window.set_default_icon_name — for Wayland app_id detection on the Shell side, we identify by Meta.Window.get_gtk_application_id()).
  • Title: Dashward.
  • Decorations: none (set_decorated(false)).
  • Fullscreen on the primary monitor.
  • Type hint: NORMAL (not DOCK, since dock windows skip workspaces).
  • Stickiness: NOT sticky; it must belong to the dashboard workspace only.

Lifetime managed by ContainerSupervisor

spawn() → wait for window-created signal → match by app_id →
move to dashboardWs (Layer 1 whitelist needed!) → record windowId →
WebKit ready → first eval: bootstrap dashboard-runtime.js

Crash recovery: restart with exponential backoff (1s, 2s, 5s, 10s, then give up and surface error in panel indicator). The container's stderr streams to ~/.local/state/dashward/container.log.

Visibility model

The container window always exists on the dashboard workspace; user "opens dashboard" = switching to that workspace; "closes" = switching away. No need to map/unmap the window per visit — Mutter culls it from rendering when its workspace isn't active. This is the same model the overview uses for inactive workspace windows.

Exception: on disable / shell-shutdown, container is gracefully asked to quit via DBus, then SIGTERM after 2s.


8. IPC layers

8.1 Shell ↔ Container (DBus)

Bus name: top.hangmanlab.Dashward.Shell (owned by the extension). Object path: /top/hangmanlab/Dashward. Interface methods:

Method Direction Purpose
Ready() container → shell container finished bootstrap; safe to enter edit mode
RequestEnterDashboard() container → shell "switch to dashboard workspace" (used by <a href> clicks etc.)
RequestExitDashboard() container → shell "switch to previous workspace"
GetSystemInfo() → a{sv} container → shell brightness, battery, network, theme
WidgetIPC(widgetId, method, args) container → shell namespaced widget-to-shell calls; routed by SDK

Signals (shell → container):

Signal Payload Purpose
ThemeChanged s ("light"/"dark") system theme toggle
EditModeRequested b (enter/exit) extension's panel button or keybind
SystemInfoChanged a{sv} (partial) battery/network/etc updates

8.2 Container GJS ↔ WebKit page (UserContentManager)

GJS-side container exposes script-message handlers:

ucm.register_script_message_handler('shellCall');   // page → GJS
ucm.register_script_message_handler('log');         // widget logs to GJS stderr

Page-side bridge:

window.__dashShell__ = {
  call(method, args) {
    return new Promise((resolve, reject) => {
      const id = nextId();
      pending.set(id, { resolve, reject });
      webkit.messageHandlers.shellCall.postMessage({ id, method, args });
    });
  }
};

Reverse (GJS → page): webView.evaluate_javascript(...).

8.3 Page ↔ widget iframe

window.postMessage with the SDK's wrapper. SDK injects into each iframe a preload script that exposes:

const dashboard = {
  config: { get(): T, set(T): Promise<void>, onChange(cb) },
  system: { onThemeChange(cb), getTheme(): 'light'|'dark', /* … */ },
  lifecycle: { onMount(cb), onUnmount(cb), onVisibilityChange(cb) },
  shell: { call(method, args): Promise<any> },  // gated by manifest permissions
};

9. Widget SDK

Package: @dashward/widget-sdk (TypeScript, ESM, distributed as both source-for-bundle and as a CDN-style local file the runtime can load).

Authoring API

import { defineWidget } from '@dashward/widget-sdk';

interface ClockConfig { hour12: boolean; showSeconds: boolean }

export default defineWidget<ClockConfig>({
  id: 'clock',
  defaultConfig: { hour12: false, showSeconds: true },

  mount(host, { config, system, lifecycle }) {
    const root = host.attachShadow({ mode: 'open' });
    root.innerHTML = `<div class="clock"></div>`;
    const tick = () => { /* render */ };

    const timer = setInterval(tick, 1000);
    lifecycle.onUnmount(() => clearInterval(timer));

    config.onChange(() => tick());
    system.onThemeChange(t => root.host.dataset.theme = t);
  },

  configUI(host, { config }) {
    // optional: render a settings panel for edit mode
  },
});

Surfaces:

API Capability
config.get/set/onChange persistent JSON config; auto-saved to ~/.local/share/dashward/widgets/<id>/config.json
system.getTheme/onThemeChange follow Shell theme
system.getBattery/getNetwork/getBrightness shell DBus relay (requires manifest permission)
lifecycle.onMount/onUnmount/onVisibilityChange called by host when widget appears/disappears
shell.call(method, args) escape hatch to namespaced shell DBus calls (requires permission)

What's not exposed: file system, network sockets, arbitrary Gio/Meta access. Widgets that need that file an upstream issue.


10. Widget manifest

widget.json at the widget root:

{
  "id": "clock",
  "name": "Clock",
  "version": "0.1.0",
  "entry": "dist/index.js",
  "icon": "icon.svg",
  "size": { "w": 2, "h": 2, "minW": 1, "minH": 1, "maxW": 4, "maxH": 4 },
  "permissions": ["timer"],
  "configSchema": { "hour12": "boolean", "showSeconds": "boolean" }
}

Permissions vocabulary v1: timer, battery, network, brightness, notifications. Permission grant is currently implicit (manifest declares it, runtime allows it); a user-visible grant UI is v2.

Widget directory layout:

widgets/clock/
├── widget.json
├── src/
│   └── index.ts
├── dist/
│   └── index.js          # esbuild-bundled, ESM, references SDK as bare import
└── icon.svg

Install location at runtime:

~/.local/share/dashward/widgets/
├── clock/                  # user's widgets (drop a dir here, runtime sees it)
├── weather/
└── …

Plus a built-in pack shipped with the extension at extension/widgets-builtin/ (clock, calendar, system-stats, notes).


11. Layout & edit mode

Grid

  • 12 columns wide; rows grow vertically as needed.
  • Cell aspect ratio: 1:1 at the canonical size of floor(viewportWidth / 12) px. So a widget with size: { w: 3, h: 2 } occupies 3 cols × 2 rows = 3w × 2h grid cells, naturally responsive.
  • Widgets snap to grid; no free positioning in v1.

Storage

User layout in ~/.local/share/dashward/layout.json:

{
  "widgets": [
    { "instanceId": "clock-1", "widgetId": "clock", "x": 0, "y": 0, "w": 2, "h": 2 },
    { "instanceId": "weather-1", "widgetId": "weather", "x": 2, "y": 0, "w": 3, "h": 2 }
  ]
}

Multiple instances of the same widget are allowed (hence instanceId vs widgetId); each has its own config file scoped by instanceId.

Edit mode

Triggered by:

  • Top-edge floating button (visible only when dashboard is active).
  • Keyboard e while on dashboard.
  • Long-press on any widget.

In edit mode: widgets show resize handles + delete X; a "+" tray slides in from the right with available widgets; drag to add. Exit on Esc or clicking outside.

Out of edit mode: widgets are passive; no drag/resize.


12. Theming

  • Container reads org.gnome.desktop.interface color-scheme at startup.
  • Shell subscribes to GSettings changes; pushes ThemeChanged DBus signal to container; container does document.documentElement.dataset. theme = 'dark' | 'light'.
  • SDK exposes system.onThemeChange(cb); widgets style via CSS [data-theme="dark"] attribute selectors (no JS theming, CSS does it).
  • Background is a configurable color + optional wallpaper image (defaults to a desaturated copy of the user's desktop wallpaper).

13. Entry / exit UX

Three ways in:

Path Default binding Implementation
Gesture 4-finger swipe right past last workspace hook Shell.WorkspacesView._swipeTracker; on end with target > N-1, jump to dashboard
Keyboard Super+Grave Meta.KeyBinding via Shell.ActionMode.NORMAL
Panel button left-click Dashward indicator PanelMenu.Button with custom icon

All three call the same EntryUX.openDashboard() which animates the workspace switch via existing Shell APIs.

Exit: same gesture/shortcut/button toggles. Plus Esc while on dashboard (but only if no widget has focus capturing it).


14. Persistence & permissions

Filesystem layout (all under XDG_* standards):

~/.local/share/dashward/
├── layout.json                          # grid layout
├── widgets/<instanceId>/config.json     # per-instance config
└── widgets-installed/<widgetId>/        # user-installed widget bundles
~/.local/state/dashward/
├── saved-settings.json                  # gsettings snapshot for restore
├── container.log
└── extension.log
~/.cache/dashward/                     # WebKit cache, regenerable

Nothing else outside the extension's install directory is touched.

Permissions today: declared in manifest, allowed by runtime. v2: a first-run prompt per widget; revocable from edit mode.


15. Directory layout (the repo)

Dashward/
├── extension/                            # GNOME Shell extension
│   ├── metadata.json                     # GNOME-mandated, shell-version: ["48"]
│   ├── src/
│   │   ├── extension.ts                  # entry, exports default class
│   │   ├── workspace-warden.ts
│   │   ├── window-guard.ts
│   │   ├── container-supervisor.ts
│   │   ├── entry-ux.ts
│   │   ├── dbus-service.ts
│   │   └── prefs.ts                      # GNOME Extension prefs window
│   ├── dist/                             # esbuild output, single extension.js
│   ├── widgets-builtin/                  # ships with extension
│   │   ├── clock/
│   │   ├── calendar/
│   │   ├── system-stats/
│   │   └── notes/
│   └── tsconfig.json
│
├── container/                            # WebKit kiosk process
│   ├── src/
│   │   ├── main.ts                       # GJS bootstrap
│   │   ├── window.ts                     # GtkWindow + WebKitWebView
│   │   ├── dbus-client.ts
│   │   └── bridge.ts                     # UserContentManager wiring
│   ├── runtime/                          # injected into the page
│   │   ├── runtime.ts                    # grid host, edit mode, widget loader
│   │   ├── dashboard.html
│   │   └── styles.css
│   └── dist/
│
├── sdk/                                  # @dashward/widget-sdk
│   ├── src/
│   │   ├── index.ts
│   │   ├── define-widget.ts
│   │   ├── config.ts
│   │   ├── system.ts
│   │   └── types.ts
│   ├── package.json
│   └── tsconfig.json
│
├── widgets/                              # example/3rd-party widgets dev tree
│   └── …                                 # each is a self-contained TS project
│
├── proto/                                # DBus xml + shared TS types
│   ├── shell.iface.xml
│   └── types.ts
│
├── scripts/
│   ├── dev-vm.sh                         # rsync to VM + reload extension
│   ├── package.sh                        # produces .shell-extension.zip
│   └── lint.sh
│
└── README.md

16. Build chain

Single root package.json + pnpm workspaces (sdk, extension, container, widgets/*).

Subproject Bundler Output
sdk tsc ESM, types, published as workspace dep
extension esbuild single dist/extension.js (GNOME loads one file)
container esbuild single dist/main.js (GJS-friendly ESM)
each widget esbuild dist/index.js, SDK marked external (loaded by runtime)

GJS specifics (carried from your project_fabric_esm habit):

  • "module": "NodeNext" in tsconfig; explicit .js import suffixes; default-import any CJS dep.
  • Gio / Meta etc. imported via gi:// URLs.
  • Type stubs: @girs/gnome-shell, @girs/meta-16, @girs/gtk-3.0, @girs/webkit2-4.1.

Dev cycle:

pnpm dev                # watches src/, rebuilds extension+container+widgets
pnpm vm:sync            # rsync dist → VM, restart shell (or just kill container)

Inside the VM, a small helper script (~/dashward-dev/reload.sh):

  • Wayland: re-enable extension via gnome-extensions disable && enable (a full Shell restart on Wayland requires logout — see §17).
  • Container-only changes: pkill dashward-container; supervisor respawns.

17. Implementation phases

Phase Deliverable Acceptance
P0 Repo skeleton, build chain, hello-world extension that logs on enable extension shows in gnome-extensions list on VM
P1 WorkspaceWarden in isolation: appends/removes ws, position invariant manual: enable → see N+1 workspaces; disable → back to N; mutations preserved
P2 WindowGuard manual: try wmctrl -r foo -t <dashboard>, super+shift+→, overview drag — all bounce
P3 Container spawn + WebKit window + pin to dashboard container window appears on dashboard ws only
P4 DBus + WebKit bridge round-trip dashboard.system.getTheme() returns shell-side value
P5 Runtime: grid host, layout.json load/save, one builtin widget (clock) clock renders, persists across enable cycles
P6 SDK + 3 more builtins (calendar, system-stats, notes) each widget works in isolation; iframe crash containment verified
P7 Edit mode (add/remove/resize/rearrange) layout edits persist; instances scoped configs
P8 Entry UX (gesture, shortcut, panel button) all three paths open dashboard
P9 Polish: theming, animations, error surfaces passes a manual checklist
P10 Stretch: WebKit 6.0 migration, multi-monitor, widget marketplace stub post-v1

Critical: P1, P2, P3 all run only on the VM (project_gridspaces_test_vm). The host's GNOME Shell never sees the extension until P9 and only via gnome-extensions install from a packaged zip — never via a ~/.local/share/gnome-shell/extensions/ symlink, which would survive logout and lock us out as in project_gridspaces_lockout.


18. Risks & open questions

Risks

R# Risk Mitigation
R1 Wayland shell restart requires logout — slow iteration Container hot-reload covers 80%; for extension changes, accept the logout cost; consider a nested Wayland session (mutter --nested) for ultra-fast iteration in P5+
R2 dynamic-workspaces = false is intrusive — user may rely on dynamic behavior Restore on disable; document in README; consider an "advanced mode" later that keeps dynamic mode and races the GC, but not v1
R3 Overview shows the dashboard slot oddly when hidden from strip Spend a P9 day on the cosmetic; if upstream WorkspacesView proves fragile across GNOME minor versions, fall back to "show the slot with a placeholder thumbnail"
R4 WebKitGTK 4.1 (GTK3) is older — long-term deprecation risk Migration path: swap container to GTK4 + WebKitGTK 6.0 (install gir1.2-webkit-6.0). API surface is similar; isolated to container/, won't touch SDK or widgets
R5 Widget IPC permissions are honor-system in v1 Acceptable for personal-use v1; v2 adds first-run prompt
R6 tiling-assistant or other extensions interact with workspace count Document incompatibility list in README; test against default Ubuntu 25.04 extension set on VM during P1
R7 A misbehaving extension load could crash shell on enable Wrap entire enable() in try/catch; on first-throw, write error to ~/.local/state/dashward/extension.log and bail rather than partial-init

Open questions

Q# Question Status
Q1 Project name Decided 2026-05-22: Dashward
Q2 Where does the repo live? Decided 2026-05-22: local ~/dev/Dashward/; remote = https://git.hangman-lab.top/hzhang/Dashward.git (Gitea on server.t0)
Q3 Should widgets be able to span the entire dashboard (full-bleed)? Open — no in v1; revisit
Q4 Multi-monitor: dashboard on primary only, or per-monitor? Open — primary only in v1
Q5 Should Super+Grave clash check happen at install or runtime? Open — runtime check on enable(); if taken, fall back to no shortcut and warn
Q6 Widget config schema validation (Zod? JSON Schema? none?) Open — none in v1; SDK trusts the widget's TS types
Q7 Do we ship a widget devtools page (live reload of widget under dev)? Open — P10 stretch

19. Naming, hosting & next actions

Codename: Dashward (decided 2026-05-22).

Local working tree: ~/dev/Dashward/. Not under HangmanLab — Dashward is a personal tool, orthogonal to the lab estate.

Remote: Gitea on server.t0 at https://git.hangman-lab.top/hzhang/Dashward.git. Created when the P0 skeleton is ready to push. Per project-server-topology t0 hosts Git + auth; per project-local-sim-env the local-sim hijack of *.hangman-lab.top is opt-in (set up by setup-sim.sh) — when the hijack is off (default state), git.hangman-lab.top resolves to prod, which is what we want for pushing Dashward.

Next concrete step:

  1. Q1, Q2 decided.
  2. Create the repo skeleton on disk (§15) — empty files, working pnpm install, no logic.
  3. Create the Gitea repo (via Gitea API or web UI) and git push -u origin main.
  4. P0 hello-world extension on the VM, prove the dev loop end-to-end.
  5. Begin P1.

See project-gridspaces-lockout and project-gridspaces-test-vm for the testing discipline this project inherits.