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

684 lines
29 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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
```js
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:
```js
ucm.register_script_message_handler('shellCall'); // page → GJS
ucm.register_script_message_handler('log'); // widget logs to GJS stderr
```
Page-side bridge:
```js
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:
```ts
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
```ts
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:
```json
{
"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`:
```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:
```bash
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.