#16 CSS Upgrade

Merged
Tilo15 merged 21 commits from clanker/goal/ui-ux into clanker/web-stack 1 day ago
69 changed files with 6166 additions and 3809 deletions
  1. 336 0
      docs/spry-conversion.md
  2. 8 0
      docs/ux-notes.md
  3. 4 0
      meson.build
  4. 0 177
      mocks/README.md
  5. 0 311
      mocks/dashboard-control.html
  6. 0 282
      mocks/dashboard-current.html
  7. 0 321
      mocks/dashboard-primer.html
  8. 0 304
      mocks/dashboard-swiss.html
  9. 29 0
      src/Data/Entities.vala
  10. 78 0
      src/Data/Migrations/M0011_UserPreferences.vala
  11. 14 0
      src/Json.vala
  12. 24 0
      src/Services/RuleService.vala
  13. 86 0
      src/Services/UserPreferenceService.vala
  14. 47 1
      src/actions/LoginAction.vala
  15. 125 1
      src/actions/ProfileActions.vala
  16. 7 1
      src/actions/TaskActions.vala
  17. 8 0
      src/entrypoints/ChatDetailEntrypoint.vala
  18. 15 0
      src/entrypoints/GoalDetailEntrypoint.vala
  19. 82 0
      src/entrypoints/HomeEntrypoint.vala
  20. 94 10
      src/entrypoints/ProfileEntrypoint.vala
  21. 71 0
      src/entrypoints/ProjectsEntrypoint.vala
  22. 65 2
      src/entrypoints/RulesEntrypoint.vala
  23. 65 0
      src/entrypoints/SaddlesEntrypoint.vala
  24. 27 2
      src/entrypoints/TaskDetailEntrypoint.vala
  25. 18 2
      src/entrypoints/TaskLogEntrypoint.vala
  26. 4 0
      src/main.vala
  27. 390 742
      src/pages/app.css
  28. 444 2
      src/pages/app.js
  29. 1 1
      src/pages/block_answer.html
  30. 2 2
      src/pages/block_reply.html
  31. 20 0
      src/pages/breadcrumb.html
  32. 29 22
      src/pages/chat_detail.html
  33. 14 7
      src/pages/chats.html
  34. 41 21
      src/pages/discoveries.html
  35. 1 1
      src/pages/goal_branches.html
  36. 317 193
      src/pages/goal_detail.html
  37. 147 34
      src/pages/goals.html
  38. 313 409
      src/pages/home.html
  39. 23 11
      src/pages/login.html
  40. 58 46
      src/pages/main.html
  41. 58 40
      src/pages/model_settings.html
  42. 103 18
      src/pages/profile.html
  43. 2 2
      src/pages/progress_feed.html
  44. 97 49
      src/pages/project_detail.html
  45. 56 25
      src/pages/projects.html
  46. 99 31
      src/pages/rules.html
  47. 194 110
      src/pages/saddles.html
  48. 82 49
      src/pages/stack_detail.html
  49. 41 20
      src/pages/stacks.html
  50. 471 330
      src/pages/task_detail.html
  51. 26 31
      src/pages/task_log.html
  52. 48 5
      src/pages/task_log.js
  53. 51 0
      src/pages/task_log_feed.html
  54. 409 74
      src/pages/tasks.html
  55. 88 59
      src/pages/users.html
  56. 5 1
      test/bundles/__init__.py
  57. 13 4
      test/bundles/goals.py
  58. 67 13
      test/bundles/goalui.py
  59. 62 33
      test/bundles/pages.py
  60. 16 2
      test/bundles/push.py
  61. 11 2
      test/bundles/resurrect.py
  62. 281 0
      test/bundles/t10views.py
  63. 144 0
      test/bundles/t4lists.py
  64. 185 0
      test/bundles/t7log.py
  65. 173 0
      test/bundles/t8fleet.py
  66. 238 0
      test/bundles/t9library.py
  67. 124 0
      test/bundles/theme.py
  68. 3 3
      test/harness.py
  69. 12 3
      test/test_task_log_js.py

+ 336 - 0
docs/spry-conversion.md

@@ -0,0 +1,336 @@
+# Spry conversion conventions (goal 41, binding for T3–T10)
+
+ClankerSaddle runs on the Spry CSS framework that ships inside
+libspry. This document is the binding contract for every page task in
+the UI/UX goal: what Spry provides, what `app.css` owns, how classes
+map from the old stylesheet, and the rules for new markup.
+
+## How the framework is loaded
+
+`main.html` and `login.html` link, in this order:
+
+1. `spry.css` — core + default theme (light palette, dark palette on
+   `prefers-color-scheme: dark`). This IS the product default; no
+   `data-theme` is set anywhere by default.
+2. `spry-theme-{airy,dwarf,formal,glassy,h2o,living-sky,modern}.css`
+   — the seven optional themes, loaded but INERT until
+   `<html data-theme="slug">` is set. T2 wires the account-settings
+   picker that sets that attribute; until then they cost a few KB of
+   cache and do nothing.
+3. `app.css` — the app component layer, always LAST so it wins ties.
+
+Color mode is `data-mode="auto"` on `<html>` (spry's default behavior
+— omitting the attribute is also auto). Never hard-code a mode.
+
+## The theme slot contract (T2 — the account-settings picker)
+
+The user's saved appearance lives in TWO places with ONE source of
+truth:
+
+- **Server side**: the `user_preferences` table (M0011) stores
+  `theme` ("" = the core default, or one of the seven slugs) and
+  `mode` (auto|light|dark) per `spry_users.id` — see
+  `Services.UserPreferenceService`. This is the durable record the
+  login seeds from.
+- **Client side**: the DEVICE-scope Statum slot `"ui"` (public data
+  `{theme, mode}`) — written by `SetThemeAction` on save and re-seeded
+  by `LoginAction` at login. DEVICE scope means the browser caches it
+  in `localStorage`, so a returning visit paints the chosen theme
+  before any server round-trip (no FOUC beyond the very first visit,
+  which sees the Spry default — itself the default).
+
+`app.js`'s `applyUiSlot` hook reads the slot on statum init and after
+every render cycle and does the only client-side work there is:
+`data-theme="slug"` on `<html>` when a theme is set (attribute
+REMOVED for the core default — no `data-theme="core"`), and
+`data-mode="light|dark"` when forced (always present; `auto` is the
+default value). No other JS, stylesheet swaps or reloads — the seven
+theme stylesheets are always linked and inert until `data-theme`
+scopes them.
+
+Per-account on a per-device slot: two humans sharing a browser
+profile each get their own choice once they log in; a single user on
+two devices picks per device (the DEVICE slot is written on every
+save and every login of that user, so the LAST login on a device
+wins). Deleting the slot client-side (`statum.clear('ui')`) reverts
+to the Spry default until the next login.
+
+## Where styles live — the layering contract
+
+- **spry.css owns every primitive**: typography, links, focus rings,
+  buttons (`button`, `.btn`, `.btn-primary/danger/outline/ghost/link`,
+  `.btn-sm/lg/block`, `.btn-group`), forms (`input/select/textarea`
+  element styles, `.field`, `.label`, `.hint`, `.is-invalid/valid`,
+  `.choice(s)(-inline)`, `.switch`), tables (`table`, `.table-wrap`,
+  `.table-striped/hover/compact/borderless`), cards (`.card`,
+  `-flat/-header/-body/-footer/-title/-subtitle`), alerts (`.alert`
+  + `-danger/success/warning/info`), badges (`.badge` +
+  `-danger/success/warning/info/muted`), progress/meter/spinners,
+  dialogs (`<dialog>`, `.dialog-sm/lg/titlebar/title/close/body/footer`,
+  `[popover]`, `.popover-menu`, `.anchor`/`.popover-anchor`),
+  expanders (`details`/`summary`, `.expander-group`), hero/avatar/otp/
+  drawer/pagination, topbar/nav/menu/submenu/breadcrumb/tabs, layout
+  utilities (`.container(-sm/-lg)`, `.stack`, `.cluster`, `.center`,
+  `.with-sidebar`, `.block/inline/hidden/visually-hidden`, width/
+  position/overflow utilities), spacing utilities
+  (`.{m,p}{t,r,b,l,x,y}-{0..5}`, `.mx-auto`), and text utilities
+  (`.text-{xs..2xl}`, `.text-muted/accent/danger/success/warning/info`,
+  `.fw-*`, `.mono`, `.num`, `.truncate`, `.clamp-2/3`, `.measure`,
+  `.lead`, `.small`).
+- **app.css owns only ClankerSaddle components**: status/event badges
+  (the `.badge-*` family below), the goal identity, comments and
+  footnote treatment, blocks + plan review, the event log, advisor
+  chat, the terminal, toasts, the quote popover, the login shell,
+  `.cards` grid, `.grid-2`, and chrome shims (`.brand-emblem`,
+  `.nav-auth`, `.nav-user`, `.site-footer`).
+- **Never restyle a Spry primitive from app.css** and never
+  re-declare Spry's reset/typography. If a page needs something Spry
+  has, USE the Spry class; only add to app.css when the component is
+  genuinely app-specific.
+
+### app.css append-only discipline
+
+app.css is organised in sections with `==== header ====` comments.
+Within a section, new rules are APPENDED under the section header with
+a `/* comment */` naming the component and the reason (which page,
+which plan/goal). Never edit another task's rules in place — merge
+conflicts in this file are append-per-section and resolved trivially.
+Sections may be added at the END of the file.
+
+## Class mapping (old app.css → Spry)
+
+| old class | use instead |
+| --- | --- |
+| `body` 64rem column | `.container` on `<main>` (72rem, fluid padding) |
+| `.form-row` | `.field` (label + input inside) |
+| `button.primary` | `.btn-primary` |
+| `button.approve` | `.btn-primary` |
+| `button.reject` | `.btn-danger` |
+| `form.inline` | `.cluster` (wrap the fields/buttons) |
+| `label.inline` (checkbox row) | `.choice` |
+| `.muted` | `.text-muted` |
+| `.error` (text color) | `.text-danger` |
+| `details` custom | bare `details`/`summary` (Spry styles them) |
+| `.card` | `.card` (Spry) + `.card-body` etc. as needed |
+| `header` custom chrome | `.topbar` + `.topbar-brand` + `ul.nav`/`.has-submenu`/`ul.submenu` |
+| `.nav-menu`/`.nav-dropdown` | deleted — see topbar row above |
+| tables | bare `<table>` (+ `.table-hover`/`.table-compact` where dense) inside `.table-wrap` when wide |
+
+Kept as app components (now tokenised): `.comment`, `.footnote`,
+`.block-card`, `.plan-review`, `.plan-popover`, `.review-banner`,
+`.goal-*`, `.log-*`, `.event-*`, `.term-*`, `.terminal`, `.chat-*`,
+`.toast*`, `.quote-*`, `.ssh-key`, `.badge-*` status family,
+`.cards`, `.grid-2`, `.status-head`, `.status-corner`,
+`.waiting-drafts`, `.rule-card`, `.chip-remove`, `.yeehaw-*`.
+
+App tokens: `--goal: var(--spry-accent)` and
+`--goal-surface: var(--spry-accent-soft)` (goal identity derives from
+the theme accent; goal pages read as themselves through layout and
+badges, not a competing hue). All other old app tokens
+(`--ink`, `--line`, `--surface`, `--accent`, `--success`, …) are
+DELETED — use the `--spry-*` equivalents.
+
+## The badge-colour contract
+
+Every status/health/event badge consumes a semantic Spry variable —
+NO hard-coded hex anywhere. A `.badge-<status>` rule in app.css maps
+to exactly one of:
+
+- **muted** (`--spry-bg-alt` bg, `--spry-fg-muted` fg): draft, paused,
+  cancelled, offline, reference, command, created, shell-reset,
+  mode-implement, and their goal-* twins.
+- **queued** (`--spry-warning-soft`/`--spry-warning`): queued,
+  enqueued.
+- **info** (`--spry-info-soft`/`--spry-info`): assigned, planning,
+  started, resumed, dispatch, query, question, user-comment,
+  co-developed, mode-investigate, chat-awaiting_answer, kind-task,
+  state_change.
+- **warning** (`--spry-warning-soft`/`--spry-warning`): working,
+  blocked, block, suspended, review_requested, review,
+  completion_review, mode-review, busy, chat-running,
+  suggestion-pending, yeehaw, and the block-raised family.
+- **success** (`--spry-success-soft`/`--spry-success`): done, idle,
+  active, complete, completed, progress, mode-test, chat-idle,
+  suggestion-approved, and the answered family.
+- **danger** (`--spry-danger-soft`/`--spry-danger`): failed and every
+  rejected/failed goal transition.
+- **accent** (`--spry-accent-soft`/`--spry-accent`): plan, plan_review,
+  rfc, mode-orchestrate, block-plan, admin, capability, kind-goal,
+  goal-plan-submitted. (The old violet plan token retired: plans and
+  goals now ride the theme accent.)
+- **neutral inset** (`--spry-bg-inset`/`--spry-fg-muted`): comment,
+  tool.
+- Health badges are FILLED (strong var as bg, `--spry-accent-fg` fg)
+  so they read at a glance on the saddles page.
+
+New statuses in page tasks: add ONE line to the matching group in the
+badge section of app.css, never a new hex.
+
+## Markup rules for page tasks (T3–T10)
+
+1. **Page shell**: every page's content sits directly under `<main
+   class="container">`; page-level stacks use `.stack` or
+   `.stack`-like sections with spacing utilities. Detail pages may
+   adopt `.with-sidebar` from T5/T6 for side navigation.
+2. **Spacing** comes from Spry utilities (`.mt-3`, `.p-2`, `.gap-*`
+   via `.cluster`/`.stack`), not bespoke margins. If you write
+   `margin: …` in app.css, you need a reason comment.
+3. **Every colour via `--spry-*`** (or an app token derived from
+   one). Dark mode must work with zero extra rules — if you find
+   yourself writing `@media (prefers-color-scheme)`, stop: you
+   hard-coded something.
+4. **Buttons**: the element default is already Spry-styled; add
+   `.btn-primary` for the one primary action per view, `.btn-danger`
+   for destructive, `.btn-ghost` for quiet/inline actions (the old
+   `button.inline`), `.btn-sm` inside dense rows/tables.
+5. **Forms**: `<div class="field"><label…>…</label><input…>
+   <p class="hint">…</p></div>`. Checkboxes/radios use `.choice` /
+   `.choices(-inline)`; toggles use `.switch`. Wide editors
+   (textareas) live in `.field`s capped by `.form-rows .field
+   { max-width: 34rem }` — group a page's edit fields in
+   `.form-rows`.
+6. **History folds / actions surfaces**: per the goal plan, the
+   collapsed-history expander pattern and "actions surface" reshaping
+   (modals via native `<dialog>` with `.dialog-titlebar/-title/
+   -close/-body/-footer`) arrive WITH the page tasks (T3–T9), not in
+   this foundation. When you add one: `<dialog class="…" id="…">`
+   with a `.dialog-titlebar` carrying `<p class="dialog-title">` and
+   a `.dialog-close` button, body in `.dialog-body`, actions right-
+   aligned in `.dialog-footer`.
+7. **Expanders**: plain `<details>`/`<summary>` (Spry styles them;
+   group siblings in `.expander-group` to fuse borders).
+8. **Badges**: `<span class="badge badge-<status>">` per the contract
+   above; event kinds on the log/narrative use the same semantic
+   groups via `.event-kind-*` in app.css.
+9. **Wide tables** sit in `.table-wrap`; dense tables add
+   `.table-compact`, row-hover `.table-hover`.
+10. **Login is the reference** for form markup conventions
+    (`src/pages/login.html`).
+
+## The breadcrumb partial
+
+`src/pages/breadcrumb.html` is a `pstm-fragment` partial (the
+`progress_feed` pattern) that renders a Spry `.breadcrumb` from a
+`crumbs` array of `{href?, label}` items — the last crumb carries
+`aria-current="page"`. Include it as:
+
+    <pstm-fragment name="breadcrumb" crumbs="page.crumbs"/>
+
+Adoption by detail pages happens in T5/T6/T9 (it needs each page's
+entrypoint to expose a `crumbs` vector — do not fake it client-side).
+
+## The task-log-feed partial (a client-JS component partial)
+
+`src/pages/task_log_feed.html` is the event log's interactive shell
+(toolbar, filters, search, feed, load-older) as a `pstm-fragment`
+partial, included by BOTH the standalone `/tasks/{id}/log` page and
+the task page's "Event log" tab. It binds the including page's
+`task_log` PAGE slot (`exists` / `task_id` / `task_title` /
+`task_status` / `task_url`); `task_log.js` (loaded via `pstm-res-src`
+by whichever page embeds it) mounts everything from its
+`[data-log-*]` hooks. A page embedding it must (a) load the script
+resource, (b) have its entrypoint publish the slot —
+`TaskLogEntrypoint.log_slot_for(state_service, db, task_id)` builds
+it — and (c) re-set the slot from any pulse refresh the page owns
+(see `RefreshTaskDataAction`). Both surfaces share ONE markup and
+ONE engine by construction: never hand-copy the shell.
+
+## The sub-nav engine (T11 — one implementation, every detail page)
+
+A detail page's anchor sub-nav is ONE shared engine, `initSubnav()`
+in `app.js`. T5 shipped an IntersectionObserver chassis inline on
+the task page and T6 a closest-to-fold highlighter inline on the
+goal page; T11 unified them — never hand-copy either again. The
+engine owns smooth jumps that stop below the sticky chrome (topbar
++ strip, measured at click time), the closest-to-fold section
+highlight (the section whose top sits closest above the strip's
+bottom edge — the two-column Act|Consult grid made the naive
+"last heading that crossed" rule light the wrong card), the
+`.is-stuck` strip class, `aria-current` on the active link, and
+publishing the measured topbar height as `--topbar-bottom` (the
+sticky log toolbar consumes the same property).
+
+Markup contract:
+
+    <nav class="subnav" aria-label="Sections" data-subnav>
+      <a href="#section-id">…</a>
+    </nav>
+    <section id="section-id" data-subnav-section="section-id">…
+
+Both attributes are required: the `id` is what an anchor jumps to,
+`data-subnav-section` is what the highlight matches. A page opts in
+with one call — `if (window.initSubnav) { window.initSubnav(); }` —
+which is idempotent (the engine binds its document-level listeners
+once, flagged on `<html>`, and re-runs only the sync pass from
+`statum.afterEvaluate`).
+
+## The Act|Consult column layout (T11 — one class)
+
+Both detail pages (task, goal) lay out acting surfaces left and
+consulting surfaces right with ONE class:
+
+    <div class="with-sidebar act-consult">
+      <div class="stack">…ACT…</div>
+      <div class="stack">…CONSULT…</div>
+    </div>
+
+`app.css` re-proportions Spry's `.with-sidebar` (act takes the
+flexible track, consult a bounded 18–24rem rail) and restores the
+framework's 55rem stack below the breakpoint so ACT renders fully
+above CONSULT on narrow screens — DOM order IS the stack order.
+The per-page `.task-columns` / `.goal-columns` variants were
+unified away; do not reintroduce them.
+
+## The work-stream row grammar (T10)
+
+The dashboard's work stream is ONE `<ul class="work-stream">` of
+list rows (badge → when → actor → text), each row's badge mapping
+to a semantic group per the badge contract: task progress → the
+success group (`.badge-stream-task_progress`), goal events → accent
+(`.badge-stream-goal_event`), log highlights → the quiet inset
+(`.badge-stream-log_highlight`). Rows are list items, never table
+rows — a stream is read down the left edge and wraps under itself
+when narrow.
+
+## The saved-view hash contract (T10)
+
+`/tasks` filter state lives in `location.hash` (client-owned,
+shareable): the page reads it on load and keeps it current as the
+chips change, so a saved URL restores the exact view. The keys are
+the filter names the tasks slot already carries (`#status=…`,
+combinable). No server round-trip — the hash IS the view.
+
+## The dialog contract (T4/T8/T11)
+
+Every create/answer dialog in the app follows ONE shape:
+
+    <dialog id="…" class="dialog-sm">
+      <div class="dialog-titlebar">
+        <p class="dialog-title">…</p>
+        <button type="button" class="dialog-close" aria-label="Close"
+                onclick="window.closeAppDialog('…')">×</button>
+      </div>
+      <form stm-action="…">
+        <div class="dialog-body form-rows">…fields…</div>
+        <div class="dialog-footer">…buttons right-aligned…</div>
+      </form>
+    </dialog>
+
+Open with `window.openAppDialog('…')` — it wraps
+`statum.onNotifyHandler` and closes the dialog on ANY notify (a
+success closes the modal; an error closes it too, the toast carries
+the message). An open dialog KEEPS its open state and typed values
+across pulse re-renders (statum never rebuilds static markup). The
+one intentional deviation is the project delete dialog, which keeps
+its bespoke confirm-by-name danger shape (T9's design).
+
+## The CrumbView contract (T11)
+
+Every detail page renders its breadcrumb from ONE class:
+`ClankerSaddle.CrumbView` (`src/Json.vala`) — `{href, label}`,
+`href` null on the leaf. Build the vector in the entrypoint,
+publish it as `crumbs` via `StateJson.list<CrumbView>(…)`, include
+the partial (`<pstm-fragment name="breadcrumb" crumbs="…"/>`). The
+four per-page copies (Task/Goal/Project/Chat CrumbView) were
+unified away; do not add a page-local one.
+

+ 8 - 0
docs/ux-notes.md

@@ -1,5 +1,13 @@
 # ClankerSaddle web UI — structural layout critique
 
+> **Historical document (goal 41).** This critique drove the UI/UX
+> overhaul; every quick win and structural bet it proposed now ships
+> on the Spry framework — `docs/spry-conversion.md` describes the
+> app as built (the binding conventions), and this file stays as the
+> record of what was wrong and why. The `mocks/` direction samplers
+> it references were explorations and were deleted once the shipped
+> dashboard superseded them.
+
 This is an opinions document about **structure**, not looks: the theme,
 colours and typography stay as they are. "Structure" here means page
 hierarchy (what leads a page, what folds away), information order,

+ 4 - 0
meson.build

@@ -48,8 +48,10 @@ template_deps = files(
     'src/pages/goal_branches.html',
     'src/pages/goal_compare.html',
     'src/pages/progress_feed.html',
+    'src/pages/task_log_feed.html',
     'src/pages/block_answer.html',
     'src/pages/block_reply.html',
+    'src/pages/breadcrumb.html',
 )
 
 # spry:pages-begin
@@ -225,6 +227,7 @@ app_sources = files(
     'src/Data/Migrations/M0008_AnswerWake.vala',
     'src/Data/Migrations/M0009_TurnCapPark.vala',
     'src/Data/Migrations/M0010_ModelConfig.vala',
+    'src/Data/Migrations/M0011_UserPreferences.vala',
     'src/Services/ProjectService.vala',
     'src/Services/TaskService.vala',
     'src/Services/GoalService.vala',
@@ -246,6 +249,7 @@ app_sources = files(
     'src/Services/PushService.vala',
     'src/Services/ChatService.vala',
     'src/Services/ModelConfigService.vala',
+    'src/Services/UserPreferenceService.vala',
     'src/Services/CanonService.vala',
     'src/Services/AdvisorService.vala',
     'src/Services/AdvisorCallback.vala',

+ 0 - 177
mocks/README.md

@@ -1,177 +0,0 @@
-# Dashboard visual directions — static HTML samplers
-
-Three standalone, fully self-contained mocks of the SAME dashboard.
-No frameworks, no build step, no external assets — embedded CSS only
-(the brand emblem rides inline as a downscaled PNG data URI).
-Open directly via `file://`. Each supports light AND dark via
-`prefers-color-scheme` (verify by toggling devtools scheme emulation).
-
-- `dashboard-primer.html` — 1 Curated Primer (light-first)
-- `dashboard-control.html` — 2 Control Room (dark-first)
-- `dashboard-swiss.html` — 3 Swiss Flat (light-first)
-- `dashboard-current.html` — 4 Current quieted (theme-faithful)
-
-All four carry IDENTICAL content structure (the `<body>` markup is
-byte-identical apart from the sampler number/label), so the comparison
-is purely visual.
-
-## Header
-
-Every direction keeps the real app's chrome: the emblem + ClankerSaddle
-brand at the app's original `height: 8rem` (with its `-0.2rem`
-margin-block trim), and the three nav groups from `main.html` — Work
-(Dashboard, Tasks, Goals, Chats), Library (Projects, Rules, Stacks,
-Discoveries), Admin (Saddles, Users, Model) — plus the Account menu.
-Pure CSS `<details>` dropdowns, no JS. Brand and nav are centre-aligned
-on one axis (verified via engine geometry: brand/emblem/nav/summary
-mids equal to 0.1px in all three directions). The emblem is embedded
-per file as a 256px, 64-colour-quantised PNG data URI (~31 KB base64)
-so the samplers stay standalone while rendering crisp at 8rem.
-
-## Content model (per the approved hierarchy)
-
-- Zone 1 (top): the advisor entry box ONLY — heading and description
-  sit INSIDE the composer card, above the textarea; model/snapshot
-  hint and Start chat below. The front page never shows chat
-  transcripts themselves (review decision). A single quiet link to
-  the chats list.
-- Zone 2 (one small scroll below): "Needs a human" items tiered ABOVE
-  queue health — a completion awaiting review, a turn-cap parked task
-  to wake, an open question; then running / stuck / queued counts and
-  saddle states.
-- Zone 3 (ambient, reduced density): notes, latest discoveries,
-  active tasks table and activity timeline — ALL sections start
-  collapsed, rendered as plain ruled expander rows with NO card
-  chrome (no background, no border box, no radius).
-
-Sample data uses the real app's vocabulary: statuses
-draft / queued / assigned / working / blocked / review_requested /
-suspended / done / failed; saddle states idle / busy / offline;
-blocks review / question; turn-cap parks. Task titles reference real
-recent fleet work. Sections are deliberately uneven — one saddle
-offline, one task with no latest progress, a sparse notes list —
-because perfection reads fake.
-
-## Direction 1 — Curated Primer
-
-GitHub/Primer lineage, disciplined, light default. Cards on a sunken
-page, 8px grid, 8px radius, tinted status pills, the existing blue as
-accent. The natural evolution of today's `app.css`.
-
-:root (light):
-```
---surface-sunken #f6f8fa   --surface #ffffff    --surface-inset #eff2f5
---ink #1f2328               --ink-muted #59636e  --ink-faint #6e7681
---line #d1d9e0              --line-soft #e4eaef
---accent #0969da            --accent-surface #ddf4ff
---success #1a7f37           --warning #9a6700    --danger #cf222e
---radius 8px
-```
-Dark variant: `--surface-sunken #0d1117`, `--surface #151b23`,
-`--surface-inset #1c2430`, `--ink #e6edf3`, `--ink-muted #9198a1`,
-`--ink-faint #6e7681`, `--line #3d444d`, `--line-soft #262c36`,
-`--accent #4493f8`, `--accent-surface #121d2f`, `--success #3fb950`,
-`--warning #d29922`, `--danger #f85149`.
-
-Status = tinted pill pairs: working/blocked/queued on yellow tints,
-done/progress green tints, failed red tint, review orange tint,
-question blue tint, parked/suspended neutral. Type: system-ui 15px.
-Shadows: none (1px borders only).
-
-## Direction 2 — Control Room
-
-Dark-first, mono-forward, mission-console restraint. Quiet cold
-neutrals; status colour carries the meaning; labels and numerals in
-mono. Status reads as small outline capsules in the status hue —
-colour IS the signal, fill stays quiet. Light variant is a working
-console on white, not an afterthought.
-
-:root (dark, default):
-```
---bg #0f1317   --surface #151a20   --raise #1c232b
---ink #c9d4de  --dim #7e8b98       --faint #525e6a
---rule #262e38 --rule-hi #394450
---run #4cc38a  --warn #f5b452  --crit #ef6460  --info #58a6ff
---accent #58a6ff  --radius 4px
---mono ui-monospace …  --sans system-ui …
-```
-Light variant: `--bg #eef1f4`, `--surface #ffffff`, `--raise #f4f6f8`,
-`--ink #1b2530`, `--dim #54626f`, `--faint #75828e`, `--rule #d7dee4`,
-`--rule-hi #b4c0ca`, `--run #10735a`, `--warn #8f5b00`,
-`--crit #c3362f`, `--info #0a5dc2`.
-
-Status semantics: run=green (running/done/busy), warn=amber
-(queued/blocked/review/stuck), crit=red (failed), info=blue
-(interactive/question). Type: sans 14px body, mono for labels,
-metrics, meta and times. Radius 4px; no shadows; saddle states are
-tiny square lamps (filled / outline / dashed).
-
-## Direction 3 — Swiss Flat
-
-Strict grid, typographic hierarchy, one strong accent, no shadows, no
-ornament. Structure lines are INK, not grey; the frame is the design.
-A hard left rail carries brand + zone labels; metrics are oversized
-light numerals (weight 200). Status is typography: weight and colour
-in near-black / greys, with red reserved for failure and the accent.
-
-:root (light):
-```
---paper #fdfdfb  --ink #111111  --grey-1 #6b6b6b  --grey-2 #82827e
---rule #111111   --rule-thin #d9d9d6
---red #e2231a    --radius 0
---font "Helvetica Neue" …  --mono ui-monospace …
-```
-Dark variant: `--paper #121212`, `--ink #f0f0ec`, `--grey-1 #a8a8a4`,
-`--grey-2 #7c7c78`, `--rule #f0f0ec`, `--rule-thin #2e2e2c`,
-`--red #ff4438`.
-
-Status: failed and the single accent share red; everything else is
-typographic (bold caps tags, question underlined in ink). Links are
-ink with a thin grey underline; red appears on hover and on the
-primary button. Radius 0, no shadows, 2px structural rules.
-
-## Direction 4 — Current quieted (theme-faithful)
-
-The control sample: today's theme kept as close as possible — the
-same tokens as `app.css` (`#f6f8fa` sunken page, `#ffffff` cards,
-`#0969da` accent, `1a7f37/9a6700/cf222e` status, 8px radius, 8px
-grid, system-ui 15px), the buttony nav dropdown menus, bordered cards
-with tinted pill badges, `details` sections rendered as cards, and the
-full-grid bordered tables. Only the LAYOUT is quieted:
-
-- one column at the app's real 64rem (no second sidebar column),
-- "Needs a human" as three rows inside ONE card instead of a card
-  per item,
-- section headings reduced to a single small heading each — no
-  stacked h2 + h3 pairs, no explanatory paragraphs between sections,
-- ambient sections are plain ruled expanders (the app's
-  details-as-cards chrome removed per review),
-- the advisor hero is a single card carrying its own heading and
-  description, matching `home.html`'s token recipe.
-
-Component styles verified identical to the live `app.css` via
-computed-style comparison (radius, shadow, paddings, colours, fonts).
-A dark set is included so it honours `prefers-color-scheme` like its
-siblings; the light values are the faithful ones.
-
-## Anti-slop checklist (honoured by all four)
-
-No purple-blue gradients, no glassmorphism, no oversized drop
-shadows, no marketing hero, no emoji decoration, no fake-perfect
-symmetry. Status palettes avoid the blue/purple wash; accent use is
-rationed.
-
-## Verification performed
-
-- Files open standalone via `file://` (Chromium 154 headless).
-- Light/dark flip verified per file via forced scheme: page
-  backgrounds and every status colour change to the intended tokens;
-  content extents identical between schemes (no reflow breakage).
-- The four `<body>` blocks verified identical modulo sampler label.
-- Nav dropdowns verified open (per-direction probe renders); emblem
-  verified visible in both schemes in all four directions, rendered
-  at the app's original 8rem height; brand/nav vertical alignment
-  verified equal via getBoundingClientRect mids.
-- Direction 4 verified component-faithful to `app.css` via computed
-  styles (radius/shadow/padding/colours/fonts all equal).
-- 390px mobile: no horizontal overflow; all zones render.

File diff suppressed because it is too large
+ 0 - 311
mocks/dashboard-control.html


File diff suppressed because it is too large
+ 0 - 282
mocks/dashboard-current.html


File diff suppressed because it is too large
+ 0 - 321
mocks/dashboard-primer.html


File diff suppressed because it is too large
+ 0 - 304
mocks/dashboard-swiss.html


+ 29 - 0
src/Data/Entities.vala

@@ -828,6 +828,35 @@ namespace ClankerSaddle.Data {
         }
     }
 
+    /**
+     * One per-user preference row (goal 41 task 2): the account
+     * settings' key/value store — the theme picker's "theme" (a Spry
+     * theme slug, "" = the core default) and "mode" (auto|light|dark)
+     * are the first keys; new preferences need no migration. Stored in
+     * the `user_preferences` table with a COMPOSITE primary key
+     * (user_id, key), so rows upsert through {@link
+     * Services.UserPreferenceService}'s raw SQL — the ORM's
+     * update_async keys on the (introspected, single) primary-key
+     * column and must not touch this table.
+     *
+     * `user_id` references the Spry users table by value (no FK — the
+     * auth schema owns that table, the same posture as {@link
+     * PushSubscription}).
+     */
+    public class UserPreference : Object {
+
+        public int64 user_id { get; set; }
+        public string key { get; set; default = ""; }
+        public string value { get; set; default = ""; }
+
+        public static void entity_mapping(EntityMapperBuilder<UserPreference> cfg) {
+            cfg.table("user_preferences")
+                .column<int64?>("user_id", o => o.user_id, (o, v) => o.user_id = v)
+                .column<string>("key", o => o.key, (o, v) => o.key = v)
+                .column<string>("value", o => o.value, (o, v) => o.value = v);
+        }
+    }
+
     /**
      * The deployment's model configuration (the Admin → Model page):
      * the OpenAI-compatible endpoint every saddle assignment is sent

+ 78 - 0
src/Data/Migrations/M0011_UserPreferences.vala

@@ -0,0 +1,78 @@
+    using InvercargillSql.Migrations;
+    using Sqlite;
+
+    namespace ClankerSaddle.Data.Migrations {
+
+        /**
+         * Per-user preference storage for the account settings page (goal
+         * 41 task 2): the `user_preferences` key/value table backing the
+         * Spry theme picker. Rows are (user_id, key, value) upserts —
+         * the first keys are "theme" (a Spry theme slug, "" = the core
+         * default) and "mode" (auto|light|dark) — so new preferences
+         * need no further migrations.
+         *
+         * `user_id` references the Spry users table BY VALUE (no FK —
+         * the auth schema owns that table, the same posture as
+         * `push_subscriptions`).
+         *
+         * IDEMPOTENCE: same contract as M0002–M0010 — up() runs before
+         * the apply transaction opens (the builder only queues), so it
+         * introspects the schema through a separate read-only handle and
+         * creates the table only when it is still missing.
+         */
+        public class M0011_UserPreferences : Migration {
+
+            public override string name { get { return "User preferences"; } }
+            public override uint64 serial { get { return 11; } }
+            public override string migration_namespace { get { return "clanker-saddle"; } }
+
+            // The runner never hands up() a connection, so the schema is
+            // read through a read-only handle on the SPRY_DB_PATH main.vala
+            // connects to.
+            private string db_path() {
+                var path = Environment.get_variable("SPRY_DB_PATH") ?? "";
+                if (path.strip().length == 0) {
+                    return "./clanker-saddle.sqlite";
+                }
+                return path;
+            }
+
+            private bool table_exists() {
+                Database db;
+                int rc = Database.open_v2(db_path(), out db, Sqlite.OPEN_READONLY);
+                if (rc != Sqlite.OK) {
+                    return false;
+                }
+                Statement statement;
+                rc = db.prepare_v2(
+                    "select 1 from sqlite_master where type = 'table' and name = 'user_preferences'",
+                    -1, out statement);
+                if (rc != Sqlite.OK) {
+                    return false;
+                }
+                return statement.step() == Sqlite.ROW;
+            }
+
+            public override void up(MigrationBuilder b) {
+                if (table_exists()) {
+                    return;
+                }
+                b.create_table("user_preferences", (t) => {
+                    t.column<int64?>("user_id")
+                        .not_null();
+                    t.column<string>("key")
+                        .not_null();
+                    t.column<string>("value")
+                        .not_null()
+                        .default_value<string>("");
+                    // The composite key makes every (user, key) an UPSERT:
+                    // one row per preference, replaced on change.
+                    t.primary_key({"user_id", "key"});
+                });
+            }
+
+            public override void down(MigrationBuilder b) {
+                b.execute_sql("drop table if exists user_preferences");
+            }
+        }
+    }

+ 14 - 0
src/Json.vala

@@ -5,6 +5,20 @@ using Statum;
 
 namespace ClankerSaddle {
 
+    /**
+     * One breadcrumb crumb (goal 41 T11): the shared partial
+     * (src/pages/breadcrumb.html) renders the trail from a vector of
+     * these. {@link href} is null for the trailing (current page)
+     * crumb — the partial gives it aria-current and muted styling.
+     * One shape for every detail page (task, goal, project, chat);
+     * it lived as four identical per-entrypoint copies until the
+     * goal-41 sweep unified them.
+     */
+    public class CrumbView : Object {
+        public string? href { get; set; }
+        public string label { get; set; default = ""; }
+    }
+
     /**
      * Helpers for reading the JSON objects that travel the wire —
      * pushed results/chunks entries, block payloads and request

+ 24 - 0
src/Services/RuleService.vala

@@ -216,6 +216,30 @@ namespace ClankerSaddle.Services {
                 .materialise_async();
         }
 
+        /**
+         * Goal 41 T9: the `rule_projects` rows carrying `rule_id`,
+         * oldest first — the project half of a rule's blast radius on
+         * the rules page.
+         */
+        public async ImmutableLot<RuleProject> list_project_attachments_by_rule(int64 rule_id) throws Error {
+            return yield db.query<RuleProject>()
+                .where(expr("rule_id == $0", elem<int64?>(rule_id)))
+                .order_by(expr("id"))
+                .materialise_async();
+        }
+
+        /**
+         * Goal 41 T9: the `task_rules` rows carrying `rule_id`, oldest
+         * first — the task half of a rule's blast radius on the rules
+         * page.
+         */
+        public async ImmutableLot<TaskRule> list_task_attachments_by_rule(int64 rule_id) throws Error {
+            return yield db.query<TaskRule>()
+                .where(expr("rule_id == $0", elem<int64?>(rule_id)))
+                .order_by(expr("id"))
+                .materialise_async();
+        }
+
         /** The rules attached to `task_id`, attachment order. */
         public async ImmutableLot<Rule> list_rules_for_task(int64 task_id) throws Error {
             var rows = new Vector<Rule>();

+ 86 - 0
src/Services/UserPreferenceService.vala

@@ -0,0 +1,86 @@
+using Invercargill;
+using Invercargill.DataStructures;
+using Invercargill.Expressions;
+using InvercargillSql;
+using InvercargillSql.Orm;
+using Inversion;
+using ClankerSaddle.Data;
+
+namespace ClankerSaddle.Services {
+
+    /** The user_preferences key of the Spry theme slug ("" = core default). */
+    public const string PREF_THEME = "theme";
+
+    /** The user_preferences key of the colour mode (auto|light|dark). */
+    public const string PREF_MODE = "mode";
+
+    /** The default colour mode when no preference is stored. */
+    public const string MODE_DEFAULT = "auto";
+
+    /**
+     * Per-user preferences (goal 41 task 2): the account settings'
+     * key/value store behind the Spry theme picker. Reads go through
+     * the ORM query API; writes are raw UPSERTs — the table's
+     * composite primary key (user_id, key) defeats the ORM's
+     * single-column update_async, so INSERT ... ON CONFLICT replaces
+     * both halves of the pair.
+     *
+     * The theme helpers ({@link get_theme}, {@link set_theme}, {@link
+     * get_mode}, {@link set_mode}) are thin sugar over the generic
+     * string accessors; validation lives with the action that owns
+     * the form, not here — storage accepts any string.
+     */
+    public class UserPreferenceService : Object {
+
+        private OrmSession db = inject<OrmSession>();
+        private Connection connection = inject<Connection>();
+
+        /**
+         * The stored value of `key` for `user_id`, or "" when the
+         * user never set it.
+         */
+        public async string get_string(int64 user_id, string key) throws Error {
+            var row = yield db.query<UserPreference>()
+                .where(expr("user_id == $0 && key == $1",
+                    elem<int64?>(user_id), elem(key)))
+                .first_async();
+            return row != null ? ((!)row).value : "";
+        }
+
+        /**
+         * Upserts `value` under `key` for `user_id`: one row per
+         * (user, key), replaced on conflict.
+         */
+        public async void set_string(int64 user_id, string key, string value) throws Error {
+            yield connection.create_command(
+                "INSERT INTO user_preferences (user_id, key, value)"
+                + " VALUES (:user_id, :key, :value)"
+                + " ON CONFLICT(user_id, key) DO UPDATE SET value = excluded.value")
+                .with_parameter<Invercargill.Element>("user_id", elem<int64?>(user_id))
+                .with_parameter<Invercargill.Element>("key", elem(key))
+                .with_parameter<Invercargill.Element>("value", elem(value))
+                .execute_non_query_async();
+        }
+
+        /** The stored theme slug ("" = the Spry core default). */
+        public async string get_theme(int64 user_id) throws Error {
+            return yield get_string(user_id, PREF_THEME);
+        }
+
+        /** Stores the theme slug ("" = the Spry core default). */
+        public async void set_theme(int64 user_id, string theme) throws Error {
+            yield set_string(user_id, PREF_THEME, theme);
+        }
+
+        /** The stored colour mode (auto when never set). */
+        public async string get_mode(int64 user_id) throws Error {
+            var mode = yield get_string(user_id, PREF_MODE);
+            return mode.length > 0 ? mode : MODE_DEFAULT;
+        }
+
+        /** Stores the colour mode. */
+        public async void set_mode(int64 user_id, string mode) throws Error {
+            yield set_string(user_id, PREF_MODE, mode);
+        }
+    }
+}

+ 47 - 1
src/actions/LoginAction.vala

@@ -1,13 +1,59 @@
+using Inversion;
+using Spry;
+using Spry.Authentication;
 using Statum;
+using ClankerSaddle.Services;
 
 namespace ClankerSaddle {
 
     /**
      * The shipped {@link Spry.Actions.LoginAction} with this app's
-     * post-login destination: the dashboard.
+     * post-login destination (the dashboard) and its appearance
+     * seeding (goal 41 task 2): a successful login also writes the
+     * user's stored theme/mode preferences to the DEVICE-scope `ui`
+     * slot, so the client's theme hook paints the chosen look from
+     * the very first page and keeps it cached on this device across
+     * sessions.
+     *
+     * The user id CANNOT come from {@link SpryAuth.current} here: the
+     * request's held slots are the ones the browser SENT, and the auth
+     * slot this login establishes rides the RESPONSE — so the
+     * credentials are resolved once more through the user service
+     * (a failed login short-circuits to Spry's own answer, and a
+     * storage failure degrades to a warning: the login itself must
+     * not break).
      */
     public class LoginAction : Spry.Actions.LoginAction {
 
         protected override string landing_uri { get { return "/"; } }
+
+        private UserService users = inject<UserService>();
+        private UserPreferenceService preferences = inject<UserPreferenceService>();
+
+        public override async DirectiveBuilder handle() throws GLib.Error {
+            var landing = yield base.handle();
+
+            string username = "";
+            string password = "";
+            if (request.form != null) {
+                username = request.form.get_field("username") ?? "";
+                password = request.form.get_field("password") ?? "";
+            }
+            var user = yield users.authenticate_user(username, password);
+            if (user == null) {
+                return landing;
+            }
+
+            try {
+                string theme = yield preferences.get_theme(((!)user).id);
+                string mode = yield preferences.get_mode(((!)user).id);
+                var ui = new UiSlotPublic() { theme = theme, mode = mode };
+                return landing.set_typed<UiSlotPublic>(SetThemeAction.UI_SLOT_TYPE,
+                    Statum.Scope.DEVICE, ui);
+            } catch (Error e) {
+                warning(@"[ClankerSaddle] could not seed the appearance slot at login: $(e.message)");
+                return landing;
+            }
+        }
     }
 }

+ 125 - 1
src/actions/ProfileActions.vala

@@ -1,14 +1,138 @@
+using Inversion;
+using Spry;
+using Spry.Authentication;
 using Statum;
+using ClankerSaddle.Services;
 
 namespace ClankerSaddle {
 
     /**
      * The shipped Spry change-own-password action with this app's
-     * post-change destination (the profile page) — the form fields and
+     * post-change destination (the profile page) - the form fields and
      * verification (current/new/confirm) are Spry's.
      */
     public class ChangeMyPasswordAction : Spry.Actions.ChangePasswordAction {
 
         protected override string login_uri { get { return "/login"; } }
     }
+
+    /**
+     * The public shape of the DEVICE-scope `ui` slot (goal 41 task 2):
+     * the theme + colour mode the client applies to
+     * `document.documentElement` (`data-theme`/`data-mode`). Plain
+     * strings on purpose — the slot is cached client-side across
+     * sessions, so it must stay readable by old frames.
+     */
+    public class UiSlotPublic : Object {
+        /** The Spry theme slug ("" = the core default). */
+        public string theme { get; set; default = ""; }
+        /** auto | light | dark. */
+        public string mode { get; set; default = MODE_DEFAULT; }
+    }
+
+    /**
+     * Saves the account's appearance choice (goal 41 task 2): the
+     * form on /profile posts `theme` (the Spry core default "" or one
+     * of the seven shipped theme slugs) and `mode`
+     * (auto|light|dark). The choice is persisted per user in
+     * `user_preferences` AND written to the DEVICE-scope `ui` slot —
+     * the client's theme hook reads that slot on init and after every
+     * statum render, so the new look applies immediately and survives
+     * sessions on this device.
+     */
+    public class SetThemeAction : StatumAction {
+
+        /** The seven optional themes shipped with Spry 0.3. */
+        public const string[] THEME_SLUGS = {
+            "airy", "dwarf", "formal", "glassy", "h2o", "living-sky", "modern"
+        };
+
+        /** The three colour modes. */
+        public const string[] MODES = { "auto", "light", "dark" };
+
+        /** The DEVICE-scope slot type the client theme hook reads. */
+        public const string UI_SLOT_TYPE = "ui";
+
+        private UserService users = inject<UserService>();
+        private UserPreferenceService preferences = inject<UserPreferenceService>();
+        private StateService states = inject<StateService>();
+        private PushService push_service = inject<PushService>();
+        private InvercargillSql.Orm.OrmSession db = inject<InvercargillSql.Orm.OrmSession>();
+
+        /** True when `slug` names a theme the client may apply. */
+        public static bool is_valid_theme(string slug) {
+            if (slug == "" || slug == "core") {
+                return true;
+            }
+            foreach (string candidate in THEME_SLUGS) {
+                if (candidate == slug) {
+                    return true;
+                }
+            }
+            return false;
+        }
+
+        /** True when `mode` is a colour mode the client may apply. */
+        public static bool is_valid_mode(string mode) {
+            foreach (string candidate in MODES) {
+                if (candidate == mode) {
+                    return true;
+                }
+            }
+            return false;
+        }
+
+        public override async DirectiveBuilder handle() throws GLib.Error {
+            var guard = yield SpryAuth.require_login(directives(), request, users);
+            if (guard != null) {
+                return (!)guard;
+            }
+
+            string theme = Forms.field(request, "theme");
+            string mode = Forms.field(request, "mode");
+            if (mode.length == 0) {
+                mode = MODE_DEFAULT;
+            }
+            // "core" is the same choice as "" (the default); store the
+            // canonical empty slug so the client never carries a
+            // data-theme that scopes no stylesheet.
+            if (theme == "core") {
+                theme = "";
+            }
+
+            if (!is_valid_theme(theme)) {
+                return directives().notify("error",
+                    "Unknown theme '" + theme + "' - pick one of the themes offered below");
+            }
+            if (!is_valid_mode(mode)) {
+                return directives().notify("error",
+                    "Unknown colour mode '" + mode + "' - use auto, light or dark");
+            }
+
+            var me = SpryAuth.current(request);
+            try {
+                yield preferences.set_theme(me.user_id, theme);
+                yield preferences.set_mode(me.user_id, mode);
+                var ui = new UiSlotPublic() { theme = theme, mode = mode };
+                var builder = directives()
+                    .set_typed<UiSlotPublic>(UI_SLOT_TYPE, Statum.Scope.DEVICE, ui);
+                // Answer with the refreshed profile card too (the
+                // dashboard-action pattern): the client re-renders
+                // every binding after the chain, and without this the
+                // radios would snap back to the pre-save values.
+                var user = yield users.get_user(me.user_id);
+                if (user != null) {
+                    var profile = yield ProfileEntrypoint.build_profile_slot(
+                        states, action_registry, users, preferences, me, (!)user,
+                        push_service, db);
+                    builder.set(profile);
+                }
+                return builder
+                    .notify("success", "Appearance saved — the new theme applies immediately");
+            } catch (Error e) {
+                return directives().notify("error",
+                    @"Could not save the appearance choice: $(e.message)");
+            }
+        }
+    }
 }

+ 7 - 1
src/actions/TaskActions.vala

@@ -646,6 +646,11 @@ namespace ClankerSaddle {
      * current data so the three-second client poll shows live comments,
      * blocks, output-driven status changes and completions. Navigates
      * nowhere; a failed reload leaves the client's state untouched.
+     *
+     * Goal 41 T7: the page also embeds the event log's shell (its
+     * "Log" tab), so the refresh rebuilds the `task_log` slot too —
+     * the embedded live badge's status words stay truthful after a
+     * transition without the log shell owning its own poll.
      */
     public class RefreshTaskDataAction : TaskDetailRefreshingAction {
 
@@ -660,7 +665,8 @@ namespace ClankerSaddle {
                 return directives();
             }
 
-            return directives().set(yield task_detail_slot(task_id));
+            return directives().set(yield task_detail_slot(task_id))
+                .set(yield TaskLogEntrypoint.log_slot_for(state_service, db, task_id));
         }
     }
 }

+ 8 - 0
src/entrypoints/ChatDetailEntrypoint.vala

@@ -165,6 +165,14 @@ namespace ClankerSaddle {
                     messages.to_immutable_buffer());
                 props["suggestions"] = StateJson.list<ChatSuggestionView>(
                     suggestions.to_immutable_buffer());
+                // Goal 41 T9: the breadcrumb trail (Dashboard / Chats /
+                // <title>) rendered by the shared partial.
+                var crumbs = new Vector<CrumbView>();
+                crumbs.add(new CrumbView() { href = "/", label = "Dashboard" });
+                crumbs.add(new CrumbView() { href = "/chats", label = "Chats" });
+                crumbs.add(new CrumbView() { label = ((!)chat).title });
+                props["crumbs"] = StateJson.list<CrumbView>(
+                    crumbs.to_immutable_buffer());
             } else {
                 props = (PropertyDictionary) GObjectMapping.to_properties(pub);
                 props["messages"] = StateJson.list<ChatMessageView>(

+ 15 - 0
src/entrypoints/GoalDetailEntrypoint.vala

@@ -284,6 +284,7 @@ namespace ClankerSaddle {
         public Vector<GoalCommentView> comments { get; set; default = new Vector<GoalCommentView>(); }
         public Vector<GoalRunView> runs { get; set; default = new Vector<GoalRunView>(); }
         public Vector<GoalPushBranchView> push_branches { get; set; default = new Vector<GoalPushBranchView>(); }
+        public Vector<CrumbView> crumbs { get; set; default = new Vector<CrumbView>(); }
         public Vector<GoalProgressView> progress { get; set; default = new Vector<GoalProgressView>(); }
     }
 
@@ -313,6 +314,18 @@ namespace ClankerSaddle {
             var goal = yield ctx.goals.get_goal(goal_id);
             if (goal != null) {
                 view = yield full_view((!)goal, ctx);
+                // Goal 41 T6: the breadcrumb trail (the shared partial
+                // renders it; the leaf carries no href and reads
+                // aria-current from the partial).
+                view.crumbs.add(new CrumbView() {
+                    href = "/", label = "Dashboard"
+                });
+                view.crumbs.add(new CrumbView() {
+                    href = "/goals", label = "Goals"
+                });
+                view.crumbs.add(new CrumbView() {
+                    label = @"#$(goal_id)"
+                });
                 foreach (var block in view.blocks) {
                     if (block.is_plan_review && plan_pending == null) {
                         plan_pending = block;
@@ -400,6 +413,8 @@ namespace ClankerSaddle {
                 view.progress.to_immutable_buffer());
             pub_root["push_branches"] = StateJson.list<GoalPushBranchView>(
                 view.push_branches.to_immutable_buffer());
+            pub_root["crumbs"] = StateJson.list<CrumbView>(
+                view.crumbs.to_immutable_buffer());
             pub_root["plan_revisions"] = StateJson.list<GoalBlockView>(
                 plan_revisions.to_immutable_buffer());
             pub_root["rules"] = StateJson.list<GoalRuleView>(

+ 82 - 0
src/entrypoints/HomeEntrypoint.vala

@@ -49,6 +49,36 @@ namespace ClankerSaddle {
         public string task_branch { get; set; default = ""; }
     }
 
+    /**
+     * One row of the dashboard's cross-entity work stream (goal 41
+     * T10, ux-notes structural bet 5): task progress, goal narrative
+     * and event-log highlights joined into ONE time-ordered feed —
+     * the projection of {@link TimelineService}'s window into the
+     * three grammar the feed renders, each row carrying its own link
+     * so the stream is the dashboard's front door into the work.
+     */
+    public class DashboardStreamRowView : Object {
+        /** task_progress | goal_event | log_highlight — the feed's
+         * three row grammars (one badge vocabulary: app.css maps
+         * .badge-stream-<kind> onto the semantic token groups). */
+        public string kind { get; set; default = "log_highlight"; }
+        public string when_display { get; set; default = ""; }
+        /** The row's epoch second (newest-first sort already done by
+         * the timeline window; kept for the client's new-since badge). */
+        public int64 at_epoch { get; set; }
+        /** The actor the row names (task/goal title, or "" for plain
+         * log lines) — the feed's WHO column. */
+        public string actor { get; set; default = ""; }
+        /** The row's one-line text (TimelineService's snippet). */
+        public string text { get; set; default = ""; }
+        /** The timeline kind that produced the row (badge text). */
+        public string event_kind { get; set; default = ""; }
+        /** Where the row links ("" = a bare highlight row). */
+        public string href { get; set; default = ""; }
+        public int64 task_id { get; set; }
+        public int64 goal_id { get; set; }
+    }
+
     /**
      * A pinned OPEN blocking item (plan 10 phase 3; plan 12 unified;
      * plan 15 C1 single-row): an unanswered question, RFC, review or
@@ -175,6 +205,13 @@ namespace ClankerSaddle {
      */
     public class DashboardState : Object {
 
+        /**
+         * The work stream's row cap (goal 41 T10): the newest 15 rows
+         * of the timeline window — a compact PRIMARY feed, one glance
+         * deep. The full 50-row window stays on the `activity` data.
+         */
+        public const int STREAM_MAX = 15;
+
         /**
          * Loads the dashboard aggregate and shapes its PAGE slot. The
          * `db` session comes from the caller (entrypoint or action scope).
@@ -414,10 +451,55 @@ namespace ClankerSaddle {
                 });
             }
 
+            // The work stream (goal 41 T10, ux-notes bet 5): the SAME
+            // timeline window projected into the feed's three-row
+            // grammar — task progress (progress lines on task-scoped
+            // events), goal narrative (every goal-scoped event: the
+            // goal page's narrative card vocabulary) and log
+            // highlights (everything else worth a line: verdicts,
+            // comments, state changes). One extra query over what
+            // build() already runs: none — the window is loaded once
+            // above and re-projected here, so the join is free and
+            // the two feeds can never disagree about order. Capped at
+            // STREAM_MAX rows (the newest ones); the full window
+            // stays on the activity data for anything that wants it.
+            var stream = new Vector<DashboardStreamRowView>();
+            foreach (var item in activity) {
+                if (stream.length >= DashboardState.STREAM_MAX) {
+                    break;
+                }
+                var row = new DashboardStreamRowView() {
+                    when_display = item.when_display,
+                    at_epoch = item.at_epoch,
+                    text = item.text,
+                    event_kind = item.kind
+                };
+                if (item.goal_id > 0) {
+                    row.kind = "goal_event";
+                    row.actor = item.goal_title;
+                    row.goal_id = item.goal_id;
+                    row.href = @"/goals/$(item.goal_id)";
+                } else if (item.kind == "progress" && item.task_id > 0) {
+                    row.kind = "task_progress";
+                    row.actor = item.task_title;
+                    row.task_id = item.task_id;
+                    row.href = @"/tasks/$(item.task_id)";
+                } else {
+                    row.kind = "log_highlight";
+                    if (item.task_id > 0) {
+                        row.actor = item.task_title;
+                        row.task_id = item.task_id;
+                        row.href = @"/tasks/$(item.task_id)";
+                    }
+                }
+                stream.add(row);
+            }
+
             var props = (PropertyDictionary) GObjectMapping.to_properties(pub);
             props["saddles"] = StateJson.list<DashboardSaddleView>(saddle_rows.to_immutable_buffer());
             props["push_endpoints"] = StateJson.strings(push_fingerprints);
             props["activity"] = StateJson.list<DashboardActivityView>(activity.to_immutable_buffer());
+            props["work_stream"] = StateJson.list<DashboardStreamRowView>(stream.to_immutable_buffer());
             props["active_tasks"] = StateJson.list<DashboardActiveView>(active.to_immutable_buffer());
             props["pinned"] = StateJson.list<DashboardBlockView>(pinned_sorted.to_immutable_buffer());
 

+ 94 - 10
src/entrypoints/ProfileEntrypoint.vala

@@ -3,6 +3,7 @@ using Invercargill.DataStructures;
 using Spry;
 using Spry.Authentication;
 using Statum;
+using ClankerSaddle.Services;
 
 namespace ClankerSaddle {
 
@@ -14,17 +15,40 @@ namespace ClankerSaddle {
         public bool is_admin { get; set; }
         public string created_display { get; set; default = ""; }
         public Statum.Model.ActionDto change_password { get; set; }
+        /** The stored theme slug ("" = the Spry core default). */
+        public string theme { get; set; default = ""; }
+        /** The stored colour mode (auto|light|dark). */
+        public string mode { get; set; default = MODE_DEFAULT; }
+        public Statum.Model.ActionDto set_theme { get; set; }
+        /**
+         * The VAPID public key the browser subscribes with ("" when
+         * push is unavailable) — goal 41 task 3: the bell preference
+         * UI moved from the dashboard's opener card to this page's
+         * Notifications card, riding the SAME dashboard push actions
+         * (PushService untouched).
+         */
+        public string push_vapid_key { get; set; default = ""; }
+        public Statum.Model.ActionDto? push_subscribe { get; set; }
+        public Statum.Model.ActionDto? push_unsubscribe { get; set; }
+        /** Queues a REAL test push to this user (the bell's probe). */
+        public Statum.Model.ActionDto? push_test { get; set; }
     }
 
     /**
      * Entrypoint for /profile: the logged-in user's own account (name,
-     * email, username, role) and the change-own-password form. Open to
+     * email, username, role), the change-own-password form and the
+     * appearance card (goal 41 task 2 — the Spry theme picker). Open to
      * every logged-in user; the store row (not the signed login
-     * snapshot) is read so a renamed account shows its current profile.
+     * snapshot) is read so a renamed account shows its current profile,
+     * and the theme/mode fields mirror the stored preferences so the
+     * picker opens on the choice this device will paint.
      */
     public class ProfileEntrypoint : StatumEntrypoint {
 
         private UserService users_service = Inversion.inject<UserService>();
+        private UserPreferenceService preferences = Inversion.inject<UserPreferenceService>();
+        private PushService push_service = Inversion.inject<PushService>();
+        private InvercargillSql.Orm.OrmSession db = Inversion.inject<InvercargillSql.Orm.OrmSession>();
 
         public override async DirectiveBuilder handle() throws GLib.Error {
             var guard = yield SpryAuth.require_login(directives(), request, users_service);
@@ -41,24 +65,84 @@ namespace ClankerSaddle {
                 return directives().notify("error", "Your account no longer exists.").navigate("/login");
             }
 
+            var slot = yield build_profile_slot(state_service, action_registry,
+                users_service, preferences, me, (!)user, push_service, db);
+            return directives().set(slot);
+        }
+
+        /**
+         * The page's PAGE-scope profile slot. Shared with
+         * {@link SetThemeAction} so the save answers with the REFRESHED
+         * card (the same trick as the dashboard actions): the client
+         * re-renders every binding after a directive chain, and without
+         * this the theme radios would snap back against the stale
+         * pre-save values.
+         */
+        public static async Statum.Slot build_profile_slot(StateService state_service,
+                ActionRegistry action_registry, UserService users_service,
+                UserPreferenceService preferences, CurrentUser me, UserProjection user,
+                PushService? push = null, InvercargillSql.Orm.OrmSession? db = null)
+                throws GLib.Error {
+            // The appearance card's current values: the stored
+            // preferences (missing rows read as the defaults — the
+            // core theme, auto mode).
+            string theme = "";
+            string mode = MODE_DEFAULT;
+            try {
+                theme = yield preferences.get_theme(me.user_id);
+                mode = yield preferences.get_mode(me.user_id);
+            } catch (Error e) {
+                warning(@"[ClankerSaddle] could not read the appearance preferences: $(e.message)");
+            }
+
             var view = new ProfileView() {
-                username = ((!)user).username,
-                email = ((!)user).email,
-                name_display = @"$(((!)user).forename) $(((!)user).surname)".strip(),
-                is_admin = SpryAuth.permission_matches(((!)user).permissions.to_array(),
+                username = user.username,
+                email = user.email,
+                name_display = @"$(user.forename) $(user.surname)".strip(),
+                is_admin = SpryAuth.permission_matches(user.permissions.to_array(),
                     SpryAuth.ADMIN_PERMISSION),
-                created_display = format_stamp(((!)user).created),
-                change_password = action_registry.author<ChangeMyPasswordAction>()
+                created_display = format_stamp(user.created),
+                change_password = action_registry.author<ChangeMyPasswordAction>(),
+                theme = theme,
+                mode = mode,
+                set_theme = action_registry.author<SetThemeAction>()
             };
 
+            // The Notifications card (goal 41 task 3): the same facts
+            // the dashboard's bell strip derives its per-browser verdict
+            // from, so /profile owns the preference UI while the
+            // dashboard keeps only the compact strip. Reuses the
+            // EXISTING dashboard push actions — PushService untouched.
+            // The push service and db session arrive as parameters
+            // (SetThemeAction's scope may carry them; a static method
+            // cannot Inversion.inject — the context stack is per-request).
+            view.push_vapid_key = PushService.vapid_public_key;
+            view.push_subscribe = action_registry.author<PushSubscribeAction>();
+            view.push_unsubscribe = action_registry.author<PushUnsubscribeAction>();
+            view.push_test = action_registry.author<PushTestAction>();
+            string[] fingerprints = new string[0];
+            if (push != null && db != null) {
+                try {
+                    fingerprints = yield ((!)push)
+                        .user_endpoint_fingerprints((!)db, me.user_id);
+                } catch (Error e) {
+                    warning(@"[ClankerSaddle] could not read push endpoints: $(e.message)");
+                }
+            }
+
             var props = (PropertyDictionary) GObjectMapping.to_properties(view);
+            // The fingerprint list is array-shaped — GObjectMapping
+            // cannot serialise arrays as properties (the same
+            // constraint as the dashboard's lists), so it rides the
+            // props dictionary under the SAME key the bell engine
+            // reads on the dashboard slot.
+            props["push_endpoints"] = StateJson.strings(fingerprints);
 
-            var slot = state_service.new_slot(Scope.PAGE, new State() {
+            return state_service.new_slot(Scope.PAGE, new State() {
                 type_name = "profile",
                 public_data = props,
                 private_data = new PropertyDictionary()
             });
-            return directives().set(slot);
         }
     }
 }

+ 71 - 0
src/entrypoints/ProjectsEntrypoint.vala

@@ -1,5 +1,6 @@
 using Invercargill;
 using Invercargill.DataStructures;
+using Invercargill.Expressions;
 using Spry;
 using Spry.Authentication;
 using Statum;
@@ -103,6 +104,23 @@ namespace ClankerSaddle {
         public int64 priority { get; set; }
     }
 
+    /**
+     * A goal row on the project detail page (goal 41 T9): the missing
+     * reverse cross-link from ux-notes — the project's own goals plus
+     * the ones linked to it as co-developed, so "what is being built
+     * here" is answerable from the project page itself.
+     */
+    public class ProjectGoalRowView : Object {
+        public int64 id { get; set; }
+        public string title { get; set; default = ""; }
+        public string status { get; set; default = ""; }
+        /** The status with underscores replaced (matches goal pages). */
+        public string display { get; set; default = ""; }
+        /** "primary" for the project's own goals, else the link's relation. */
+        public string relationship { get; set; default = ""; }
+        public string branch_name { get; set; default = ""; }
+    }
+
     /** A rule attached to the project, with its detach action. */
     public class ProjectRuleRowView : Object {
         public int64 link_id { get; set; }
@@ -191,6 +209,8 @@ namespace ClankerSaddle {
         private UnderstandingService understanding_service = Inversion.inject<UnderstandingService>();
         private RuleService rule_service = Inversion.inject<RuleService>();
         private StackService stack_service = Inversion.inject<StackService>();
+        private GoalService goal_service = Inversion.inject<GoalService>();
+        private InvercargillSql.Orm.OrmSession db = Inversion.inject<InvercargillSql.Orm.OrmSession>();
         private UserService users = Inversion.inject<UserService>();
 
         public override async DirectiveBuilder handle() throws GLib.Error {
@@ -322,6 +342,48 @@ namespace ClankerSaddle {
                 });
             }
 
+            // Goal 41 T9: the goals reverse link (ux-notes navigation).
+            // The project's own goals lead; goals linked as co-developed
+            // follow with their relationship, so the page answers "what
+            // is being built here" without a detour through /goals.
+            // Reference links are deliberately omitted — a reference
+            // repo is context, not work happening in this project.
+            var goal_rows = new Vector<ProjectGoalRowView>();
+            var seen_goals = new Invercargill.DataStructures.HashSet<string>();
+            foreach (var goal in yield goal_service.list_goals()) {
+                if (goal.project_id == project_id) {
+                    seen_goals.add(goal.id.to_string());
+                    goal_rows.add(new ProjectGoalRowView() {
+                        id = goal.id,
+                        title = goal.title,
+                        status = goal.status,
+                        display = goal.status.replace("_", " "),
+                        relationship = "primary",
+                        branch_name = goal.branch_name
+                    });
+                }
+            }
+            foreach (var link in yield db.query<GoalLink>()
+                    .where(expr("project_id == $0", elem<int64?>(project_id)))
+                    .materialise_async()) {
+                if (link.relationship != "co-developed"
+                        || seen_goals.contains(link.goal_id.to_string())) {
+                    continue;
+                }
+                var goal = yield goal_service.get_goal(link.goal_id);
+                if (goal == null) {
+                    continue;
+                }
+                goal_rows.add(new ProjectGoalRowView() {
+                    id = ((!)goal).id,
+                    title = ((!)goal).title,
+                    status = ((!)goal).status,
+                    display = ((!)goal).status.replace("_", " "),
+                    relationship = link.relationship,
+                    branch_name = ((!)goal).branch_name
+                });
+            }
+
             var note_rows = new Vector<ProjectNoteRowView>();
             foreach (var note in yield project_service.list_notes(project_id)) {
                 var note_private = new ProjectNoteActionPrivate() {
@@ -376,6 +438,7 @@ namespace ClankerSaddle {
             props["links"] = StateJson.list<ProjectLinkView>(links.to_immutable_buffer());
             props["linkable"] = StateJson.list<ProjectRowView>(linkable.to_immutable_buffer());
             props["tasks"] = StateJson.list<ProjectTaskRowView>(task_rows.to_immutable_buffer());
+            props["goals"] = StateJson.list<ProjectGoalRowView>(goal_rows.to_immutable_buffer());
             props["rules"] = StateJson.list<ProjectRuleRowView>(rule_rows.to_immutable_buffer());
             props["rule_options"] = StateJson.list<RuleOptionView>(rule_options.to_immutable_buffer());
             props["understanding_current"] = StateJson.element(current_understanding_row);
@@ -390,6 +453,14 @@ namespace ClankerSaddle {
             }
             props["stack_options"] = StateJson.list<StackOptionView>(stack_options.to_immutable_buffer());
             props["notes"] = StateJson.list<ProjectNoteRowView>(note_rows.to_immutable_buffer());
+            // Goal 41 T9: the breadcrumb trail (Dashboard / Projects /
+            // <name>) — the shared partial renders it, replacing the
+            // hand-drawn "All projects" anchor.
+            var crumbs = new Vector<CrumbView>();
+            crumbs.add(new CrumbView() { href = "/", label = "Dashboard" });
+            crumbs.add(new CrumbView() { href = "/projects", label = "Projects" });
+            crumbs.add(new CrumbView() { label = ((!)project).name });
+            props["crumbs"] = StateJson.list<CrumbView>(crumbs.to_immutable_buffer());
 
             var slot = state_service.new_slot(Scope.PAGE, new State() {
                 type_name = "project",

+ 65 - 2
src/entrypoints/RulesEntrypoint.vala

@@ -8,6 +8,20 @@ using ClankerSaddle.Services;
 
 namespace ClankerSaddle {
 
+    /**
+     * One "where this rule is attached" row on the rules page (goal 41
+     * T9): the blast radius of a delete, per ux-notes — the page says
+     * rules attach on project/task pages but you could not see from
+     * here which ones carry them.
+     */
+    public class RuleAttachmentRowView : Object {
+        /** "project" or "task". */
+        public string kind { get; set; default = ""; }
+        public int64 id { get; set; }
+        /** The project or task's name/title. */
+        public string label { get; set; default = ""; }
+    }
+
     /** A rule row on the rules page, with its row actions. */
     public class RuleRowView : Object {
         public int64 id { get; set; }
@@ -16,6 +30,14 @@ namespace ClankerSaddle {
         public string created_display { get; set; default = ""; }
         public Statum.Model.ActionDto? update { get; set; }
         public Statum.Model.ActionDto? delete { get; set; }
+        /**
+         * Goal 41 T9: where the rule is attached (the delete's blast
+         * radius). An array-valued field cannot ride GObjectMapping's
+         * snapshot, so the entrypoint publishes it through
+         * StateJson.list_rich (the goal page's options/capabilities
+         * pattern).
+         */
+        public Vector<RuleAttachmentRowView> attachments { get; set; default = new Vector<RuleAttachmentRowView>(); }
     }
 
     /** Public data of the `rules` slot. */
@@ -32,6 +54,8 @@ namespace ClankerSaddle {
     public class RulesEntrypoint : StatumEntrypoint {
 
         private RuleService rule_service = Inversion.inject<RuleService>();
+        private ProjectService project_service = Inversion.inject<ProjectService>();
+        private TaskService task_service = Inversion.inject<TaskService>();
         private UserService users = Inversion.inject<UserService>();
 
         public override async DirectiveBuilder handle() throws GLib.Error {
@@ -49,7 +73,8 @@ namespace ClankerSaddle {
                     body = rule.body,
                     created_display = format_stamp(rule.created_at),
                     update = action_registry.author_private<UpdateRuleAction, RuleActionPrivate>(sealed),
-                    delete = action_registry.author_private<DeleteRuleAction, RuleActionPrivate>(sealed)
+                    delete = action_registry.author_private<DeleteRuleAction, RuleActionPrivate>(sealed),
+                    attachments = yield attachments_for(rule.id)
                 });
             }
 
@@ -57,7 +82,12 @@ namespace ClankerSaddle {
             pub.create = action_registry.author<CreateRuleAction>();
 
             var props = (PropertyDictionary) GObjectMapping.to_properties(pub);
-            props["rules"] = StateJson.list<RuleRowView>(rows.to_immutable_buffer());
+            props["rules"] = StateJson.list_rich<RuleRowView>(
+                rows.to_immutable_buffer(),
+                (item, dict) => {
+                    dict["attachments"] = StateJson.list<RuleAttachmentRowView>(
+                        ((RuleRowView) item).attachments.to_immutable_buffer());
+                });
 
             var slot = state_service.new_slot(Scope.PAGE, new State() {
                 type_name = "rules",
@@ -66,5 +96,38 @@ namespace ClankerSaddle {
             });
             return directives().set(slot);
         }
+
+        /**
+         * Goal 41 T9: where `rule_id` is attached — every project whose
+         * dispatches carry it and every task with its own copy, each as
+         * a linked row. This is the blast radius the delete confirm
+         * should be able to name.
+         */
+        private async Vector<RuleAttachmentRowView> attachments_for(int64 rule_id) throws GLib.Error {
+            var attached = new Vector<RuleAttachmentRowView>();
+            foreach (var link in yield rule_service.list_project_attachments_by_rule(rule_id)) {
+                var project = yield project_service.get_project(link.project_id);
+                if (project == null) {
+                    continue;
+                }
+                attached.add(new RuleAttachmentRowView() {
+                    kind = "project",
+                    id = link.project_id,
+                    label = ((!)project).name
+                });
+            }
+            foreach (var link in yield rule_service.list_task_attachments_by_rule(rule_id)) {
+                var detail = yield task_service.get_task(link.task_id);
+                if (detail == null) {
+                    continue;
+                }
+                attached.add(new RuleAttachmentRowView() {
+                    kind = "task",
+                    id = link.task_id,
+                    label = ((!)detail).task.title
+                });
+            }
+            return attached;
+        }
     }
 }

+ 65 - 0
src/entrypoints/SaddlesEntrypoint.vala

@@ -28,6 +28,36 @@ namespace ClankerSaddle {
         public Statum.Model.ActionDto? detach { get; set; }
     }
 
+    /**
+     * The one-line health verdict (goal 41 T8, quick win 8): the old
+     * card expressed health as three separate fragments (the badge,
+     * "daemon idle", "up 3d", "contacted 12s ago"); this composes
+     * them into ONE line the card head carries next to the badge.
+     * Pure display — the fragments stay on the row (the health panel
+     * assertions read them).
+     */
+    public string saddle_health_verdict(string health, string daemon_state,
+            string uptime_display, string last_contact) {
+        var parts = new string[] {};
+        if (daemon_state.length > 0) {
+            parts += @"daemon $(daemon_state)";
+        }
+        if (uptime_display.length > 0) {
+            parts += @"up $(uptime_display)";
+        }
+        parts += @"contacted $(last_contact) ago";
+        if (health == "stale") {
+            return string.joinv(", ", parts) + " - silent past the stale window";
+        }
+        if (health == "offline") {
+            return "offline - not responding to probes";
+        }
+        if (health == "unknown" && daemon_state.length == 0) {
+            return "no health report yet - contacted " + last_contact + " ago";
+        }
+        return string.joinv(", ", parts);
+    }
+
     /** A saddle card on the management page, with its current task and API token. */
     public class SaddleRowView : Object {
         public int64 id { get; set; }
@@ -38,6 +68,8 @@ namespace ClankerSaddle {
         public string daemon_state { get; set; default = ""; }
         public string uptime_display { get; set; default = ""; }
         public string last_contact_display { get; set; default = ""; }
+        /** One composed line (goal 41 T8): the fragments above, consolidated. */
+        public string health_verdict { get; set; default = ""; }
         public string git_name { get; set; default = ""; }
         public string git_email { get; set; default = ""; }
         public string ssh_public_key { get; set; default = ""; }
@@ -79,6 +111,14 @@ namespace ClankerSaddle {
         /** True when the viewer may see the admin controls (the actions
          * re-check the permission; this only shapes the page). */
         public bool is_admin { get; set; }
+        /**
+         * The fleet health summary line (goal 41 T8): non-empty when
+         * any saddle is offline/stale/fenced — "2 of 3 saddles stale
+         * (saddle-1, saddle-2) - the dispatcher may be starved". Empty
+         * means every saddle is healthy/unknown and the page shows
+         * nothing above the grid.
+         */
+        public string fleet_alert { get; set; default = ""; }
         public Statum.Model.ActionDto register { get; set; }
         public Statum.Model.ActionDto create_capability { get; set; }
     }
@@ -183,6 +223,8 @@ namespace ClankerSaddle {
                     daemon_state = daemon_state,
                     uptime_display = uptime > 0 ? relative_time(uptime * 1000) : "",
                     last_contact_display = last_contact,
+                    health_verdict = saddle_health_verdict(health, daemon_state,
+                        uptime > 0 ? relative_time(uptime * 1000) : "", last_contact),
                     git_name = saddle.git_name ?? "",
                     git_email = saddle.git_email ?? "",
                     ssh_public_key = saddle.ssh_public_key ?? "",
@@ -234,6 +276,29 @@ namespace ClankerSaddle {
 
             var pub = new SaddlesPublic();
             pub.is_admin = is_admin;
+            // The fleet summary line: offline/stale/fenced saddles
+            // named at the top of the page (quick win 8 B). The row
+            // loop above collected the views; "fenced" never appears
+            // as a health value today (health is healthy/stale/
+            // offline/unknown) but is tolerated so a future state
+            // reads correctly without touching this.
+            var ailing = new Vector<string>();
+            int total = 0;
+            foreach (var row in rows) {
+                total++;
+                if (row.health == "offline" || row.health == "stale"
+                        || row.health == "fenced") {
+                    ailing.add(row.name);
+                }
+            }
+            if (ailing.length > 0) {
+                var names = new string[ailing.length];
+                for (int at = 0; at < ailing.length; at++) {
+                    names[at] = ailing[at];
+                }
+                pub.fleet_alert = @"$(ailing.length) of $(total) saddles $(ailing.length == 1 ? "is" : "are") not healthy"
+                    + " (" + string.joinv(", ", names) + ") - the dispatcher may be starved";
+            }
             pub.register = action_registry.author<RegisterSaddleAction>();
             pub.create_capability = action_registry.author<CreateCapabilityAction>();
 

+ 27 - 2
src/entrypoints/TaskDetailEntrypoint.vala

@@ -482,6 +482,23 @@ namespace ClankerSaddle {
             props["capabilities"] = StateJson.list<TaskCapabilityRowView>(task_capabilities.to_immutable_buffer());
             props["capability_options"] = StateJson.list<CapabilityOptionView>(capability_options.to_immutable_buffer());
             props["sessions"] = StateJson.list<SessionView>(sessions.to_immutable_buffer());
+            // Goal 41 T5: the breadcrumb trail (Tasks -> #id title),
+            // replacing the hand-drawn "All tasks" context line. The
+            // project and enqueue context ride the trail: a
+            // project-owned task nests under its project, a standalone
+            // one sits directly under Tasks.
+            var crumbs = new Vector<CrumbView>();
+            crumbs.add(new CrumbView() { href = "/tasks", label = "Tasks" });
+            if (task.project_id > 0 && project != null) {
+                crumbs.add(new CrumbView() {
+                    href = @"/projects/$(task.project_id)",
+                    label = ((!)project).name
+                });
+            }
+            crumbs.add(new CrumbView() {
+                label = @"#$(task.id) $(task.title)"
+            });
+            props["crumbs"] = StateJson.list<CrumbView>(crumbs.to_immutable_buffer());
             return slot_for(state_service, pub, props);
         }
 
@@ -537,6 +554,13 @@ namespace ClankerSaddle {
                 project_service, saddle_service, rule_service, capability_service,
                 action_registry, poll_state, push_service, event_service, db,
                 me != null ? ((!)me).user_id : 0, task_id);
+            // Goal 41 T7 (structural bet 3): the task page embeds the
+            // event log's shell as its "Log" tab (the task-log-feed
+            // partial); the slot the shell binds hydrates alongside
+            // the task slot — same shape the standalone log page
+            // publishes, one build (TaskLogEntrypoint.log_slot_for).
+            var log_state = yield TaskLogEntrypoint.log_slot_for(
+                state_service, db, task_id);
             // Plan 14 B1: the goal page fully replaces the goal-run
             // task's page — deep links (dashboard, notifications,
             // timeline) redirect to the goal. The task state still
@@ -545,11 +569,12 @@ namespace ClankerSaddle {
             var detail = yield task_service.get_task(task_id);
             if (detail != null && ((!)detail).task.kind == TASK_KIND_GOAL_RUN
                     && ((!)detail).task.goal_id > 0) {
-                return directives().set(state)
+                return directives().set(state).set(log_state)
                     .navigate(@"/goals/$(((!)detail).task.goal_id)")
                     .subscribe_global(StatePulse.SLOT_TYPE);
             }
-            return directives().set(state).subscribe_global(StatePulse.SLOT_TYPE);
+            return directives().set(state).set(log_state)
+                .subscribe_global(StatePulse.SLOT_TYPE);
         }
     }
 }

+ 18 - 2
src/entrypoints/TaskLogEntrypoint.vala

@@ -28,6 +28,11 @@ namespace ClankerSaddle {
      * control pages backwards through GET /tasks/&lt;id&gt;/events in
      * batches of 30. Unauthenticated like the terminal page before
      * it (the task id is the capability).
+     *
+     * Goal 41 T7 (structural bet 3): the same {@link TaskLogPublic}
+     * slot now hydrates the task page's embedded "Log" tab — both
+     * surfaces share {@link log_slot_for} so the shape (and the
+     * task_log.js contract over it) is identical by construction.
      */
     public class TaskLogEntrypoint : StatumEntrypoint {
 
@@ -35,6 +40,17 @@ namespace ClankerSaddle {
 
         public override async DirectiveBuilder handle() throws GLib.Error {
             int64 task_id = int64.parse(request.route("task_id") ?? "0");
+            return directives().set(yield log_slot_for(state_service, db, task_id));
+        }
+
+        /**
+         * Builds the `task_log` PAGE slot for task `task_id`: the
+         * shared shell (the task-log-feed partial) binds exactly
+         * these fields. `exists` is false for a missing id — both
+         * pages render their own not-found note from it.
+         */
+        public static async Statum.Slot log_slot_for(StateService state_service,
+                InvercargillSql.Orm.OrmSession db, int64 task_id) throws GLib.Error {
             var task = yield db.query<TaskEntity>()
                 .where(expr("id == $0", elem<int64?>(task_id)))
                 .first_async();
@@ -48,10 +64,10 @@ namespace ClankerSaddle {
                 pub.task_title = ((!)task).title;
                 pub.task_status = ((!)task).status;
             }
-            return directives().set(state_service.new_slot(Statum.Scope.PAGE, new Statum.State() {
+            return state_service.new_slot(Statum.Scope.PAGE, new Statum.State() {
                 type_name = "task_log",
                 public_data = GObjectMapping.to_properties(pub)
-            }));
+            });
         }
     }
 }

+ 4 - 0
src/main.vala

@@ -52,6 +52,7 @@ int main(string[] args) {
         db.add_migration<ClankerSaddle.Data.Migrations.M0008_AnswerWake>();
         db.add_migration<ClankerSaddle.Data.Migrations.M0009_TurnCapPark>();
         db.add_migration<ClankerSaddle.Data.Migrations.M0010_ModelConfig>();
+        db.add_migration<ClankerSaddle.Data.Migrations.M0011_UserPreferences>();
         db.add_entity<Project>(Project.entity_mapping);
         db.add_entity<ProjectLink>(ProjectLink.entity_mapping);
         db.add_entity<TaskEntity>(TaskEntity.entity_mapping);
@@ -80,6 +81,7 @@ int main(string[] args) {
         db.add_entity<StackProject>(StackProject.entity_mapping);
         db.add_entity<Note>(Note.entity_mapping);
         db.add_entity<ModelConfig>(ModelConfig.entity_mapping);
+        db.add_entity<UserPreference>(UserPreference.entity_mapping);
         db.migrate_on_startup();
 
         application.add_scoped<ProjectService>();
@@ -97,6 +99,7 @@ int main(string[] args) {
         application.add_scoped<AllocationService>();
         application.add_scoped<TimelineService>();
         application.add_scoped<ChatService>();
+        application.add_scoped<UserPreferenceService>();
         application.add_singleton<SaddlePollState>();
         application.add_singleton<AnswerDeliveryHub>();
         application.add_singleton<StatePulse>();
@@ -226,6 +229,7 @@ int main(string[] args) {
         statum.action<DeleteManagedUserAction>();
         statum.action<ChangeMyPasswordAction>();
         statum.action<SaveModelConfigAction>();
+        statum.action<SetThemeAction>();
         // spry:actions-end
 
         // spry:resources-begin

File diff suppressed because it is too large
+ 390 - 742
src/pages/app.css


+ 444 - 2
src/pages/app.js

@@ -120,7 +120,7 @@
             actions.className = 'actions';
             var add = document.createElement('button');
             add.type = 'button';
-            add.className = 'primary';
+            add.className = 'btn-primary btn-sm';
             add.textContent = 'Add remark';
             add.addEventListener('click', function () {
                 if (body.value.trim().length === 0) return;
@@ -208,7 +208,7 @@
                     entry.appendChild(body);
                     var remove = document.createElement('button');
                     remove.type = 'button';
-                    remove.className = 'inline';
+                    remove.className = 'btn-ghost btn-sm';
                     remove.setAttribute('data-plan-remove', '');
                     remove.textContent = 'Remove';
                     entry.appendChild(remove);
@@ -326,6 +326,149 @@
         statum.afterEvaluate(applyPrefills);
     };
 
+    //
+    // Anchor sub-nav engine (goal 41 T5 shipped the chassis on the
+    // task page, T6 the closest-to-fold selection rule on the goal
+    // page; T11 unifies them into this ONE engine both pages run).
+    //
+    // Markup contract (docs/spry-conversion.md):
+    //   <nav class="subnav" data-subnav aria-label="Sections">
+    //     <a href="#section-id">…</a>…
+    //   </nav>
+    //   <section id="section-id" data-subnav-section="section-id">…
+    //
+    // The engine owns: (a) smooth in-page jumps that stop below the
+    // sticky chrome (topbar + strip, measured at click time), (b)
+    // highlighting whichever region the reader is actually in — the
+    // section whose top sits CLOSEST above the strip's bottom edge
+    // (ties to the later DOM node); the single-column "last heading
+    // that crossed the line" reading T5's IntersectionObserver
+    // implemented is the same outcome, and the closest-to-fold rule
+    // stays correct in the two-column Act|Consult grid where an
+    // observer can light a consult card while the reader looks at an
+    // act one, (c) the strip's .is-stuck class once the page scrolls
+    // it under the topbar, and (d) publishing the measured topbar
+    // height as --topbar-bottom (the sticky log toolbar consumes the
+    // same property). Listeners bind ONCE on document/window — statum
+    // re-renders replace the DOM but never static markup's listeners,
+    // and the bind flag keeps a second init call from stacking them;
+    // statum.afterEvaluate re-runs the sync pass so the strip
+    // re-derives its highlight and stuck state after every render.
+    //
+    window.initSubnav = function () {
+        if (!window.statum || !statum.afterEvaluate) return;
+        var FLAG = 'data-subnav-engine';
+        var root = document.documentElement;
+        if (root.getAttribute(FLAG) === 'bound') return; // already live
+        root.setAttribute(FLAG, 'bound');
+
+        function strips() {
+            return Array.prototype.slice.call(
+                document.querySelectorAll('[data-subnav]'));
+        }
+
+        function sections() {
+            return Array.prototype.slice.call(
+                document.querySelectorAll('[data-subnav-section]'));
+        }
+
+        // The strip parks under the sticky topbar; publish the
+        // measured topbar height as a custom property so app.css's
+        // `top:` tracks any wrap/viewport.
+        function syncTopbarOffset() {
+            var bar = document.querySelector('.topbar');
+            if (bar && bar.offsetHeight > 0) {
+                root.style.setProperty('--topbar-bottom',
+                    bar.offsetHeight + 'px');
+            }
+        }
+
+        // How much sticky chrome sits above a scrolled-to heading:
+        // the topbar plus the strip itself (so the heading the
+        // reader jumped to is not swallowed by either).
+        function jumpOffset(strip) {
+            var height = 0;
+            var bar = document.querySelector('.topbar');
+            if (bar) height += bar.offsetHeight;
+            if (strip) height += strip.offsetHeight + 8;
+            return height;
+        }
+
+        function highlight(strip, id) {
+            var links = strip.querySelectorAll('a[href^="#"]');
+            for (var i = 0; i < links.length; i++) {
+                var active = links[i].getAttribute('href') === '#' + id;
+                links[i].classList.toggle('active', active);
+                if (active) {
+                    links[i].setAttribute('aria-current', 'true');
+                } else {
+                    links[i].removeAttribute('aria-current');
+                }
+            }
+        }
+
+        // The section the reader is in: the one whose top sits
+        // closest above the strip's bottom edge (just below it).
+        // Sections still below that line have not been entered;
+        // before the first one crosses, nothing is current.
+        function currentSection(strip) {
+            var mark = strip.getBoundingClientRect().bottom + 24;
+            var current = null;
+            var best = Infinity;
+            var all = sections();
+            for (var i = 0; i < all.length; i++) {
+                var distance = all[i].getBoundingClientRect().top - mark;
+                if (distance > 0) continue;  // still below the fold line
+                if (-distance <= best) {
+                    best = -distance;
+                    current = all[i];
+                }
+            }
+            return current;
+        }
+
+        function sync() {
+            strips().forEach(function (strip) {
+                syncTopbarOffset();
+                // The strip sticks once the page scrolls it under
+                // the topbar: the class carries the opaque surface
+                // and edge the un-stuck strip must not have.
+                var bar = document.querySelector('.topbar');
+                var barBottom = bar ? bar.getBoundingClientRect().bottom : 0;
+                strip.classList.toggle('is-stuck',
+                    strip.getBoundingClientRect().top <= barBottom);
+                var region = currentSection(strip);
+                highlight(strip,
+                    region ? region.getAttribute('data-subnav-section') : null);
+            });
+        }
+
+        document.addEventListener('click', function (event) {
+            if (!event.target.closest) return;
+            var link = event.target.closest('[data-subnav] a[href^="#"]');
+            if (!link) return;
+            var region = document.getElementById(
+                link.getAttribute('href').slice(1));
+            if (!region) return;
+            event.preventDefault();
+            var strip = link.closest('[data-subnav]');
+            window.scrollTo({
+                top: region.getBoundingClientRect().top
+                    + window.pageYOffset - jumpOffset(strip),
+                behavior: 'smooth'
+            });
+            if (history.replaceState) {
+                history.replaceState(null, '',
+                    link.getAttribute('href'));
+            }
+        });
+
+        window.addEventListener('scroll', sync, { passive: true });
+        window.addEventListener('resize', sync, { passive: true });
+        statum.afterEvaluate(sync);
+        sync();
+    };
+
     if (window.statum) {
             (function () {
                 'use strict';
@@ -351,6 +494,227 @@
                     showToast(message || kind, kind);
                 };
 
+                //
+                // Web Push bell engine (goal 41 task 3; extracted from
+                // the dashboard's inline script so the /profile settings
+                // card and the dashboard's compact rail strip run the
+                // SAME per-browser verdict logic): the page state's
+                // endpoint FINGERPRINTS are matched against this
+                // browser's own pushManager subscription (hashed the
+                // same way), so each browser shows its own truth —
+                // subscribed here ("Turn off") vs subscribed elsewhere
+                // only ("Enable" + a count). The client-side check is
+                // authoritative; the server flag is a convenience for
+                // other consumers.
+                //
+                window.initPushBell = function (config) {
+                    var stateType = (config && config.stateType) || 'dashboard';
+
+                    function state() {
+                        return (window.statum && statum.read(stateType)) || {};
+                    }
+
+                    function fail(error) {
+                        showToast('Notifications: '
+                            + (error && error.message ? error.message : error), 'error');
+                    }
+
+                    function vapidKeyBytes(base64Url) {
+                        var padding = '='.repeat((4 - base64Url.length % 4) % 4);
+                        var base64 = (base64Url + padding).replace(/-/g, '+')
+                            .replace(/_/g, '/');
+                        var raw = atob(base64);
+                        var bytes = new Uint8Array(raw.length);
+                        for (var index = 0; index < raw.length; index++) {
+                            bytes[index] = raw.charCodeAt(index);
+                        }
+                        return bytes;
+                    }
+
+                    function enablePush() {
+                        var page = state();
+                        if (!page.push_vapid_key) {
+                            fail('not available on this server');
+                            return;
+                        }
+                        if (!('serviceWorker' in navigator) || !('PushManager' in window)) {
+                            fail('this browser has no Web Push support');
+                            return;
+                        }
+                        Notification.requestPermission().then(function (permission) {
+                            if (permission !== 'granted') {
+                                fail('permission denied in the browser');
+                                return;
+                            }
+                            // The service worker lives at the origin root
+                            // (GET /sw.js, Service-Worker-Allowed: /), so
+                            // its scope covers every page.
+                            return navigator.serviceWorker.register('/sw.js', { scope: '/' })
+                                .then(function () { return navigator.serviceWorker.ready; })
+                                .then(function (registration) {
+                                    return registration.pushManager
+                                        .getSubscription().then(function (existing) {
+                                            if (existing) return existing;
+                                            return registration.pushManager.subscribe({
+                                                userVisibleOnly: true,
+                                                applicationServerKey:
+                                                    vapidKeyBytes(page.push_vapid_key)
+                                            });
+                                        });
+                                })
+                                .then(function (subscription) {
+                                    var keys = subscription.toJSON().keys || {};
+                                    return statum.act(page.push_subscribe, {
+                                        endpoint: subscription.endpoint,
+                                        p256dh: keys.p256dh,
+                                        auth: keys.auth
+                                    });
+                                })
+                                .catch(function (error) { fail(error); });
+                        });
+                    }
+
+                    function disablePush() {
+                        var page = state();
+                        navigator.serviceWorker.getRegistration('/')
+                            .then(function (registration) {
+                                if (!registration) return null;
+                                return registration.pushManager.getSubscription();
+                            }).then(function (subscription) {
+                                var done = subscription
+                                    ? subscription.unsubscribe() : Promise.resolve(true);
+                                return done.then(function () {
+                                    if (subscription && page.push_unsubscribe) {
+                                        return statum.act(page.push_unsubscribe, {
+                                            endpoint: subscription.endpoint
+                                        });
+                                    }
+                                });
+                            }).catch(function (error) { fail(error); });
+                    }
+
+                    function sendTest() {
+                        var page = state();
+                        if (!page.push_test) {
+                            fail('not available on this server');
+                            return;
+                        }
+                        statum.act(page.push_test, {})
+                            .catch(function (error) { fail(error); });
+                    }
+
+                    var browserSubscribed = null; // null = still asking the browser
+                    var hereNow = false;         // the displayed verdict (a click acts on it)
+
+                    function sha256Hex16(text) {
+                        // Mirrors PushService.endpoint_fingerprint: the
+                        // first 8 bytes of the SHA-256, hex.
+                        // crypto.subtle needs a secure context, exactly
+                        // like pushManager itself.
+                        var data = new TextEncoder().encode(text);
+                        return crypto.subtle.digest('SHA-256', data)
+                            .then(function (digest) {
+                                var bytes = new Uint8Array(digest).slice(0, 8);
+                                var hex = '';
+                                for (var index = 0; index < bytes.length; index++) {
+                                    hex += ('0' + bytes[index].toString(16)).slice(-2);
+                                }
+                                return hex;
+                            });
+                    }
+
+                    function myFingerprint() {
+                        if (!('serviceWorker' in navigator)
+                                || !('PushManager' in window)) {
+                            return Promise.resolve(null);
+                        }
+                        return navigator.serviceWorker.getRegistration('/')
+                            .then(function (registration) {
+                                if (!registration) return null;
+                                return registration.pushManager
+                                    .getSubscription().then(function (sub) {
+                                        return sub ? sha256Hex16(sub.endpoint) : null;
+                                    });
+                            }).catch(function () { return null; });
+                    }
+
+                    function otherBrowserCount() {
+                        // The state's fingerprints minus this browser's own.
+                        var mine = browserSubscribed;
+                        var list = state().push_endpoints || [];
+                        if (mine === null || mine === undefined) return list.length;
+                        var others = 0;
+                        for (var index = 0; index < list.length; index++) {
+                            if (list[index] !== mine) others++;
+                        }
+                        return others;
+                    }
+
+                    function refreshBell() {
+                        return myFingerprint().then(function (mine) {
+                            browserSubscribed = mine;
+                            var list = state().push_endpoints || [];
+                            // The page's .push-bell-card is the live one
+                            // (the dashboard's rail strip or /profile's
+                            // settings card — one per page).
+                            var card = document.querySelector('.push-bell-card');
+                            if (!card) return;
+                            var here = mine !== null && list.indexOf(mine) !== -1;
+                            var others = otherBrowserCount();
+                            var onBtn = card.querySelector('[data-bell-on]');
+                            var offBtn = card.querySelector('[data-bell-off]');
+                            var help = card.querySelector('[data-bell-help]');
+                            var testBtn = card.querySelector('[data-push-test]');
+                            hereNow = here;
+                            if (onBtn) onBtn.hidden = here;
+                            if (offBtn) offBtn.hidden = !here;
+                            if (testBtn) testBtn.hidden = !here;
+                            if (help) {
+                                if (here) {
+                                    help.textContent = others > 0
+                                        ? '\u2014 this browser receives question, RFC, review and waiting-on-drafts alerts; '
+                                          + others + ' other browser(s) are also subscribed; "Notify me" per task adds its start, failure and completion'
+                                        : '\u2014 this browser receives question, RFC, review and waiting-on-drafts alerts; "Notify me" per task adds its start, failure and completion';
+                                } else if (list.length > 0) {
+                                    help.textContent = '\u2014 you are subscribed on '
+                                        + list.length
+                                        + ' other browser(s); enable here too to be notified in this browser';
+                                } else {
+                                    help.textContent = '\u2014 be told when an agent asks a question, raises an RFC or review';
+                                }
+                            }
+                        });
+                    }
+
+                    // The page state refreshes on every pulse; the
+                    // bell's per-browser verdict must be re-derived each
+                    // time (the browser's own subscription may have
+                    // changed under us).
+                    statum.addStateListener(stateType, refreshBell);
+                    refreshBell();
+
+                    document.addEventListener('click', function (event) {
+                        if (!event.target.closest) return;
+                        var card = event.target.closest('.push-bell-card');
+                        if (!card) return;
+                        var bell = event.target.closest('[data-push-bell]');
+                        var test = event.target.closest('[data-push-test]');
+                        if (bell) {
+                            event.preventDefault();
+                            // Follow the DISPLAYED verdict: a browser
+                            // holding a subscription the server no longer
+                            // knows (a pruned row) shows Enable — and
+                            // enabling re-registers the existing
+                            // subscription, healing it.
+                            if (hereNow) disablePush();
+                            else enablePush();
+                        } else if (test) {
+                            event.preventDefault();
+                            sendTest();
+                        }
+                    });
+                };
+
                 var quoteHandlers = [];
                 var quoteButton = null;
                 var quotePopover = null;
@@ -426,3 +790,81 @@
                 }
             })();
         }
+
+        //
+        // Theme hook (goal 41 task 2): the DEVICE-scope "ui" slot carries
+        // the account's saved appearance ({theme, mode}). The seven theme
+        // stylesheets main.html links stay inert until <html> carries
+        // data-theme, and data-mode picks light/dark/auto - so applying a
+        // theme is purely attribute work on documentElement. Runs on statum
+        // init and after every render cycle, so a saved choice paints the
+        // first page and any later change applies immediately.
+        //
+        window.applyUiSlot = function () {
+            if (!window.statum || !statum.read) return;
+            var ui = statum.read('ui');
+            var root = document.documentElement;
+            if (ui && ui.theme) {
+                root.setAttribute('data-theme', ui.theme);
+            } else {
+                root.removeAttribute('data-theme');
+            }
+            if (ui && ui.mode && ui.mode !== 'auto') {
+                root.setAttribute('data-mode', ui.mode);
+            } else {
+                root.setAttribute('data-mode', 'auto');
+            }
+        };
+
+        if (window.statum) {
+            statum.afterEvaluate(window.applyUiSlot);
+            window.applyUiSlot();
+        }
+
+        //
+        // App dialog helper (goal 41 T4). The create forms on the
+        // tasks/goals/projects/stacks lists live in native <dialog>
+        // modals styled with Spry's dialog classes; this helper owns
+        // opening them and closing them when their stm-action form
+        // gets an answer.
+        //
+        // Verified against js/statum.js (asserted in the T4 bundle):
+        // a re-render (pulse or set directive) never rebuilds static
+        // markup — only stm-for loop instances are cloned — so an
+        // open dialog KEEPS its open state and typed values across
+        // re-renders. Nothing in statum closes or resets a form
+        // after an action: the server's `notify` directive is the
+        // success/failure signal (kind success vs error), and
+        // Clanker's create actions ALWAYS notify on both paths. So
+        // the helper wraps statum.onNotifyHandler and closes the top
+        // open dialog on ANY notify while one is open: a success
+        // closes the modal; an error closes it too (the toast
+        // carries the message — a failed create with a stuck-open
+        // modal is worse than a closed one whose field values are
+        // still there when it is reopened). A `navigate` response
+        // tears the whole page down, dialog included.
+        //
+        window.openAppDialog = function (id) {
+            var dialog = document.getElementById(id);
+            if (!dialog) return;
+            var previousNotify = statum.onNotifyHandler;
+            statum.onNotifyHandler = function (kind, message) {
+                // Restore the app toast handler, then close.
+                statum.onNotifyHandler = previousNotify === null
+                    ? null : previousNotify;
+                if (dialog.open) dialog.close();
+                if (statum.onNotifyHandler) {
+                    statum.onNotifyHandler(kind, message);
+                }
+            };
+            if (typeof dialog.showModal === 'function') {
+                dialog.showModal();
+            } else {
+                dialog.setAttribute('open', '');
+            }
+        };
+
+        window.closeAppDialog = function (id) {
+            var dialog = document.getElementById(id);
+            if (dialog && dialog.open) dialog.close();
+        };

+ 1 - 1
src/pages/block_answer.html

@@ -23,7 +23,7 @@
                 <textarea name="other" rows="2"></textarea>
             </label>
         </div>
-        <button type="submit" class="primary">Answer</button>
+        <button type="submit" class="btn-primary">Answer</button>
     </form>
 </body>
 </html>

+ 2 - 2
src/pages/block_reply.html

@@ -9,9 +9,9 @@
 </head>
 <body>
     <form stm-if="!block.answered && block.block_type != 'question' && block.block_type != 'review'"
-          stm-action="block.answer_action" class="inline">
+          stm-action="block.answer_action" class="cluster">
         <textarea name="answer" rows="3" placeholder="Your reply (delivered to the agent)"></textarea>
-        <button type="submit" class="primary">Reply</button>
+        <button type="submit" class="btn-primary">Reply</button>
     </form>
 </body>
 </html>

+ 20 - 0
src/pages/breadcrumb.html

@@ -0,0 +1,20 @@
+<!DOCTYPE html>
+<html lang="en">
+<head>
+    <!-- A partial (goal 41 T1): the Spry breadcrumb for detail pages —
+         Tasks → #42, Goals → #7 → Tasks… Each page supplies a
+         `crumbs` array of {href?, label} items; trailing crumb gets
+         aria-current="page" (Spry's .breadcrumb styles it). Adoption
+         by detail pages happens in T5/T6/T9, each page's entrypoint
+         exposing the crumbs vector. -->
+    <pstm-name>breadcrumb</pstm-name>
+</head>
+<body>
+    <pstm-input name="crumbs"/>
+    <nav aria-label="Breadcrumb">
+        <ol class="breadcrumb">
+            <li stm-for-item-in="crumbs"><a stm-if="item.href != null" stm-attribute.href="item.href" stm-text="item.label">label</a><span stm-if="item.href == null" stm-attribute.aria-current="'page'" stm-text="item.label">label</span></li>
+        </ol>
+    </nav>
+</body>
+</html>

+ 29 - 22
src/pages/chat_detail.html

@@ -126,21 +126,28 @@
 
         <div stm-if="chat != null && chat.id == 0">
             <h2>Chat not found</h2>
-            <p class="muted">That chat does not exist, or belongs to another user -
+            <p class="text-muted">That chat does not exist, or belongs to another user -
                 chats are private. <a href="/chats">Back to your chats.</a></p>
         </div>
 
         <div stm-if="chat != null && chat.id > 0" class="chat-page">
             <div class="chat-head">
+                <!-- Goal 41 T9: the breadcrumb trail (Dashboard / Chats /
+                     <title>) - the partial is T1's, the crumbs vector the
+                     entrypoint's, replacing the hand-drawn back link. -->
+                <pstm-fragment name="breadcrumb" crumbs="chat.crumbs"/>
                 <h2 stm-text="chat.title">title</h2>
-                <p><span stm-attribute.class="'badge badge-chat-' + chat.status"
+                <p class="cluster"><span stm-attribute.class="'badge badge-chat-' + chat.status"
                          stm-text="chat.status_display">status</span>
-                    <span class="muted">advisor chat</span>
-                    <form class="inline">
-                        <button type="button" class="chat-delete" stm-action="chat.delete"
+                    <span class="text-muted">advisor chat</span>
+                    <form class="cluster">
+                        <!-- Goal 41 T9: delete is destructive - ghost it
+                             out of the header's face (the confirm still
+                             guards it). -->
+                        <button type="button" class="btn-ghost btn-sm chat-delete" stm-action="chat.delete"
                                 stm-confirm="Delete this chat? Its messages and suggestions go with it.">Delete chat</button>
                     </form></p>
-                <p class="notice" stm-if="!chat.advisor_configured">The advisor is not
+                <p class="alert alert-warning" stm-if="!chat.advisor_configured">The advisor is not
                     configured - set the model endpoint on the <a href="/models">Admin
                     → Model</a> page to enable it. Your messages wait
                     until then.</p>
@@ -157,15 +164,15 @@
                         <details>
                             <summary><span class="badge badge-tool">tool</span>
                                 <code stm-text="message.tool_name">name</code></summary>
-                            <p class="muted">arguments</p>
+                            <p class="text-muted">arguments</p>
                             <pre stm-text="message.arguments">args</pre>
-                            <p class="muted">result</p>
+                            <p class="text-muted">result</p>
                             <pre stm-text="message.result">result</pre>
                         </details>
                     </div>
                     <div stm-if="message.kind == 'user'" class="chat-bubble chat-user">
                         <div class="md" stm-markdown="message.content">message</div>
-                        <p class="muted" stm-text="message.when_display">when</p>
+                        <p class="text-muted" stm-text="message.when_display">when</p>
                     </div>
                     <!-- A tool-calling assistant row renders NO bubble
                          of its own when it said nothing worth reading
@@ -176,7 +183,7 @@
                     <div stm-if="message.kind == 'assistant' && !message.is_question && !message.bubble_hidden"
                          class="chat-bubble chat-assistant">
                         <div class="md" stm-markdown="message.content">message</div>
-                        <p class="muted" stm-text="message.when_display">when</p>
+                        <p class="text-muted" stm-text="message.when_display">when</p>
                     </div>
                     <div stm-if="message.kind == 'assistant' && message.is_question"
                          class="chat-question">
@@ -186,35 +193,35 @@
                         <div stm-if="!message.answered && chat.status == 'awaiting_answer'"
                              class="card chat-question-card"
                              stm-attribute.data-message-id="message.id">
-                            <p stm-if="message.options.length > 0" class="muted">Choose an
+                            <p stm-if="message.options.length > 0" class="text-muted">Choose an
                                 answer, or type your own below.</p>
                             <div class="chat-options" stm-if="message.options.length > 0">
                                 <button type="button" stm-for-option-in="message.options"
                                         stm-key="option" stm-text="option">option</button>
                             </div>
-                            <form class="inline"
+                            <form class="cluster"
                                   onsubmit="window.submitAdvisorOther(this); return false;">
                                 <input type="text" name="other"
                                        placeholder="Or type your own answer">
                                 <button type="submit">Answer</button>
                             </form>
                         </div>
-                        <p stm-if="message.answered" class="muted">You answered:
+                        <p stm-if="message.answered" class="text-muted">You answered:
                             <strong stm-text="message.answer">answer</strong></p>
                     </div>
                     <div stm-if="message.kind == 'error'" class="chat-error">
                         <p stm-text="message.content">error</p>
                     </div>
                     <div stm-if="message.kind == 'system'" class="chat-system">
-                        <p class="muted" stm-text="message.content">system</p>
+                        <p class="text-muted" stm-text="message.content">system</p>
                     </div>
                 </div>
 
                 <div stm-if="chat.status == 'running'" class="chat-thinking">
-                    <p class="muted">thinking…</p>
+                    <p class="text-muted">thinking…</p>
                 </div>
 
-                <p stm-if="chat.messages.length == 0" class="muted">Nothing yet - say
+                <p stm-if="chat.messages.length == 0" class="text-muted">Nothing yet - say
                     something below.</p>
             </div>
 
@@ -227,22 +234,22 @@
                              stm-text="suggestion.kind">task</span>
                         <span stm-attribute.class="'badge badge-suggestion-' + suggestion.status"
                               stm-text="suggestion.status">pending</span>
-                        <span class="muted" stm-text="suggestion.created_display">when</span></p>
+                        <span class="text-muted" stm-text="suggestion.created_display">when</span></p>
                     <p><strong stm-text="suggestion.title">title</strong></p>
                     <div class="md" stm-markdown="suggestion.body">body</div>
-                    <p class="muted" stm-if="suggestion.kind == 'task'">
+                    <p class="text-muted" stm-if="suggestion.kind == 'task'">
                         mode <span stm-text="suggestion.mode.length > 0 ? suggestion.mode : 'implement'">mode</span>
                         - priority <span stm-text="suggestion.priority">5</span>
                         <span stm-if="suggestion.project_id > 0"> - project
                             <span stm-text="suggestion.project_id">id</span></span></p>
-                    <p class="muted" stm-if="suggestion.kind == 'goal'">
+                    <p class="text-muted" stm-if="suggestion.kind == 'goal'">
                         <span stm-if="suggestion.branch_name.length > 0">branch
                             <code stm-text="suggestion.branch_name">branch</code> - </span>
                         <span stm-if="suggestion.project_id > 0">project
                             <span stm-text="suggestion.project_id">id</span> - </span>
                         created as an unqueued draft</p>
                     <div stm-if="suggestion.status == 'pending'" class="chat-suggestion-actions">
-                        <button type="button" class="primary" data-decision="approve">Approve</button>
+                        <button type="button" class="btn-primary" data-decision="btn-primary">Approve</button>
                         <button type="button" data-decision="approve_enqueue">Approve + enqueue</button>
                         <button type="button" data-decision="decline">Decline</button>
                     </div>
@@ -259,11 +266,11 @@
                 <textarea id="chat-composer" rows="3"
                           stm-attribute.disabled="chat.status == 'running'"
                           placeholder="Reply to the advisor..."></textarea>
-                <button type="button" class="primary"
+                <button type="button" class="btn-primary"
                         stm-attribute.disabled="chat.status == 'running'"
                         onclick="window.sendAdvisorMessage()">Send</button>
             </div>
-            <p class="muted" stm-if="chat.status == 'awaiting_answer'">The advisor is
+            <p class="text-muted" stm-if="chat.status == 'awaiting_answer'">The advisor is
                 waiting for your answer above.</p>
         </div>
     </section>

+ 14 - 7
src/pages/chats.html

@@ -7,11 +7,11 @@
 <body>
     <section>
         <h2>Chats</h2>
-        <p class="muted">Your advisor conversations. Ask the advisor on the
+        <p class="text-muted">Your advisor conversations. Ask the advisor on the
             <a href="/">dashboard</a> to start a new one - it can read the fleet's
             state, ask you questions and suggest tasks or goals, but never runs
             commands itself.</p>
-        <p class="notice" stm-if="chats != null && !chats.advisor_configured">The
+        <p class="alert alert-warning" stm-if="chats != null && !chats.advisor_configured">The
             advisor is not configured - set the model endpoint on the <a href="/models">Admin
             → Model</a> page to enable it. Chats you start now
             will wait until then.</p>
@@ -19,7 +19,9 @@
         <p stm-if="chats == null">Loading…</p>
 
         <div stm-if="chats != null">
-            <table stm-if="chats.chats.length > 0">
+            <div class="table-wrap">
+                <table stm-if="chats.chats.length >
+ 0">
                 <thead><tr><th>Chat</th><th>Status</th><th>Created</th><th>Updated</th><th></th></tr></thead>
                 <tbody>
                     <tr stm-for-chat-in="chats.chats" stm-key="chat.id">
@@ -30,15 +32,20 @@
                         <td stm-text="chat.created_display">when</td>
                         <td stm-text="chat.updated_display">when</td>
                         <td>
-                            <form class="inline">
-                                <button type="button" stm-action="chat.delete"
+                            <form class="cluster">
+                                <!-- Goal 41 T9: a destructive action should not
+                                     be the row's loudest control - ghost it
+                                     (the confirm still guards it). -->
+                                <button type="button" class="btn-ghost btn-sm" stm-action="chat.delete"
                                         stm-confirm="Delete this chat? Its messages and suggestions go with it.">Delete</button>
                             </form>
                         </td>
                     </tr>
                 </tbody>
-            </table>
-            <p stm-if="chats.chats.length == 0" class="muted">No chats yet - type a
+            
+                </table>
+            </div>
+            <p stm-if="chats.chats.length == 0" class="text-muted">No chats yet - type a
                 question into the "Ask the advisor" box at the top of the
                 <a href="/">dashboard</a> to start one.</p>
         </div>

+ 41 - 21
src/pages/discoveries.html

@@ -37,15 +37,15 @@
 <body>
     <section>
         <h2>Discoveries</h2>
-        <p class="muted">The fleet's editable knowledge base of non-project facts — compiler
+        <p class="text-muted">The fleet's editable knowledge base of non-project facts — compiler
             quirks, library behaviours, tool flags, protocol gotchas. Agents search this
             before working with a technology and refine notes in place; you can do the same.</p>
 
         <p stm-if="discoveries == null">Loading…</p>
 
         <div stm-if="discoveries != null">
-            <form class="inline" onsubmit="window.setDiscoveryQuery(document.getElementById('discovery-q').value); return false;">
-                <div class="form-row"><label for="discovery-q">Search</label>
+            <form class="cluster" onsubmit="window.setDiscoveryQuery(document.getElementById('discovery-q').value); return false;">
+                <div class="field"><label for="discovery-q">Search</label>
                     <input type="search" id="discovery-q"
                            stm-attribute.value="discoveries.query"
                            placeholder="meson, valac, sqlite…">
@@ -54,28 +54,48 @@
 
             <div stm-for-discovery-in="discoveries.discoveries" stm-key="discovery.id"
                  stm-if="window.discoveryRowVisible(discovery)" class="card">
-                <div class="md" stm-markdown="discovery.body">body</div>
-                <p class="muted">Created <span stm-text="discovery.created_display">when</span><span
-                        stm-if="discovery.updated_display != ''"> · Updated <span
-                        stm-text="discovery.updated_display">when</span></span></p>
-                <details>
-                    <summary>Edit</summary>
-                    <form stm-action="discovery.update">
-                        <input type="hidden" name="discovery_id" stm-attribute.value="discovery.id">
-                        <div class="form-row"><label>Body</label>
-                            <textarea name="body" rows="6" stm-attribute.value="discovery.body"></textarea></div>
-                        <button type="submit">Save</button>
-                    </form>
-                </details>
-                <form class="inline">
-                    <button type="button" stm-action="discovery.delete"
+                <!-- Goal 41 T9 (ux-notes expander fatigue): the Edit
+                     expander became the in-place editor (the
+                     data-long-form engine in app.js). The editors live
+                     INSIDE a stm-for loop, so no slot path can address
+                     a row: the textarea prefills from stm-text (text
+                     content pre-fills a pristine textarea; the page
+                     re-renders only on its own actions) and the lf
+                     engine's prefill pass finds no data-lf-src controls
+                     and leaves it as rendered. -->
+                <div data-long-form data-field="discovery-row">
+                    <div class="card-header cluster cluster-between">
+                        <p class="text-muted">Discovery <span stm-text="discovery.id">#</span></p>
+                        <button type="button" class="btn-ghost btn-sm" data-lf-toggle>Edit</button>
+                    </div>
+                    <div class="card-body" data-lf-view>
+                        <div class="md" stm-markdown="discovery.body">body</div>
+                        <p class="text-muted">Created <span stm-text="discovery.created_display">when</span><span
+                                stm-if="discovery.updated_display != ''"> · Updated <span
+                                stm-text="discovery.updated_display">when</span></span></p>
+                    </div>
+                    <div class="card-body" data-lf-edit hidden>
+                        <form stm-action="discovery.update" class="form-rows">
+                            <input type="hidden" name="discovery_id" stm-attribute.value="discovery.id">
+                            <div class="field"><label>Body</label>
+                                <textarea name="body" rows="6"
+                                          stm-text="discovery.body">body</textarea></div>
+                            <div class="cluster">
+                                <button type="submit" class="btn-primary">Save</button>
+                                <button type="button" class="btn-ghost" data-lf-toggle>Cancel</button>
+                            </div>
+                        </form>
+                    </div>
+                </div>
+                <div class="card-body">
+                    <button type="button" class="btn-ghost btn-sm" stm-action="discovery.delete"
                             stm-confirm="Delete this discovery?">Delete</button>
-                </form>
+                </div>
             </div>
-            <p stm-if="discoveries.discoveries.length == 0" class="muted">
+            <p stm-if="discoveries.discoveries.length == 0" class="text-muted">
                 No discoveries yet — the knowledge base starts empty and grows as agents
                 record what they learn.</p>
-            <p stm-if="discoveries.more > 0" class="muted">… and <span
+            <p stm-if="discoveries.more > 0" class="text-muted">… and <span
                     stm-text="discoveries.more">n</span> more (narrow the search).</p>
         </div>
     </section>

+ 1 - 1
src/pages/goal_branches.html

@@ -17,6 +17,6 @@
         <span stm-attribute.class="branch.pushed ? 'badge badge-done' : 'badge badge-failed'"
               stm-text="branch.pushed ? 'pushed' : 'unpushed'">state</span>
     </span>
-    <span class="muted" stm-if="note != null" stm-text="note">note</span>
+    <span class="text-muted" stm-if="note != null" stm-text="note">note</span>
 </body>
 </html>

+ 317 - 193
src/pages/goal_detail.html

@@ -4,13 +4,18 @@
     <pstm-uri>/goals/{goal_id}</pstm-uri>
     <pstm-template>main</pstm-template>
 <script>
-    // The goal page: supervision surface of one goal - status head
-    // with lifecycle actions (plus run-control parity: retry
-    // supervision, requeue-to-resume), branch/push-state chips, one
-    // muted activity line, the agent's live progress feed, the ATTENTION
-    // blocks (the goal agent's answerable questions/RFCs and the
-    // completion review), the task status strip with per-row
-    // capability badges, the locked brief, the goal's effective
+    // The goal page (goal 41 T6): the state strip leads - the
+    // one-glance verdict (status badge, the open-item line, the
+    // primary lifecycle button) - then the anchor sub-nav, then the
+    // Act|Consult two-column layout (spry .with-sidebar): the acting
+    // surfaces (state, blocks/questions, plan review, completion
+    // review, comments) on the left, the consulting ones (tasks with
+    // the filter strip, initial brief, involved projects, rules,
+    // narrative, runs) on the right, stacking Act-then-Consult under
+    // the framework breakpoint. Below the fold the page keeps its
+    // established surfaces: the agent's live progress feed, the
+    // attention blocks (the goal agent's answerable questions/RFCs
+    // and the completion review), the locked brief, the effective
     // rules, the approved plan (revision history behind the
     // expander), the supervision narrative (events at full weight,
     // comment events as footnotes), the comment thread with
@@ -21,10 +26,9 @@
     // R3/arch 15): a per-block queue that renders at the bottom of
     // the plan, persists across pulse refreshes (the maps live
     // outside statum re-renders), rides the rejection numbered, and
-    // clears on accept. The status strip's
-    // filter lives client-side in window.goalTaskFilter; clicking a
-    // strip button re-runs the sealed refresh action, and the filter
-    // rides the re-render.
+    // clears on accept. The tasks card's filter lives client-side in
+    // window.goalTaskFilter; clicking a strip button re-runs the
+    // sealed refresh action, and the filter rides the re-render.
     if (window.statum) {
         (function () {
             'use strict';
@@ -63,6 +67,17 @@
                 if (document.visibilityState === 'visible') refreshGoal('focus');
             });
 
+            // Goal 41 T6 shipped this page's sub-nav highlight inline
+            // (closest-to-fold - the two-column grid made the
+            // single-column observer rule light the wrong card); T11
+            // unified it with the task page's chassis into app.js's
+            // shared initSubnav engine, which keeps this page's
+            // selection rule for BOTH pages. The strip carries
+            // data-subnav; sections carry id + data-subnav-section.
+            if (window.initSubnav) {
+                window.initSubnav();
+            }
+
             if (window.initQuoteComments) {
                 window.initQuoteComments({
                     matches: function (node) {
@@ -85,11 +100,11 @@
             // now lives once); only the block resolution is page
             // state.
             // Plan 16 S2: the block for a review container resolves
-            // from the ACTUAL [data-plan-review] element - the pending
-            // completion review renders as an attention block (NOT the
-            // plan section), so goal.plan_pending_block alone never
-            // resolves it; every open block plus the plan section's
-            // row are all candidates.
+            // from the ACTUAL [data-plan-review] element - the
+            // pending completion review renders as a LEADING review
+            // card (goal 41 T6), so goal.plan_pending_block alone
+            // never resolves it; the slot's three review shapes plus
+            // every open block are all candidates.
             function resolveReviewBlock(id) {
                 var goal = statum.read('goal');
                 if (!goal) return null;
@@ -97,6 +112,10 @@
                 (goal.open_blocks || []).forEach(function (candidate) {
                     if (String(candidate.id) === String(id)) block = candidate;
                 });
+                if (!block && goal.completion_pending_block
+                        && String(goal.completion_pending_block.id) === String(id)) {
+                    block = goal.completion_pending_block;
+                }
                 if (!block && goal.plan_pending_block
                         && String(goal.plan_pending_block.id) === String(id)) {
                     block = goal.plan_pending_block;
@@ -121,36 +140,177 @@
 </head>
 <body>
     <div class="page goal-page">
-        <p><a href="/goals">&larr; Goals</a></p>
+        <pstm-fragment name="breadcrumb" crumbs="goal.crumbs"/>
         <h2 stm-if="goal.exists"><span class="goal-icon">🎯</span> #<span stm-text="goal.id">id</span> —
             <span stm-text="goal.title">title</span></h2>
-        <p stm-if="!goal.exists" class="muted">Goal not found.</p>
+        <p stm-if="!goal.exists" class="text-muted">Goal not found.</p>
 
         <div stm-if="goal.exists">
-            <!-- Status head (first, always): lifecycle badge, branch
-                 pair with the daemon-reported push-state chips,
-                 priority, timestamps, the assigned/parking agent
-                 saddle and the lifecycle actions — with full run
-                 control parity (plan 14 B1): Retry supervision for a
-                 failed goal, Requeue-to-resume for a run stale/parked
-                 on a dead saddle, Cancel. NO notify-me toggle — goals
-                 notify unconditionally by design. Yeehaw stays on
-                 task rows only. -->
-            <section class="card goal-head">
+            <!-- GOAL 41 T6 (quick win 7): the one-line STATE STRIP —
+                 the verdict without parsing the Status card: status
+                 badge, the open-item line ("2 questions waiting · plan
+                 revision 3 pending"), the PRIMARY lifecycle button
+                 (the one matching the current state; the full ladder
+                 keeps living on the Status card). Nothing else — the
+                 strip is one glance. -->
+            <div class="goal-state-strip" data-goal-state-strip>
+                <span stm-attribute.class="'badge badge-' + goal.status"
+                      stm-text="goal.display">status</span>
+                <span class="goal-state-verdict">
+                    <span stm-if="goal.open_blocks.length == 0
+                                  && (goal.plan_pending_block == null
+                                      || goal.plan_pending_block == false)
+                                  && !goal.completion_pending"
+                          class="text-muted">No open items — nothing waits on
+                        you.</span>
+                    <span stm-if="goal.plan_pending_block != null
+                                  && goal.plan_pending_block != false">
+                        Plan revision #<span stm-text="goal.plan_pending_block.id">n</span>
+                        pending</span>
+                    <span stm-if="goal.completion_pending">Completion review
+                        pending</span>
+                    <span stm-if="goal.open_blocks.length == 1"
+                          stm-text="'1 ' + (goal.open_blocks[0].block_type == 'question'
+                              ? 'question' : goal.open_blocks[0].block_type)
+                              + ' waiting'">1 waiting</span>
+                    <span stm-if="goal.open_blocks.length > 1"
+                          stm-text="goal.open_blocks.length + ' items waiting'">n waiting</span>
+                </span>
+                <span class="goal-state-actions">
+                    <button type="button" class="btn-primary btn-sm"
+                            stm-if="goal.start != null" stm-action="goal.start">Start
+                        goal</button>
+                    <button type="button" class="btn-primary btn-sm"
+                            stm-if="goal.resume != null" stm-action="goal.resume">Resume</button>
+                    <button type="button" class="btn-primary btn-sm"
+                            stm-if="goal.retry_run != null" stm-action="goal.retry_run">Retry
+                        supervision</button>
+                    <button type="button" class="btn-primary btn-sm"
+                            stm-if="goal.pause != null" stm-action="goal.pause">Pause</button>
+                    <button type="button" class="btn-primary btn-sm"
+                            stm-if="goal.requeue_run != null" stm-action="goal.requeue_run"
+                            stm-confirm="Requeue the agent's run? The daemon restores its workspace context on the next dispatch.">Requeue
+                        to resume</button>
+                    <button type="button" class="btn-primary btn-sm"
+                            stm-if="goal.force_wake != null" stm-action="goal.force_wake"
+                            stm-confirm="Force-woke this goal's parked agent? It resumes immediately on its saddle - one wake per press.">Force
+                        wake</button>
+                </span>
+            </div>
+
+            <!-- GOAL 41 T6 (bet 2): the anchor sub-nav — Status ·
+                 Blocks · Plan · Tasks · Brief · Narrative · Comments.
+                 Sticky under the topbar (app.css reads its height);
+                 the highlight listener lives in the page script
+                 (rebound through statum.afterEvaluate). -->
+            <nav class="subnav" aria-label="Sections" data-subnav>
+                <a href="#goal-sec-status">Status</a>
+                <a href="#goal-sec-blocks">Blocks</a>
+                <a href="#goal-sec-plan">Plan</a>
+                <a href="#goal-sec-tasks">Tasks</a>
+                <a href="#goal-sec-brief">Brief</a>
+                <a href="#goal-sec-narrative">Narrative</a>
+                <a href="#goal-sec-comments">Comments</a>
+            </nav>
+
+            <!-- GOAL 41 T6 (bet 1): Act|Consult — spry's
+                 .with-sidebar grid with the proportions flipped (the
+                 acting column is the wide one; app.css owns the
+                 columns + the sub-55rem stack). ACT carries the
+                 attention order the page already had: pending
+                 reviews lead (actions surface), then state, blocks,
+                 plan, comments. CONSULT carries the reference
+                 surfaces the reader jumps to. -->
+            <div class="act-consult with-sidebar" data-goal-columns>
+                <div class="stack" data-goal-act>
+
+            <!-- GOAL 41 T6 ("actions surface"): while a plan review is
+                 pending, the review surface LEADS the acting column
+                 (stm-if ordering by state) — the reader answers before
+                 anything else. The plan-history card below renders
+                 only when nothing is pending. -->
+            <section class="card plan-review-card" stm-if="goal.plan_pending_block != null && goal.plan_pending_block != false" id="goal-sec-plan-review" data-subnav-section="goal-sec-plan-review">
+                <h3>Plan review</h3>
+                <div class="plan-review" data-plan-review
+                     stm-attribute.data-block-id="goal.plan_pending_block.id">
+                    <h4>Plan submitted for review</h4>
+                    <div class="md rfc-body goal-body" data-plan-body
+                         stm-markdown="goal.plan_pending_block.body">revision</div>
+                    <div class="actions">
+                        <form stm-action="goal.plan_pending_block.answer_action" class="cluster">
+                            <input type="hidden" name="verdict" value="accept">
+                            <button type="submit" class="btn-primary" data-plan-accept>Approve plan</button>
+                        </form>
+                        <button type="button" data-plan-toggle>Reject with feedback</button>
+                    </div>
+                    <div data-plan-feedback hidden>
+                        <p class="text-muted">Highlight plan text to attach a remark to it — the queue
+                            rides the rejection numbered, then clears. Approving ignores the queue.</p>
+                        <ul class="plan-comments" data-plan-remarks></ul>
+                        <textarea name="feedback" rows="2"
+                                  placeholder="Additional feedback (optional)"></textarea>
+                        <button type="button" class="btn-primary" data-plan-submit>Submit feedback</button>
+                    </div>
+                </div>
+            </section>
+
+            <!-- GOAL 41 T6 ("actions surface"): the pending completion
+                 review leads the acting column the same way — hoisted
+                 out of the blocks loop (which skips review rows) and
+                 keyed on the slot's completion_pending_block. -->
+            <section class="card plan-review-card" stm-if="goal.completion_pending_block != null && goal.completion_pending_block != false" id="goal-sec-completion-review" data-subnav-section="goal-sec-completion-review">
+                <h3>Completion review</h3>
+                <div class="plan-review" data-plan-review
+                     stm-attribute.data-block-id="goal.completion_pending_block.id">
+                    <pstm-fragment name="goal-branches"/>
+                    <pstm-fragment name="goal-compare"/>
+                    <div class="md rfc-body goal-body" data-plan-body
+                         stm-markdown="goal.completion_pending_block.body">report</div>
+                    <div class="actions">
+                        <form stm-action="goal.completion_pending_block.answer_action" class="cluster">
+                            <input type="hidden" name="verdict" value="accept">
+                            <button type="submit" class="btn-primary" data-plan-accept>Accept goal</button>
+                        </form>
+                        <button type="button" data-plan-toggle>Reject with feedback</button>
+                    </div>
+                    <div data-plan-feedback hidden>
+                        <p class="text-muted">Highlight report text to attach a remark to it — the queue
+                            rides the rejection numbered, then clears. Accepting ignores the queue.</p>
+                        <ul class="plan-comments" data-plan-remarks></ul>
+                        <textarea name="feedback" rows="2"
+                                  placeholder="Additional feedback (optional)"></textarea>
+                        <button type="button" class="btn-primary" data-plan-submit>Submit feedback</button>
+                    </div>
+                </div>
+            </section>
+
+            <!-- Status head (first in the ACT column while no review
+                 awaits): lifecycle badge, branch pair with the
+                 daemon-reported push-state chips, priority,
+                 timestamps, the assigned/parking agent saddle and
+                 the FULL lifecycle ladder — with run control parity
+                 (plan 14 B1): Retry supervision for a failed goal,
+                 Requeue-to-resume for a run stale/parked on a dead
+                 saddle, Cancel. NO notify-me toggle — goals notify
+                 unconditionally by design. Yeehaw stays on task
+                 rows only. The strip above carries the one-glance
+                 verdict; this card keeps the detail. -->
+            <section class="card goal-head" id="goal-sec-status" data-subnav-section="goal-sec-status">
                 <div class="card-head">
                     <h3>Status</h3>
                 </div>
                 <p><span stm-attribute.class="'badge badge-' + goal.status" stm-text="goal.display">status</span>
                     · goal branch <code stm-text="goal.branch_name">branch</code>
-                    <span class="muted">from</span> <code stm-text="goal.base_branch">base</code>
+                    <span class="text-muted">from</span> <code stm-text="goal.base_branch">base</code>
                     <a stm-if="goal.compare_url.length > 0" stm-attribute.href="goal.compare_url"
-                       target="_blank" rel="noopener">· compare changes</a>
+                       target="_blank" rel="noopener">· compare
+                        changes</a>
                     · priority <strong stm-text="goal.priority">3</strong>
                     <span stm-if="goal.agent_saddle.length > 0"> · agent saddle
                         <strong stm-text="goal.agent_saddle">saddle</strong></span></p>
                 <pstm-fragment name="goal-branches"
                                note="&quot;(the daemon's /status report)&quot;"/>
-                <p class="muted">Project:
+                <p class="text-muted">Project:
                     <a stm-attribute.href="'/projects/' + goal.project_id" stm-text="goal.project_name">project</a>
                     · Created <span stm-text="goal.created_display">when</span>
                     <span stm-if="goal.started_display.length > 0"> · Started
@@ -158,13 +318,13 @@
                     <span stm-if="goal.completed_display.length > 0"> · Finished
                         <span stm-text="goal.completed_display">when</span></span></p>
                 <p>
-                    <button type="button" class="primary" stm-if="goal.start != null"
+                    <button type="button" class="btn-primary" stm-if="goal.start != null"
                             stm-action="goal.start">Start goal</button>
                     <button type="button" stm-if="goal.pause != null"
                             stm-action="goal.pause">Pause</button>
                     <button type="button" stm-if="goal.resume != null"
                             stm-action="goal.resume">Resume</button>
-                    <button type="button" class="primary" stm-if="goal.retry_run != null"
+                    <button type="button" class="btn-primary" stm-if="goal.retry_run != null"
                             stm-action="goal.retry_run">Retry supervision</button>
                     <button type="button" stm-if="goal.requeue_run != null"
                             stm-action="goal.requeue_run"
@@ -178,20 +338,20 @@
                             stm-action="goal.cancel"
                             stm-confirm="Cancel this goal? Its tasks are cancelled with it.">Cancel goal</button>
                 </p>
-                <p class="muted" stm-if="goal.status == 'draft'">Starting locks the brief and
+                <p class="text-muted" stm-if="goal.status == 'draft'">Starting locks the brief and
                     hands the goal to its agent (an orchestrate-mode run task, priority 3).</p>
-                <p class="muted" stm-if="goal.status == 'paused'">Paused — supervision frozen;
+                <p class="text-muted" stm-if="goal.status == 'paused'">Paused — supervision frozen;
                     task events queue and coalesce into the resume.</p>
                 <!-- Plan 15 C4: the ONE muted activity line (the
                      Current-activity card is gone) — what the run is
                      doing right now, folded into the status head. -->
-                <p class="muted" stm-if="goal.current_activity.length > 0"
+                <p class="text-muted" stm-if="goal.current_activity.length > 0"
                    stm-text="goal.current_activity">activity</p>
-                <p class="muted" stm-if="goal.saddle_health.length > 0"
+                <p class="text-muted" stm-if="goal.saddle_health.length > 0"
                    stm-text="goal.saddle_health">health</p>
                 <!-- S5: the goal's aggregate LLM usage across its tasks
                      (the run plus the workers) — empty renders nothing. -->
-                <p class="muted" stm-if="goal.usage_line.length > 0"
+                <p class="text-muted" stm-if="goal.usage_line.length > 0"
                    stm-text="goal.usage_line">LLM: 1,234 calls · 45.2M in / 1.1M out tok · 3h 12m</p>
             </section>
 
@@ -203,7 +363,7 @@
             <section class="card">
                 <h3>Agent progress</h3>
                 <pstm-fragment name="progress-feed" entries="goal.progress"/>
-                <p stm-if="goal.progress.length == 0" class="muted">No progress reports yet — the goal
+                <p stm-if="goal.progress.length == 0" class="text-muted">No progress reports yet — the goal
                     agent reports via <code>saddle progress-update</code>.</p>
             </section>
 
@@ -213,43 +373,18 @@
                  the historic answered ones fold behind the
                  answered-blocks expander (the task page's pattern);
                  the completion review follows when one awaits. -->
-            <section class="card"
-                     stm-if="goal.open_blocks.length > 0 || goal.answered_blocks.length > 0">
+            <section class="card" stm-if="goal.open_blocks.length > 0 || goal.answered_blocks.length > 0" id="goal-sec-blocks" data-subnav-section="goal-sec-blocks">
                 <h3>Goal agent blocks</h3>
                 <div stm-for-block-in="goal.open_blocks" class="goal-block">
-                    <p><span stm-attribute.class="'badge badge-block-' + block.block_type"
+                    <!-- A pending completion review renders in the
+                         LEADING completion-review card above (goal 41
+                         T6 "actions surface"); the loop shows every
+                         other open block — questions and RFCs with
+                         full markdown bodies and inline answer forms. -->
+                    <p stm-if="!block.is_completion_review"><span stm-attribute.class="'badge badge-block-' + block.block_type"
                              stm-text="block.block_type">kind</span>
                         <strong stm-text="block.question">question</strong>
-                        <span class="muted" stm-text="block.created_display">when</span></p>
-                    <!-- The pending completion review renders in the rich
-                         review form (highlight-remarks queue shared with
-                         the plan review; push chips + compare above the
-                         report — chips are advisory, the push rule is
-                         enforced daemon-side at the agent's completion
-                         attempt). -->
-                    <div stm-if="block.is_completion_review"
-                         class="plan-review" data-plan-review
-                         stm-attribute.data-block-id="block.id">
-                        <pstm-fragment name="goal-branches"/>
-                        <pstm-fragment name="goal-compare"/>
-                        <div class="md rfc-body goal-body" data-plan-body
-                             stm-markdown="block.body">report</div>
-                        <div class="actions">
-                            <form stm-action="block.answer_action" class="inline">
-                                <input type="hidden" name="verdict" value="accept">
-                                <button type="submit" class="approve" data-plan-accept>Accept goal</button>
-                            </form>
-                            <button type="button" data-plan-toggle>Reject with feedback</button>
-                        </div>
-                        <div data-plan-feedback hidden>
-                            <p class="muted">Highlight report text to attach a remark to it — the queue
-                                rides the rejection numbered, then clears. Accepting ignores the queue.</p>
-                            <ul class="plan-comments" data-plan-remarks></ul>
-                            <textarea name="feedback" rows="2"
-                                      placeholder="Additional feedback (optional)"></textarea>
-                            <button type="button" class="primary" data-plan-submit>Submit feedback</button>
-                        </div>
-                    </div>
+                        <span class="text-muted" stm-text="block.created_display">when</span></p>
                     <div stm-if="!block.is_completion_review">
                         <div stm-if="block.show_body && block.body.length > 0"
                              class="md rfc-body goal-body" stm-markdown="block.body">body</div>
@@ -263,7 +398,7 @@
                         <p><span stm-attribute.class="'badge badge-block-' + block.block_type"
                                  stm-text="block.block_type">kind</span>
                             <strong stm-text="block.question">question</strong>
-                            <span class="muted" stm-text="block.created_display">when</span></p>
+                            <span class="text-muted" stm-text="block.created_display">when</span></p>
                         <div stm-if="block.show_body && block.body.length > 0"
                              class="md rfc-body goal-body" stm-markdown="block.body">body</div>
                         <blockquote stm-if="block.answered" class="term-comment-quote">
@@ -277,57 +412,32 @@
                  once the goal is complete/failed, with the report and
                  the compare link. While PENDING, the review lives in
                  the attention blocks above (the rich review form). -->
-            <section class="card" stm-if="goal.terminal && goal.completion_report.length > 0">
+            <section class="card" stm-if="goal.terminal && goal.completion_report.length > 0" id="goal-sec-completion-report" data-subnav-section="goal-sec-completion-report">
                 <h3>Completion review</h3>
                 <pstm-fragment name="goal-branches"/>
                 <pstm-fragment name="goal-compare"/>
                 <div class="md rfc-body goal-body" stm-markdown="goal.completion_report">report</div>
             </section>
 
-            <!-- The plan (plan 15 C4): BETWEEN attention and tasks.
-                 While a plan review is open the pending submission
-                 renders EXPANDED with the remarks queue (plan 14 B2);
-                 otherwise the approved plan and the revision history
-                 (approved + rejected) sit behind a collapsed
-                 expander — the goal page leads with what needs a
-                 human, not with settled history. -->
-            <section class="card">
+            <!-- The plan history (plan 15 C4): the approved plan and
+                 the revision history (approved + rejected) sit behind
+                 a collapsed expander — the goal page leads with what
+                 needs a human, not with settled history. While a plan
+                 review is open the LEADING plan-review card above
+                 carries it (this card waits below). -->
+            <section class="card" id="goal-sec-plan" data-subnav-section="goal-sec-plan">
                 <h3>Plan</h3>
 
-                <div stm-if="goal.plan_pending_block != null && goal.plan_pending_block != false"
-                     class="plan-review" data-plan-review
-                     stm-attribute.data-block-id="goal.plan_pending_block.id">
-                    <h4>Plan submitted for review</h4>
-                    <div class="md rfc-body goal-body" data-plan-body
-                         stm-markdown="goal.plan_pending_block.body">revision</div>
-                    <div class="actions">
-                        <form stm-action="goal.plan_pending_block.answer_action" class="inline">
-                            <input type="hidden" name="verdict" value="accept">
-                            <button type="submit" class="approve" data-plan-accept>Approve plan</button>
-                        </form>
-                        <button type="button" data-plan-toggle>Reject with feedback</button>
-                    </div>
-                    <div data-plan-feedback hidden>
-                        <p class="muted">Highlight plan text to attach a remark to it — the queue
-                            rides the rejection numbered, then clears. Approving ignores the queue.</p>
-                        <ul class="plan-comments" data-plan-remarks></ul>
-                        <textarea name="feedback" rows="2"
-                                  placeholder="Additional feedback (optional)"></textarea>
-                        <button type="button" class="primary" data-plan-submit>Submit feedback</button>
-                    </div>
-                </div>
-
-                <details class="history-expander"
-                         stm-if="goal.plan_pending_block == null || goal.plan_pending_block == false">
+                <details class="history-expander">
                     <summary>Approved plan &amp; revisions (<span
                             stm-text="goal.plan_revisions.length">0</span>)</summary>
                     <div stm-if="goal.plan.length > 0" class="md rfc-body goal-body" data-plan-body
                          stm-markdown="goal.plan">plan</div>
-                    <p stm-if="goal.plan.length == 0" class="muted">No approved plan yet.</p>
+                    <p stm-if="goal.plan.length == 0" class="text-muted">No approved plan yet.</p>
                     <div stm-for-revision-in="goal.plan_revisions" class="plan-revision">
                         <p><span stm-attribute.class="'badge ' + (revision.approved ? 'badge-done' : (revision.answered ? 'badge-failed' : 'badge-plan'))"
                                  stm-text="revision.approved ? 'approved' : (revision.answered ? 'rejected' : 'pending')">verdict</span>
-                            <span class="muted">Revision #<span stm-text="revision.id">n</span> &mdash; <span stm-text="revision.created_display">when</span></span></p>
+                            <span class="text-muted">Revision #<span stm-text="revision.id">n</span> &mdash; <span stm-text="revision.created_display">when</span></span></p>
                         <details>
                             <summary>Submitted markdown</summary>
                             <div class="md rfc-body" stm-markdown="revision.body">body</div>
@@ -339,10 +449,76 @@
                 </details>
             </section>
 
+            <!-- Comments with selection-quoting (markdown bodies).
+                 Plan 15 C2: the thread IS the goal-run task's own
+                 task_comments — the agent's pushes and the human's
+                 composer land on the same rows; harness-authored
+                 bookkeeping renders as distinct muted system rows,
+                 never agent comment rows. The composer opens with
+                 the run (a draft goal has no thread to inject into). -->
+            <section class="card" id="goal-sec-comments" data-subnav-section="goal-sec-comments">
+                <h3>Comments</h3>
+                <!-- Plan 15 C4: timeless-comment footnote styling —
+                     the BODY leads; the author and time read as a
+                     muted footnote (status belongs in progress
+                     updates). System rows keep their labelled style. -->
+                <div stm-for-comment-in="goal.comments"
+                     stm-attribute.class="'comment' + (comment.from_agent ? ' agent' : '') + (comment.is_system ? ' system' : '')">
+                    <div class="md" stm-markdown="comment.body">body</div>
+                    <blockquote stm-if="comment.quote.length > 0" stm-text="comment.quote">quote</blockquote>
+                    <p class="comment-meta">
+                        <strong stm-if="!comment.is_system" stm-text="comment.author">author</strong>
+                        <span stm-if="comment.is_system" class="system-label">system</span>
+                        <span class="text-muted" stm-text="comment.created_display">when</span>
+                    </p>
+                </div>
+                <p stm-if="goal.comments.length == 0" class="text-muted">No comments yet.</p>
+                <p stm-if="goal.run_task_id == 0" class="text-muted">The goal agent has not started
+                    yet — comments open with its run.</p>
+                <!-- Goal 8 task 4: the composer stays OPEN on terminal
+                     goals — a comment there re-opens the goal and
+                     re-offers its agent's run with the comment riding
+                     the resume (cancelled goals stay closed: their
+                     composer keeps the run gate alone). -->
+                <form stm-if="goal.run_task_id > 0" stm-action="goal.comment">
+                    <div class="field"><label for="goal-comment-body">Add comment</label>
+                        <textarea id="goal-comment-body" name="body" rows="3"
+                                  placeholder="A timeless note for a future reader — status belongs in progress updates (markdown)"></textarea></div>
+                    <p stm-if="goal.terminal && goal.status != 'cancelled'" class="text-muted">
+                        This goal is terminal — commenting re-opens it: the agent resumes
+                        with your comment and submits a fresh completion report.</p>
+                    <button type="submit" class="btn-primary">Comment</button>
+                </form>
+                <dialog id="quote-comment-dialog" class="dialog-sm">
+                    <div class="dialog-titlebar">
+                        <p class="dialog-title">Comment on selected text</p>
+                        <button type="button" class="dialog-close" aria-label="Close"
+                                onclick="document.getElementById('quote-comment-dialog').close()">×</button>
+                    </div>
+                    <form stm-action="goal.comment">
+                        <div class="dialog-body form-rows">
+                            <blockquote id="quote-text" class="term-comment-quote"></blockquote>
+                            <input type="hidden" name="quote">
+                            <input type="hidden" name="return_to">
+                            <textarea name="body" rows="4" placeholder="Your comment…"></textarea>
+                        </div>
+                        <div class="dialog-footer">
+                            <button type="button"
+                                    onclick="document.getElementById('quote-comment-dialog').close()">Cancel</button>
+                            <button type="submit" class="btn-primary">Send comment</button>
+                        </div>
+                    </form>
+                </dialog>
+            </section>
+
+                </div><!-- /data-goal-act -->
+
+                <div class="stack" data-goal-consult>
+
             <!-- Task status strip + the goal's task list (plan 14 B5:
                  tasks above the brief; capability badges per row —
                  no editing here; yeehaw on rows only). -->
-            <section class="card">
+            <section class="card" id="goal-sec-tasks" data-subnav-section="goal-sec-tasks">
                 <h3>Tasks</h3>
                 <p class="goal-strip">
                     <button type="button" stm-attribute.class="window.goalFilterClass('draft')"
@@ -360,7 +536,7 @@
                     <button type="button" stm-attribute.class="window.goalFilterClass('failed')"
                             onclick="window.goalSetFilter('failed')"
                             stm-text="'failed ' + goal.failed_tasks">failed</button>
-                    <span class="muted">— click a status to filter</span>
+                    <span class="text-muted">— click a status to filter</span>
                 </p>
                 <table>
                     <thead><tr><th>#</th><th>Title</th><th>Status</th><th>Mode</th><th>Requires</th><th>Created by</th></tr></thead>
@@ -378,10 +554,10 @@
                             <td><span stm-for-capability-in="goal_task.capabilities">
                                 <span class="badge badge-capability"
                                       stm-text="capability">tag</span> </span></td>
-                            <td class="muted" stm-text="goal_task.creator">who</td>
+                            <td class="text-muted" stm-text="goal_task.creator">who</td>
                         </tr>
                         <tr stm-if="goal.goal_tasks.length == 0">
-                            <td colspan="6" class="muted">No tasks yet — the goal agent fans
+                            <td colspan="6" class="text-muted">No tasks yet — the goal agent fans
                                 them out once the plan is approved.</td></tr>
                     </tbody>
                 </table>
@@ -395,23 +571,22 @@
                  nothing else could change). Starting locks the brief:
                  the editor's stm-if wrapper disappears with the
                  update action and the card stays the plain read view. -->
-            <section class="card" data-long-form data-field="goal"
-                     data-lf-open="0">
+            <section class="card" data-long-form data-field="goal" data-lf-open="0" id="goal-sec-brief" data-subnav-section="goal-sec-brief">
                 <h3>Initial brief
-                    <button type="button" class="inline" stm-if="goal.update != null"
+                    <button type="button" class="btn-ghost" stm-if="goal.update != null"
                             data-lf-toggle>Edit</button>
-                    <span class="muted" stm-if="goal.started_display.length > 0">(locked)</span></h3>
+                    <span class="text-muted" stm-if="goal.started_display.length > 0">(locked)</span></h3>
                 <div data-lf-view>
                     <div class="md rfc-body goal-body" data-brief-body stm-markdown="goal.brief">brief</div>
                 </div>
                 <div stm-if="goal.update != null">
                     <form data-lf-edit hidden stm-action="goal.update">
-                        <div class="form-row"><label>Title</label>
+                        <div class="field"><label>Title</label>
                             <input type="text" name="title" data-lf-src="goal.title"></div>
-                        <div class="form-row"><label>Brief (markdown)</label>
+                        <div class="field"><label>Brief (markdown)</label>
                             <textarea name="brief" rows="8"
                                       data-lf-src="goal.brief">brief</textarea></div>
-                        <div class="form-row"><label>Priority (1–9)</label>
+                        <div class="field"><label>Priority (1–9)</label>
                             <input type="number" name="priority" min="1" max="9"
                                    data-lf-src="goal.priority"></div>
                         <button type="submit">Save</button>
@@ -426,14 +601,14 @@
                 <p stm-if="goal.links.length > 0"><span stm-for-link-in="goal.links">
                     <span stm-attribute.class="'badge badge-' + link.relationship"
                           stm-text="link.name + ' (' + link.relationship + ')'">link</span>
-                    <button type="button" class="inline" stm-action="link.remove"
+                    <button type="button" class="btn-ghost" stm-action="link.remove"
                             stm-confirm="Remove this link?">&times;</button>
                     <input type="hidden" name="link_id" stm-attribute.value="link.id">
                 </span></p>
-                <p stm-if="goal.links.length == 0" class="muted">Only the primary project.</p>
+                <p stm-if="goal.links.length == 0" class="text-muted">Only the primary project.</p>
                 <details stm-if="!goal.terminal">
                     <summary>Link a project</summary>
-                    <form class="inline" stm-action="goal.add_link">
+                    <form class="cluster" stm-action="goal.add_link">
                         <select name="linked_project_id" required>
                             <option stm-for-project-in="goal.project_options" stm-attribute.value="project.id"
                                      stm-text="project.name">project</option>
@@ -444,7 +619,7 @@
                         </select>
                         <button type="submit">Link</button>
                     </form>
-                    <form class="inline" stm-action="goal.apply_stack"
+                    <form class="cluster" stm-action="goal.apply_stack"
                           stm-if="goal.stack_options.length > 0" style="margin-top: 0.5em;">
                         <label for="goal-stack">Apply stack</label>
                         <select id="goal-stack" name="stack_id" required>
@@ -460,24 +635,24 @@
                  the agent and every worker it fans out. -->
             <section class="card">
                 <h3>Rules</h3>
-                <p class="muted">The effective set dispatched with this goal's work — task rules,
+                <p class="text-muted">The effective set dispatched with this goal's work — task rules,
                     the primary project's, then co-developed projects' in link order.</p>
                 <div stm-for-rule-in="goal.rules" class="rule-card">
                     <h4 stm-text="rule.name">name</h4>
                     <div class="md" stm-markdown="rule.body">body</div>
                 </div>
-                <p stm-if="goal.rules.length == 0" class="muted">No rules apply to this goal.</p>
+                <p stm-if="goal.rules.length == 0" class="text-muted">No rules apply to this goal.</p>
             </section>
 
             <!-- The supervision narrative (plan 14 B6): state events
                  at full weight, comment events as .footnote — the
                  feed reads as events-with-occasional-footnotes. -->
-            <section class="card">
+            <section class="card" id="goal-sec-narrative" data-subnav-section="goal-sec-narrative">
                 <h3>Supervision narrative</h3>
-                <p stm-if="goal.events.length == 0" class="muted">Nothing yet — start the goal.</p>
+                <p stm-if="goal.events.length == 0" class="text-muted">Nothing yet — start the goal.</p>
                 <div stm-for-event-in="goal.events"
                      stm-attribute.class="'goal-event' + (event.kind == 'comment' ? ' footnote' : '')">
-                    <span class="muted" stm-text="event.at_display">when</span>
+                    <span class="text-muted" stm-text="event.at_display">when</span>
                     <span stm-attribute.class="'badge badge-goal-' + event.kind"
                           stm-text="event.kind.replace('-', ' ')">kind</span>
                     <!-- Comment events render as footnotes (plan 13 B4
@@ -489,84 +664,33 @@
                 </div>
             </section>
 
-            <!-- Comments with selection-quoting (markdown bodies).
-                 Plan 15 C2: the thread IS the goal-run task's own
-                 task_comments — the agent's pushes and the human's
-                 composer land on the same rows; harness-authored
-                 bookkeeping renders as distinct muted system rows,
-                 never agent comment rows. The composer opens with
-                 the run (a draft goal has no thread to inject into). -->
-            <section class="card">
-                <h3>Comments</h3>
-                <!-- Plan 15 C4: timeless-comment footnote styling —
-                     the BODY leads; the author and time read as a
-                     muted footnote (status belongs in progress
-                     updates). System rows keep their labelled style. -->
-                <div stm-for-comment-in="goal.comments"
-                     stm-attribute.class="'comment' + (comment.from_agent ? ' agent' : '') + (comment.is_system ? ' system' : '')">
-                    <div class="md" stm-markdown="comment.body">body</div>
-                    <blockquote stm-if="comment.quote.length > 0" stm-text="comment.quote">quote</blockquote>
-                    <p class="comment-meta">
-                        <strong stm-if="!comment.is_system" stm-text="comment.author">author</strong>
-                        <span stm-if="comment.is_system" class="system-label">system</span>
-                        <span class="muted" stm-text="comment.created_display">when</span>
-                    </p>
-                </div>
-                <p stm-if="goal.comments.length == 0" class="muted">No comments yet.</p>
-                <p stm-if="goal.run_task_id == 0" class="muted">The goal agent has not started
-                    yet — comments open with its run.</p>
-                <!-- Goal 8 task 4: the composer stays OPEN on terminal
-                     goals — a comment there re-opens the goal and
-                     re-offers its agent's run with the comment riding
-                     the resume (cancelled goals stay closed: their
-                     composer keeps the run gate alone). -->
-                <form stm-if="goal.run_task_id > 0" stm-action="goal.comment">
-                    <div class="form-row"><label for="goal-comment-body">Add comment</label>
-                        <textarea id="goal-comment-body" name="body" rows="3"
-                                  placeholder="A timeless note for a future reader — status belongs in progress updates (markdown)"></textarea></div>
-                    <p stm-if="goal.terminal && goal.status != 'cancelled'" class="muted">
-                        This goal is terminal — commenting re-opens it: the agent resumes
-                        with your comment and submits a fresh completion report.</p>
-                    <button type="submit" class="primary">Comment</button>
-                </form>
-                <dialog id="quote-comment-dialog">
-                    <h3>Comment on selected text</h3>
-                    <blockquote id="quote-text" class="term-comment-quote"></blockquote>
-                    <form stm-action="goal.comment">
-                        <input type="hidden" name="quote">
-                        <input type="hidden" name="return_to">
-                        <textarea name="body" rows="4" placeholder="Your comment…"
-                                  style="width:100%; margin:0.5em 0;"></textarea>
-                        <button type="submit" class="primary">Send comment</button>
-                        <button type="button"
-                                onclick="document.getElementById('quote-comment-dialog').close()">Cancel</button>
-                    </form>
-                </dialog>
-            </section>
-
-            <!-- Goal agent runs: every round with its host saddle; the
-                 event log links open in a new tab. -->
+            <!-- Goal agent runs: every round with its host saddle. Goal
+                 41 T7: the run links navigate SAME-TAB to the log (a
+                 new tab severed the back button; the task page's Log
+                 tab and this route are one feed). -->
             <section class="card" stm-if="goal.runs.length > 0">
                 <h3>Goal agent runs</h3>
-                <p><a stm-attribute.href="'/tasks/' + goal.run_task_id + '/log'"
-                      target="_blank" rel="noopener">Event log</a>
-                    <span class="muted">— run task
+                <p><a stm-attribute.href="'/tasks/' + goal.run_task_id + '/log'">Event log</a>
+                    <span class="text-muted">— run task
                         #<span stm-text="goal.run_task_id">id</span></span></p>
                 <table>
                     <thead><tr><th>Run</th><th>Saddle</th><th>Started</th><th>Ended</th><th>Outcome</th></tr></thead>
                     <tbody>
                         <tr stm-for-run-in="goal.runs">
-                            <td><a stm-attribute.href="run.terminal_url" target="_blank" rel="noopener"
+                            <td><a stm-attribute.href="run.terminal_url"
                                    stm-text="'session ' + run.id">run</a></td>
-                            <td class="muted" stm-text="run.saddle_name">saddle</td>
-                            <td class="muted" stm-text="run.started_display">when</td>
-                            <td class="muted" stm-text="run.ended_display">when</td>
+                            <td class="text-muted" stm-text="run.saddle_name">saddle</td>
+                            <td class="text-muted" stm-text="run.started_display">when</td>
+                            <td class="text-muted" stm-text="run.ended_display">when</td>
                             <td><span stm-if="run.open" class="badge badge-working">open</span>
                                 <span stm-if="!run.open" stm-text="run.outcome">outcome</span></td>
                         </tr>
                     </tbody>
                 </table>
             </section>
+
+                </div><!-- /data-goal-consult -->
+            </div><!-- /act-consult columns -->
         </div>
     </div>
 </body>

+ 147 - 34
src/pages/goals.html

@@ -9,40 +9,167 @@
     // brief is a heredoc-style textarea (markdown, rendered on the
     // goal page). Branch name is required and uniqueness-validated
     // server-side.
+    //
+    // Goal 41 T4: the list gained the SAME faceted status chip strip
+    // as /tasks (client-side, the queue's engine pattern) and the
+    // Tasks column's four-count muted string became readable badges
+    // - total plus the live counts (ux-notes goals list + quick win
+    // 9). The create form moved into a Spry <dialog> so the required
+    // brief textarea is visible the moment the form opens.
     if (window.statum) {
         (function () {
             'use strict';
+
+            window.goalFilters = window.goalFilters || {};
+
+            window.goalStatusCount = function (status) {
+                var goals = statum.read('goals');
+                var rows = (goals && goals.goals) || [];
+                var count = 0;
+                for (var i = 0; i < rows.length; i++) {
+                    if (status === 'all' || rows[i].status === status) {
+                        count += 1;
+                    }
+                }
+                return count;
+            };
+
+            window.goalRowVisible = function (row) {
+                var status = (window.goalFilters || {}).status;
+                return !status || status === 'all' || row.status === status;
+            };
+
+            window.goalChipClass = function (status) {
+                var current = (window.goalFilters || {}).status;
+                if (current == null || current === '') current = 'all';
+                return current === status ? 'goal-filter active' : 'goal-filter';
+            };
+
+            window.setGoalStatusFilter = function (status) {
+                var current = (window.goalFilters || {}).status;
+                if (current == null || current === '') current = 'all';
+                if (current === status) return; // one chip is always on
+                window.goalFilters.status = status;
+                var goals = statum.read('goals');
+                if (goals && goals.refresh) {
+                    statum.act(goals.refresh).catch(function () { });
+                } else {
+                    document.body.setAttribute('data-filter-bump', String(Date.now()));
+                }
+            };
         })();
     }
 </script>
 </head>
 <body>
-    <div class="page">
-        <h1><span class="goal-icon">🎯</span> Goals</h1>
-        <p class="muted">User-created units of agent-orchestrated work — brief, plan,
+    <div class="stack">
+
+        <div class="cluster cluster-between">
+            <h1><span class="goal-icon">🎯</span> Goals</h1>
+            <button type="button" class="btn-primary"
+                    onclick="window.openAppDialog('goal-create-dialog')">+ New goal</button>
+        </div>
+        <p class="text-muted">User-created units of agent-orchestrated work — brief, plan,
             task fan-out, one shared goal branch per involved repo.</p>
 
-        <details class="history-expander">
-            <summary><strong>New goal</strong></summary>
-            <form stm-action="goals.create">
-                <div class="form-row"><label for="goal-project">Project</label>
+        <!-- The /tasks chip strip, on goals: one chip always active,
+             default "All". -->
+        <p class="goal-strip">
+            <button type="button" stm-attribute.class="window.goalChipClass('all')"
+                    onclick="window.setGoalStatusFilter('all')"
+                    stm-text="'all ' + window.goalStatusCount('all')">all</button>
+            <button type="button" stm-attribute.class="window.goalChipClass('draft')"
+                    onclick="window.setGoalStatusFilter('draft')"
+                    stm-text="'draft ' + window.goalStatusCount('draft')">draft</button>
+            <button type="button" stm-attribute.class="window.goalChipClass('planning')"
+                    onclick="window.setGoalStatusFilter('planning')"
+                    stm-text="'planning ' + window.goalStatusCount('planning')">planning</button>
+            <button type="button" stm-attribute.class="window.goalChipClass('active')"
+                    onclick="window.setGoalStatusFilter('active')"
+                    stm-text="'active ' + window.goalStatusCount('active')">active</button>
+            <button type="button" stm-attribute.class="window.goalChipClass('plan_review')"
+                    onclick="window.setGoalStatusFilter('plan_review')"
+                    stm-text="'plan review ' + window.goalStatusCount('plan_review')">plan review</button>
+            <button type="button" stm-attribute.class="window.goalChipClass('completion_review')"
+                    onclick="window.setGoalStatusFilter('completion_review')"
+                    stm-text="'completion review ' + window.goalStatusCount('completion_review')">completion review</button>
+            <button type="button" stm-attribute.class="window.goalChipClass('paused')"
+                    onclick="window.setGoalStatusFilter('paused')"
+                    stm-text="'paused ' + window.goalStatusCount('paused')">paused</button>
+            <button type="button" stm-attribute.class="window.goalChipClass('done')"
+                    onclick="window.setGoalStatusFilter('done')"
+                    stm-text="'done ' + window.goalStatusCount('done')">done</button>
+            <button type="button" stm-attribute.class="window.goalChipClass('failed')"
+                    onclick="window.setGoalStatusFilter('failed')"
+                    stm-text="'failed ' + window.goalStatusCount('failed')">failed</button>
+        </p>
+
+        <div class="table-wrap">
+            <table class="table-hover">
+
+            <thead><tr><th>#</th><th>Title</th><th>Status</th><th>Branch</th><th>Priority</th>
+                <th>Tasks</th><th>Created</th></tr></thead>
+            <tbody>
+                <tr stm-for-goal-in="goals.goals" stm-if="window.goalRowVisible(goal)">
+                    <td stm-text="goal.id">id</td>
+                    <td><span class="goal-icon">🎯</span><a stm-attribute.href="'/goals/' + goal.id" stm-text="goal.title">title</a></td>
+                    <td><span stm-attribute.class="'badge badge-' + goal.status"
+                              stm-text="goal.display">status</span></td>
+                    <td><code stm-text="goal.branch_name">branch</code></td>
+                    <td stm-text="goal.priority">3</td>
+                    <!-- The four-count muted string became a total
+                         badge plus the live states - the shape of the
+                         goal at a glance instead of four numbers in a
+                         row (ux-notes quick win 9). -->
+                    <td>
+                        <span class="badge"
+                              stm-text="(goal.draft_tasks + goal.queued_tasks + goal.running_tasks + goal.done_tasks + goal.failed_tasks) + ' tasks'">0 tasks</span>
+                        <span class="badge badge-warning" stm-if="goal.running_tasks > 0"
+                              stm-text="goal.running_tasks + ' running'">running</span>
+                        <span class="badge badge-warning" stm-if="goal.queued_tasks > 0"
+                              stm-text="goal.queued_tasks + ' queued'">queued</span>
+                        <span class="badge badge-danger" stm-if="goal.failed_tasks > 0"
+                              stm-text="goal.failed_tasks + ' failed'">failed</span>
+                    </td>
+                    <td class="text-muted" stm-text="goal.created_display">when</td>
+                </tr>
+                <tr stm-if="goals.goals.length == 0"><td colspan="7" class="text-muted">No goals yet.</td></tr>
+            </tbody>
+        
+            </table>
+        </div>
+    </div>
+
+    <!-- Goal 41 T4: the create form in a Spry <dialog> — the
+         required brief textarea (the goal's most important field) is
+         visible the moment the dialog opens instead of hiding behind
+         a collapsed summary. Same fields, same stm-action. -->
+    <dialog id="goal-create-dialog" class="dialog-sm">
+        <div class="dialog-titlebar">
+            <p class="dialog-title">New goal</p>
+            <button type="button" class="dialog-close" aria-label="Close"
+                    onclick="window.closeAppDialog('goal-create-dialog')">×</button>
+        </div>
+        <form stm-action="goals.create">
+            <div class="dialog-body form-rows">
+                <div class="field"><label for="goal-project">Project</label>
                     <select id="goal-project" name="project_id" required>
                         <option stm-for-project-in="goals.projects" stm-attribute.value="project.id"
                                  stm-text="project.name">project</option>
                     </select></div>
-                <div class="form-row"><label for="goal-title">Title</label>
+                <div class="field"><label for="goal-title">Title</label>
                     <input type="text" id="goal-title" name="title" required
                            placeholder="What should the goal achieve?"></div>
-                <div class="form-row"><label for="goal-brief">Initial brief</label>
+                <div class="field"><label for="goal-brief">Initial brief</label>
                     <textarea id="goal-brief" name="brief" rows="6" required
                               placeholder="Markdown brief — locks once the goal starts"></textarea></div>
-                <div class="form-row"><label for="goal-branch">Goal branch name</label>
+                <div class="field"><label for="goal-branch">Goal branch name</label>
                     <input type="text" id="goal-branch" name="branch_name" required
                            placeholder="goal/my-feature"></div>
-                <div class="form-row"><label for="goal-base">Base branch <span class="muted">(default: the project's default branch)</span></label>
+                <div class="field"><label for="goal-base">Base branch <span class="text-muted">(default: the project's default branch)</span></label>
                     <input type="text" id="goal-base" name="base_branch"
                            placeholder="main"></div>
-                <div class="form-row"><label for="goal-priority">Priority</label>
+                <div class="field"><label for="goal-priority">Priority</label>
                     <select id="goal-priority" name="priority">
                         <option value="1">1 — highest</option>
                         <option value="2">2</option>
@@ -54,27 +181,13 @@
                         <option value="8">8</option>
                         <option value="9">9 — lowest</option>
                     </select></div>
-                <button type="submit" class="primary">Create goal</button>
-            </form>
-        </details>
-
-        <table>
-            <thead><tr><th>#</th><th>Title</th><th>Status</th><th>Branch</th><th>Priority</th>
-                <th>Tasks</th><th>Created</th></tr></thead>
-            <tbody>
-                <tr stm-for-goal-in="goals.goals">
-                    <td stm-text="goal.id">id</td>
-                    <td><span class="goal-icon">🎯</span><a stm-attribute.href="'/goals/' + goal.id" stm-text="goal.title">title</a></td>
-                    <td><span stm-attribute.class="'badge badge-' + goal.status"
-                              stm-text="goal.display">status</span></td>
-                    <td><code stm-text="goal.branch_name">branch</code></td>
-                    <td stm-text="goal.priority">3</td>
-                    <td><span class="muted" stm-text="goal.draft_tasks + ' draft / ' + goal.queued_tasks + ' queued / ' + goal.running_tasks + ' running / ' + goal.done_tasks + ' done'">counts</span></td>
-                    <td class="muted" stm-text="goal.created_display">when</td>
-                </tr>
-                <tr stm-if="goals.goals.length == 0"><td colspan="7" class="muted">No goals yet.</td></tr>
-            </tbody>
-        </table>
-    </div>
+            </div>
+            <div class="dialog-footer">
+                <button type="button" class="btn-ghost"
+                        onclick="window.closeAppDialog('goal-create-dialog')">Cancel</button>
+                <button type="submit" class="btn-primary">Create goal</button>
+            </div>
+        </form>
+    </dialog>
 </body>
 </html>

+ 313 - 409
src/pages/home.html

@@ -8,8 +8,9 @@
     // re-runs the sealed refresh action whenever task or saddle state
     // changes, so progress, blocks and review requests stream in.
     // The notification bell drives the Web Push subscribe flow (plan
-    // 11 task E): permission -> service worker -> pushManager.subscribe
-    // with the page's VAPID key -> the sealed push_subscribe action.
+    // 11 task E) - the shared engine lives in app.js
+    // (window.initPushBell, goal 41 task 3: the dashboard's compact
+    // rail strip and /profile's settings card run the same code).
     if (window.statum) {
         (function () {
             'use strict';
@@ -20,26 +21,6 @@
                 }
             });
 
-            function toBase64Url(buffer) {
-                var bytes = new Uint8Array(buffer);
-                var binary = '';
-                for (var index = 0; index < bytes.length; index++) {
-                    binary += String.fromCharCode(bytes[index]);
-                }
-                return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
-            }
-
-            function vapidKeyBytes(base64Url) {
-                var padding = '='.repeat((4 - base64Url.length % 4) % 4);
-                var base64 = (base64Url + padding).replace(/-/g, '+').replace(/_/g, '/');
-                var raw = atob(base64);
-                var bytes = new Uint8Array(raw.length);
-                for (var index = 0; index < raw.length; index++) {
-                    bytes[index] = raw.charCodeAt(index);
-                }
-                return bytes;
-            }
-
             function dashboard() {
                 return statum.read('dashboard') || {};
             }
@@ -59,419 +40,342 @@
                 statum.act(state.start_advisor, { message: text }).catch(function () { });
             };
 
-            function fail(error) {
-                if (window.showToast) {
-                    showToast('Notifications: ' + (error && error.message ? error.message : error), 'error');
-                }
-            }
+            // ---- work stream: "new since your last visit" + the row
+            // cap ----
+            // Goal 41 task 3; goal 41 task 10 moved the feed to the
+            // cross-entity work stream (the slot's work_stream rows).
+            // The framework carries no cookies (auth is slot-based)
+            // and statum exposes no client slot WRITE, so a true
+            // server-side last-visit is not available: the stamp
+            // lives in localStorage, written when the dashboard tab
+            // UNLOADS - a visit updates the baseline, a background
+            // pulse does not (an open tab keeps its count growing
+            // until you come back). First visit: no stamp, no badge
+            // (everything would be "new"). The count covers the full
+            // stream the slot carries (the server caps the stream
+            // itself at 15 rows - the old activity table's uncap
+            // control died with it; the expander stays for the
+            // quiet frame).
+            var VISIT_KEY = 'clanker.lastVisit';
 
-            function enablePush() {
-                var state = dashboard();
-                if (!state.push_vapid_key) {
-                    fail('not available on this server');
-                    return;
+            function readLastVisit() {
+                try {
+                    var raw = window.localStorage.getItem(VISIT_KEY);
+                    if (!raw) return null;
+                    var stamp = parseInt(raw, 10);
+                    return isNaN(stamp) ? null : stamp;
+                } catch (error) {
+                    return null;
                 }
-                if (!('serviceWorker' in navigator) || !('PushManager' in window)) {
-                    fail('this browser has no Web Push support');
-                    return;
-                }
-                Notification.requestPermission().then(function (permission) {
-                    if (permission !== 'granted') {
-                        fail('permission denied in the browser');
-                        return;
-                    }
-                    // The service worker lives at the origin root (GET
-                    // /sw.js, Service-Worker-Allowed: /), so its scope
-                    // covers every page.
-                    return navigator.serviceWorker.register('/sw.js', { scope: '/' })
-                        .then(function () { return navigator.serviceWorker.ready; })
-                        .then(function (registration) {
-                            return registration.pushManager.getSubscription().then(function (existing) {
-                                if (existing) return existing;
-                                return registration.pushManager.subscribe({
-                                    userVisibleOnly: true,
-                                    applicationServerKey: vapidKeyBytes(state.push_vapid_key)
-                                });
-                            });
-                        })
-                        .then(function (subscription) {
-                            var keys = subscription.toJSON().keys || {};
-                            return statum.act(state.push_subscribe, {
-                                endpoint: subscription.endpoint,
-                                p256dh: keys.p256dh,
-                                auth: keys.auth
-                            });
-                        })
-                        .catch(function (error) { fail(error); });
-                });
             }
 
-            function disablePush() {
-                var state = dashboard();
-                navigator.serviceWorker.getRegistration('/').then(function (registration) {
-                    if (!registration) return null;
-                    return registration.pushManager.getSubscription();
-                }).then(function (subscription) {
-                    var done = subscription ? subscription.unsubscribe() : Promise.resolve(true);
-                    return done.then(function () {
-                        if (subscription && state.push_unsubscribe) {
-                            return statum.act(state.push_unsubscribe, {
-                                endpoint: subscription.endpoint
-                            });
-                        }
-                    });
-                }).catch(function (error) { fail(error); });
-            }
-
-            // ---- per-browser bell state (the user-global flag lied) ----
-            // The dashboard exposes the user's endpoint FINGERPRINTS;
-            // this browser's own pushManager subscription is hashed
-            // the same way and looked up in the list. Each browser
-            // then shows its own truth: subscribed here ("Turn off")
-            // vs subscribed elsewhere only ("Enable" + a count). The
-            // client-side check is authoritative - the server flag is
-            // just a convenience for other consumers.
-            var browserSubscribed = null; // null = still asking the browser
-            var hereNow = false;          // the displayed verdict (click acts on it)
-
-            function sha256Hex16(text) {
-                // Mirrors PushService.endpoint_fingerprint: first 8
-                // bytes of the SHA-256, hex - crypto.subtle needs a
-                // secure context, exactly like pushManager itself.
-                var data = new TextEncoder().encode(text);
-                return crypto.subtle.digest('SHA-256', data).then(function (digest) {
-                    var bytes = new Uint8Array(digest).slice(0, 8);
-                    var hex = '';
-                    for (var index = 0; index < bytes.length; index++) {
-                        hex += ('0' + bytes[index].toString(16)).slice(-2);
+            function syncActivityCount() {
+                var state = statum.read('dashboard');
+                var badge = document.querySelector('[data-activity-new]');
+                if (!state || !badge) return;
+                var rows = state.work_stream || [];
+                var last = readLastVisit();
+                var fresh = 0;
+                if (last !== null) {
+                    for (var index = 0; index < rows.length; index++) {
+                        if (rows[index].at_epoch > last) fresh++;
                     }
-                    return hex;
-                });
-            }
-
-            function myFingerprint() {
-                if (!('serviceWorker' in navigator) || !('PushManager' in window)) {
-                    return Promise.resolve(null);
                 }
-                return navigator.serviceWorker.getRegistration('/').then(function (registration) {
-                    if (!registration) return null;
-                    return registration.pushManager.getSubscription().then(function (sub) {
-                        return sub ? sha256Hex16(sub.endpoint) : null;
-                    });
-                }).catch(function () { return null; });
+                badge.textContent = fresh > 0
+                    ? fresh + ' new since your last visit' : '';
+                badge.hidden = fresh === 0;
             }
 
-            function otherBrowserCount() {
-                // The dashboard's fingerprints minus this browser's own.
-                var mine = browserSubscribed;
-                var list = dashboard().push_endpoints || [];
-                if (mine === null || mine === undefined) return list.length;
-                var others = 0;
-                for (var index = 0; index < list.length; index++) {
-                    if (list[index] !== mine) others++;
-                }
-                return others;
-            }
-
-            function refreshBell() {
-                return myFingerprint().then(function (mine) {
-                    browserSubscribed = mine;
-                    var list = dashboard().push_endpoints || [];
-                    var card = document.querySelector('.push-bell-card');
-                    if (!card) return;
-                    var here = mine !== null && list.indexOf(mine) !== -1;
-                    var others = otherBrowserCount();
-                    var onBtn = card.querySelector('[data-bell-on]');
-                    var offBtn = card.querySelector('[data-bell-off]');
-                    var help = card.querySelector('[data-bell-help]');
-                    var testBtn = card.querySelector('[data-push-test]');
-                    hereNow = here;
-                    if (onBtn) onBtn.hidden = here;
-                    if (offBtn) offBtn.hidden = !here;
-                    if (testBtn) testBtn.hidden = !here;
-                    if (help) {
-                        if (here) {
-                            help.textContent = others > 0
-                                ? '\u2014 this browser receives question, RFC, review and waiting-on-drafts alerts; '
-                                  + others + ' other browser(s) are also subscribed; "Notify me" per task adds its start, failure and completion'
-                                : '\u2014 this browser receives question, RFC, review and waiting-on-drafts alerts; "Notify me" per task adds its start, failure and completion';
-                        } else if (list.length > 0) {
-                            help.textContent = '\u2014 you are subscribed on ' + list.length
-                                + ' other browser(s); enable here too to be notified in this browser';
-                        } else {
-                            help.textContent = '\u2014 be told when an agent asks a question, raises an RFC or review';
-                        }
-                    }
-                });
-            }
-
-            function sendTest() {
-                var state = dashboard();
-                if (!state.push_test) {
-                    fail('not available on this server');
-                    return;
-                }
-                statum.act(state.push_test, {}).catch(function (error) { fail(error); });
-            }
-
-            // The dashboard state refreshes on every pulse; the bell's
-            // per-browser verdict must be re-derived each time (the
-            // browser's own subscription may have changed under us).
-            statum.addStateListener('dashboard', refreshBell);
-            refreshBell();
-
-            document.addEventListener('click', function (event) {
-                if (!event.target.closest) return;
-                var card = event.target.closest('.push-bell-card');
-                if (!card) return;
-                var bell = event.target.closest('[data-push-bell]');
-                var test = event.target.closest('[data-push-test]');
-                if (bell) {
-                    event.preventDefault();
-                    // Follow the DISPLAYED verdict: a browser holding a
-                    // subscription the server no longer knows (a pruned
-                    // row) shows Enable - and enabling re-registers the
-                    // existing subscription, healing it.
-                    if (hereNow) disablePush();
-                    else enablePush();
-                } else if (test) {
-                    event.preventDefault();
-                    sendTest();
-                }
+            statum.afterEvaluate(syncActivityCount);
+            window.addEventListener('beforeunload', function () {
+                try {
+                    window.localStorage.setItem(VISIT_KEY,
+                        String(Math.floor(Date.now() / 1000)));
+                } catch (error) { /* private mode: no baseline, no badge */ }
             });
+
+            // The per-browser bell engine (fingerprint this browser's
+            // subscription against the slot's endpoint list; Enable vs
+            // Turn off follows the DISPLAYED verdict): shared with the
+            // /profile settings card, defined in app.js.
+            window.initPushBell({ stateType: 'dashboard' });
         })();
     }
 </script>
 </head>
 <body>
-    <section>
-        <!-- The advisor hero (goal 27): the page's FIRST and
-             predominant control. Starting a chat here creates it
-             (titled from the first line) and lands on the chat page;
-             the advisor reads fleet state, asks questions and
-             suggests tasks/goals, but never runs commands. -->
-        <section stm-if="dashboard != null" class="advisor-hero card">
-            <!-- Its own block layout (.advisor-hero-form): NOT
-                 form.inline + .form-row, whose inline-flex and 34rem
-                 column cap squeeze a multi-line textarea into a
-                 cramped sliver. The composer spans the card's full
-                 width with a roomy textarea; the button sits below it,
-                 right-aligned. -->
-            <form class="advisor-hero-form"
-                  onsubmit="window.startAdvisorChat(); return false;">
-                <label for="advisor-composer">Ask the advisor</label>
-                <textarea id="advisor-composer" rows="3"
-                          stm-attribute.disabled="!dashboard.advisor_configured"
-                          placeholder="What should the fleet work on next?"></textarea>
-                <div class="advisor-hero-actions">
-                    <button type="submit" class="primary"
-                            stm-attribute.disabled="!dashboard.advisor_configured">Start chat</button>
-                </div>
-            </form>
-            <p class="muted" stm-if="dashboard.advisor_configured">Reads your projects,
-                tasks, goals, rules and discoveries - asks you questions and suggests
-                work. <a href="/chats">Your chats</a></p>
-            <p class="notice" stm-if="!dashboard.advisor_configured">The advisor is not
-                configured - set the model endpoint on the <a href="/models">Admin →
-                Model</a> page to enable it.</p>
-            <p class="notice" stm-if="dashboard.canon_texts == 0">Canon unavailable -
-                no canonical texts found. Set <code>CLANKER_CANON_DIR</code> or install
-                the <code>share/clanker-saddle/canon</code> package files.</p>
-        </section>
-
-        <h2>Dashboard</h2>
+    <div class="stack">
         <p stm-if="dashboard == null">Loading…</p>
 
-        <div stm-if="dashboard != null">
-            <!-- The notification bell (plan 11 task E): subscribe this
-                 browser (permission + service worker + VAPID key) or
-                 turn it off. Block events notify every subscribed user;
-                 per-task "Notify me" adds start/failure/completion. -->
-            <!-- The notification bell (plan 11 task E; per-browser
-                 since the user-global flag lied to second browsers):
-                 THIS browser's own subscription decides Enable vs Turn
-                 off (the JS fingerprints the browser's endpoint and
-                 matches it against the dashboard's list), the helper
-                 text counts the user's OTHER subscribed browsers, and
-                 "Send test notification" fires the real end-to-end
-                 probe through this user's subscriptions. -->
-            <section class="card push-bell-card">
-                <form class="inline">
-                    <button type="button" data-push-bell data-bell-on class="primary"
-                            hidden>Enable notifications</button>
-                    <button type="button" data-push-bell data-bell-off
-                            hidden>Turn off notifications</button>
-                    <button type="button" data-push-test
-                            hidden>Send test notification</button>
-                    <span class="muted" data-bell-help>— be told when an agent asks a
-                        question, raises an RFC or review</span>
+        <div stm-if="dashboard != null" class="stack">
+            <!-- The advisor hero (goal 27; goal 41 task 3 keeps it the
+                 page's first control but gives it the samplers' shape:
+                 the heading and the description sit INSIDE the composer
+                 card, above the textarea, so the primary surface is
+                 self-describing). Starting a chat here creates it
+                 (titled from the first line) and lands on the chat
+                 page; the advisor reads fleet state, asks questions
+                 and suggests tasks/goals, but never runs commands. -->
+            <section stm-if="dashboard != null" class="advisor-hero card">
+                <!-- Its own block layout (.advisor-hero-form): NOT
+                     form.inline + .form-row, whose inline-flex and 34rem
+                     column cap squeeze a multi-line textarea into a
+                     cramped sliver. The composer spans the card's full
+                     width with a roomy textarea; the button sits below
+                     it, right-aligned. -->
+                <form class="advisor-hero-form"
+                      onsubmit="window.startAdvisorChat(); return false;">
+                    <label for="advisor-composer">Ask the advisor</label>
+                    <p class="hint advisor-hero-lede">Reads your projects, tasks, goals, rules and
+                        discoveries — asks you questions and suggests work. It never runs
+                        commands.</p>
+                    <textarea id="advisor-composer" rows="3"
+                              stm-attribute.disabled="!dashboard.advisor_configured"
+                              placeholder="What should the fleet work on next?"></textarea>
+                    <div class="advisor-hero-actions">
+                        <p class="hint advisor-hero-link"><a href="/chats">Your chats</a></p>
+                        <button type="submit" class="btn-primary"
+                                stm-attribute.disabled="!dashboard.advisor_configured">Start chat</button>
+                    </div>
                 </form>
+                <p class="notice" stm-if="!dashboard.advisor_configured">The advisor is not
+                    configured - set the model endpoint on the <a href="/models">Admin →
+                    Model</a> page to enable it.</p>
+                <p class="notice" stm-if="dashboard.canon_texts == 0">Canon unavailable -
+                    no canonical texts found. Set <code>CLANKER_CANON_DIR</code> or install
+                    the <code>share/clanker-saddle/canon</code> package files.</p>
             </section>
 
-            <div class="cards">
-                <section class="card">
-                    <h3>Queue</h3>
-                    <p><strong stm-text="dashboard.ready_count">0</strong> ready to dispatch</p>
-                    <p class="muted"><span stm-text="dashboard.queued_count">0</span> queued ·
-                        <span stm-text="dashboard.active_count">0</span> active ·
-                        <span stm-text="dashboard.blocked_count">0</span> blocked</p>
-                    <p class="muted"><span stm-text="dashboard.done_count">0</span> done ·
-                        <span stm-text="dashboard.failed_count">0</span> failed</p>
-                </section>
-                <section class="card">
-                    <h3>Saddles</h3>
-                    <p><strong stm-text="dashboard.idle_saddle_count">0</strong> idle</p>
-                    <p><a href="/saddles">Manage saddles</a> · <a href="/tasks">Task queue</a></p>
-                </section>
-            </div>
+            <!-- ATTENTION FIRST (goal 41 task 3 / ux-notes §1 A+B): the
+                 left column is why the page exists — pinned needs-a-human
+                 blocks, then active tasks with their latest progress. The
+                 right rail carries the reference material: queue metrics,
+                 the saddle list and the push-bell preference strip (a
+                 settings action, not news — the old full-width opener card
+                 is gone; the preference UI now lives on /profile). Stacks
+                 under the framework's 55rem breakpoint. -->
+            <div class="with-sidebar dash-board">
+                <div class="dash-attention stack">
+                    <section aria-labelledby="needs-a-human-h">
+                        <h2 id="needs-a-human-h" class="dash-heading">Needs a human
+                            <span class="badge badge-warning"
+                                  stm-if="dashboard.pinned.length > 0"
+                                  stm-text="dashboard.pinned.length">0</span></h2>
+                        <p class="text-muted">Open questions, RFCs and reviews across all tasks and
+                            goals, newest first — follow the link to answer on the task or goal
+                            page.</p>
+                        <div class="cards" stm-if="dashboard.pinned.length > 0">
+                            <section stm-for-blockview-in="dashboard.pinned"
+                                     stm-attribute.class="blockview.item_type == 'goal' ? 'card goal-card' : 'card'">
+                                <p>
+                                    <span stm-if="blockview.item_type == 'goal'" class="goal-icon">🎯</span>
+                                    <span stm-attribute.class="'badge badge-kind-' + blockview.item_type"
+                                          stm-text="blockview.item_type">task</span>
+                                    <span stm-attribute.class="'badge badge-' + blockview.block_type" stm-text="blockview.block_type">type</span>
+                                    <span class="text-muted" stm-text="blockview.created_display">when</span></p>
+                                <p><strong stm-text="blockview.question">question</strong></p>
+                                <p stm-if="blockview.item_type == 'task'">
+                                    <a stm-attribute.href="'/tasks/' + blockview.task_id" stm-text="blockview.task_title">task</a></p>
+                                <p stm-if="blockview.item_type == 'goal'">
+                                    <a stm-attribute.href="'/goals/' + blockview.goal_id" stm-text="blockview.goal_title">goal</a></p>
+                            </section>
+                        </div>
+                        <p stm-if="dashboard.pinned.length == 0" class="text-muted">Nothing needs a
+                            human — no open questions, RFCs or reviews.</p>
+                    </section>
 
-            <!-- The scratchpad (goal 8 task 3): jot down batches of
-                 changes before they are ready to be full-on goals.
-                 Check a note off, delete it, or promote it — promote
-                 creates a DRAFT goal (title = the first line, brief =
-                 the whole body) and jumps to its page to finish. -->
-            <h3>Notes</h3>
-            <section class="card">
-                <form stm-action="dashboard.add_note">
-                    <div class="form-row">
-                        <textarea name="body" rows="2" required
-                                  placeholder="Note down a batch of changes before it is ready to be a goal…"></textarea></div>
-                    <button type="submit">Add note</button>
-                </form>
-            </section>
+                    <section aria-labelledby="active-tasks-h"
+                             stm-if="dashboard.active_tasks.length > 0">
+                        <h2 id="active-tasks-h" class="dash-heading">Active tasks</h2>
+                        <div class="table-wrap">
+                            <table class="table-hover">
+                                <thead><tr><th>Task</th><th>Status</th><th>Branch</th><th>Latest progress</th></tr></thead>
+                                <tbody>
+                                    <tr stm-for-active-in="dashboard.active_tasks">
+                                        <td><a stm-attribute.href="'/tasks/' + active.id" stm-text="active.title">task</a></td>
+                                        <td><span stm-attribute.class="'badge badge-' + active.status" stm-text="active.status">status</span></td>
+                                        <td stm-text="active.task_branch">branch</td>
+                                        <td><strong stm-if="active.latest_progress.length > 0" stm-text="active.latest_progress">progress</strong>
+                                            <span class="text-muted" stm-if="active.latest_progress.length > 0" stm-text="active.progress_display">when</span>
+                                            <span class="text-muted" stm-if="active.latest_progress.length == 0">—</span></td>
+                                    </tr>
+                                </tbody>
+                            </table>
+                        </div>
+                    </section>
+                </div>
 
-            <!-- The five newest discoveries (goal 24): the fleet's
-                 knowledge base at a glance; the Discoveries page owns
-                 the searchable listing. -->
-            <div stm-if="dashboard.discoveries.length > 0">
-                <h3>Latest discoveries</h3>
-                <ul>
-                    <li stm-for-discovery-in="dashboard.discoveries" class="md footnote"
-                        stm-markdown="discovery.body">body</li>
-                </ul>
-                <p class="muted"><a href="/discoveries">All discoveries →</a></p>
-            </div>
-            <table stm-if="dashboard.notes.length > 0">
-                <thead><tr><th>Done</th><th>Note</th><th>Created</th><th></th></tr></thead>
-                <tbody>
-                    <tr stm-for-note-in="dashboard.notes">
-                        <td><button type="button" stm-action="note.toggle"
-                                    stm-attribute.class="note.done ? 'approve' : ''"
-                                    stm-text="note.done ? 'Done' : 'Open'">toggle</button></td>
-                        <td><span stm-attribute.class="note.done ? 'footnote' : ''"
-                                  stm-text="note.body">body</span></td>
-                        <td class="muted" stm-text="note.created_display">when</td>
-                        <td>
-                            <span stm-if="note.goal_id > 0">
-                                <span class="goal-icon">🎯</span>
-                                <a stm-attribute.href="'/goals/' + note.goal_id"
-                                   stm-text="note.goal_title">goal</a>
-                            </span>
-                            <form class="inline" stm-if="note.goal_id == 0 && !note.done"
-                                  stm-action="note.promote">
-                                <select name="project_id" required>
-                                    <option stm-for-project-in="dashboard.projects"
-                                            stm-attribute.value="project.id"
-                                            stm-text="project.name">project</option>
-                                </select>
-                                <input type="text" name="branch_name" required
-                                       placeholder="goal/branch" size="14">
-                                <button type="submit" class="primary">Create goal</button>
-                            </form>
-                            <span class="muted" stm-if="note.goal_id == 0 && note.done">—</span>
-                            <form class="inline" stm-action="note.delete">
-                                <button type="submit">Delete</button>
-                            </form>
-                        </td>
-                    </tr>
-                </tbody>
-            </table>
-            <p stm-if="dashboard.notes.length == 0" class="muted">No notes yet — jot down the
-                next batch of changes above.</p>
+                <!-- The right rail: metrics, the saddle verdict list and
+                     the bell strip. Everything here is one-glance state or
+                     a settings action — nothing that outranks the
+                     attention column. -->
+                <aside class="dash-rail stack" aria-label="Fleet status">
+                    <section class="card card-flat">
+                        <h3>Queue</h3>
+                        <p class="dash-metric"><strong class="num" stm-text="dashboard.ready_count">0</strong>
+                            ready to dispatch</p>
+                        <p class="text-muted"><span stm-text="dashboard.queued_count">0</span> queued ·
+                            <span stm-text="dashboard.active_count">0</span> active ·
+                            <span stm-text="dashboard.blocked_count">0</span> blocked</p>
+                        <p class="text-muted"><span stm-text="dashboard.done_count">0</span> done ·
+                            <span stm-text="dashboard.failed_count">0</span> failed</p>
+                        <p><a href="/tasks">Task queue</a></p>
+                    </section>
+
+                    <section class="card card-flat" aria-labelledby="rail-saddles-h">
+                        <h3 id="rail-saddles-h">Saddles</h3>
+                        <p class="dash-metric"><strong class="num" stm-text="dashboard.idle_saddle_count">0</strong>
+                            idle</p>
+                        <ul class="dash-saddles">
+                            <li stm-for-saddle-in="dashboard.saddles">
+                                <span stm-attribute.class="'badge badge-' + saddle.status"
+                                      stm-text="saddle.status">status</span>
+                                <strong stm-text="saddle.name">name</strong>
+                                <span stm-if="saddle.current_goal_id > 0">·
+                                    <a stm-attribute.href="'/goals/' + saddle.current_goal_id"
+                                       stm-text="saddle.current_goal_title">goal</a></span>
+                                <span stm-if="saddle.current_goal_id == 0 && saddle.current_task_id > 0">·
+                                    <a stm-attribute.href="'/tasks/' + saddle.current_task_id"
+                                       stm-text="saddle.current_task_title">task</a></span>
+                            </li>
+                            <li stm-if="dashboard.saddles.length == 0" class="text-muted">No saddles
+                                registered.</li>
+                        </ul>
+                        <p><a href="/saddles">Manage saddles</a></p>
+                    </section>
 
-            <h3 stm-if="dashboard.pinned.length > 0">Needs a human
-                (<span stm-text="dashboard.pinned.length">0</span> open)</h3>
-            <p class="muted" stm-if="dashboard.pinned.length > 0">Open questions, RFCs and
-                reviews across all tasks and goals, newest first — follow the link to
-                answer on the task or goal page.</p>
-            <div class="cards" stm-if="dashboard.pinned.length > 0">
-                <section stm-for-blockview-in="dashboard.pinned"
-                         stm-attribute.class="blockview.item_type == 'goal' ? 'card goal-card' : 'card'">
-                    <p>
-                        <span stm-if="blockview.item_type == 'goal'" class="goal-icon">🎯</span>
-                        <span stm-attribute.class="'badge badge-kind-' + blockview.item_type"
-                              stm-text="blockview.item_type">task</span>
-                        <span stm-attribute.class="'badge badge-' + blockview.block_type" stm-text="blockview.block_type">type</span>
-                        <span class="muted" stm-text="blockview.created_display">when</span></p>
-                    <p><strong stm-text="blockview.question">question</strong></p>
-                    <p stm-if="blockview.item_type == 'task'">
-                        <a stm-attribute.href="'/tasks/' + blockview.task_id" stm-text="blockview.task_title">task</a></p>
-                    <p stm-if="blockview.item_type == 'goal'">
-                        <a stm-attribute.href="'/goals/' + blockview.goal_id" stm-text="blockview.goal_title">goal</a></p>
-                </section>
+                    <!-- The notification bell (plan 11 task E; per-browser
+                         since the user-global flag lied to second
+                         browsers): THIS browser's own subscription decides
+                         Enable vs Turn off (the JS fingerprints the
+                         browser's endpoint and matches it against the
+                         slot's list), the helper text counts the user's
+                         OTHER subscribed browsers, and "Send test
+                         notification" fires the real end-to-end probe
+                         through this user's subscriptions. A compact
+                         strip in the rail (goal 41 task 3): the
+                         preference UI lives on /profile. -->
+                    <section class="card card-flat push-bell-card">
+                        <h3>Notifications</h3>
+                        <form class="stack">
+                            <button type="button" class="btn-sm btn-primary" data-push-bell
+                                    data-bell-on hidden>Enable notifications</button>
+                            <button type="button" class="btn-sm" data-push-bell data-bell-off
+                                    hidden>Turn off notifications</button>
+                            <button type="button" class="btn-sm" data-push-test
+                                    hidden>Send test notification</button>
+                            <p class="hint" data-bell-help>— be told when an agent asks a question,
+                                raises an RFC or review</p>
+                            <p class="hint"><a href="/profile">Notification settings</a></p>
+                        </form>
+                    </section>
+                </aside>
             </div>
-            <p stm-if="dashboard.pinned.length == 0" class="muted">Nothing needs a human —
-                no open questions, RFCs or reviews.</p>
 
-            <h3 stm-if="dashboard.active_tasks.length > 0">Active tasks</h3>
-            <table stm-if="dashboard.active_tasks.length > 0">
-                <thead><tr><th>Task</th><th>Status</th><th>Branch</th><th>Latest progress</th></tr></thead>
-                <tbody>
-                    <tr stm-for-active-in="dashboard.active_tasks">
-                        <td><a stm-attribute.href="'/tasks/' + active.id" stm-text="active.title">task</a></td>
-                        <td><span stm-attribute.class="'badge badge-' + active.status" stm-text="active.status">status</span></td>
-                        <td stm-text="active.task_branch">branch</td>
-                        <td><strong stm-if="active.latest_progress.length > 0" stm-text="active.latest_progress">progress</strong>
-                            <span class="muted" stm-if="active.latest_progress.length > 0" stm-text="active.progress_display">when</span>
-                            <span class="muted" stm-if="active.latest_progress.length == 0">—</span></td>
-                    </tr>
-                </tbody>
-            </table>
+            <!-- WORK STREAM (goal 41 T10, ux-notes bet 5): the page's
+                 PRIMARY feed — task progress, goal narrative and
+                 event-log highlights joined into ONE time-ordered
+                 stream (the slot's work_stream: the timeline window
+                 projected into three row grammars, newest 15). It
+                 replaces the old activity expander as the dashboard's
+                 feed; the underlying surfaces stay — the goal page
+                 owns the narrative, the task page's Log tab owns the
+                 full history. The stream keeps the ambient expander's
+                 quiet frame but sits OPEN by default: this is the
+                 news, not reference material. The localStorage
+                 new-since badge counts stream rows now. -->
+            <details class="dash-ambient" data-activity-expander data-work-stream open>
+                <summary>Work stream <span class="badge badge-info" data-activity-new hidden>new</span></summary>
+                <div class="dash-ambient-body">
+                    <p class="text-muted">The fleet's latest work — task progress, goal
+                        events and highlights, newest first.</p>
+                    <ul class="work-stream">
+                        <li stm-for-item-in="dashboard.work_stream">
+                            <span stm-attribute.class="'badge badge-stream-' + item.kind"
+                                  stm-text="item.event_kind">kind</span>
+                            <span class="work-stream-when text-muted num"
+                                  stm-text="item.when_display">when</span>
+                            <span class="work-stream-text"><span stm-if="item.href.length > 0"
+                                  class="work-stream-actor"><a stm-attribute.href="item.href"
+                                  stm-text="item.actor">actor</a></span>
+                                <span stm-attribute.class="item.kind == 'log_highlight' && item.event_kind == 'comment' ? 'footnote' : ''"
+                                      stm-text="item.text">text</span></span>
+                        </li>
+                        <li stm-if="dashboard.work_stream.length == 0"
+                            class="text-muted">Nothing yet.</li>
+                    </ul>
+                    <p class="text-muted"><a href="/tasks">Task queue</a> ·
+                        <a href="/goals">Goals</a></p>
+                </div>
+            </details>
 
-            <h3>Saddles</h3>
-            <div class="cards">
-                <section class="card" stm-for-saddle-in="dashboard.saddles">
-                    <h3 stm-text="saddle.name">name</h3>
-                    <p><span stm-attribute.class="'badge badge-' + saddle.status" stm-text="saddle.status">status</span>
-                        <span class="muted" stm-text="saddle.host">host</span></p>
-                    <p stm-if="saddle.current_goal_id > 0">Supervising goal
-                        <a stm-attribute.href="'/goals/' + saddle.current_goal_id"
-                           stm-text="saddle.current_goal_title">goal</a>
-                        <span class="badge badge-kind-goal">goal</span></p>
-                    <p stm-if="saddle.current_task_id > 0 && saddle.current_goal_id == 0">Working on
-                        <a stm-attribute.href="'/tasks/' + saddle.current_task_id" stm-text="saddle.current_task_title">task</a></p>
-                    <p stm-if="saddle.current_task_id == 0" class="muted">No current task</p>
-                </section>
-                <section class="card" stm-if="dashboard.saddles.length == 0">
-                    <p class="muted">No saddles registered.</p>
-                </section>
-            </div>
+            <!-- AMBIENT ZONES (goal 41 task 3; the mocks/ sampler
+                 review: reference material starts COLLAPSED so a visit
+                 for "answer the question" never scrolls past it). -->
+            <div class="expander-group dash-ambient">
+                <!-- The scratchpad (goal 8 task 3): jot down batches of
+                     changes before they are ready to be full-on goals.
+                     Check a note off, delete it, or promote it — promote
+                     creates a DRAFT goal (title = the first line, brief =
+                     the whole body) and jumps to its page to finish. -->
+                <details stm-if="dashboard.notes.length > 0">
+                    <summary>Notes <span class="badge"
+                            stm-text="dashboard.notes.length">0</span></summary>
+                    <div class="dash-ambient-body">
+                        <table>
+                            <thead><tr><th>Done</th><th>Note</th><th>Created</th><th></th></tr></thead>
+                            <tbody>
+                                <tr stm-for-note-in="dashboard.notes">
+                                    <td><button type="button" stm-action="note.toggle"
+                                                stm-attribute.class="note.done ? 'approve' : ''"
+                                                stm-text="note.done ? 'Done' : 'Open'">toggle</button></td>
+                                    <td><span stm-attribute.class="note.done ? 'footnote' : ''"
+                                              stm-text="note.body">body</span></td>
+                                    <td class="text-muted" stm-text="note.created_display">when</td>
+                                    <td>
+                                        <span stm-if="note.goal_id > 0">
+                                            <span class="goal-icon">🎯</span>
+                                            <a stm-attribute.href="'/goals/' + note.goal_id"
+                                               stm-text="note.goal_title">goal</a>
+                                        </span>
+                                        <form class="cluster" stm-if="note.goal_id == 0 && !note.done"
+                                              stm-action="note.promote">
+                                            <select name="project_id" required>
+                                                <option stm-for-project-in="dashboard.projects"
+                                                        stm-attribute.value="project.id"
+                                                        stm-text="project.name">project</option>
+                                            </select>
+                                            <input type="text" name="branch_name" required
+                                                   placeholder="goal/branch" size="14">
+                                            <button type="submit" class="btn-primary">Create goal</button>
+                                        </form>
+                                        <span class="text-muted" stm-if="note.goal_id == 0 && note.done">—</span>
+                                        <form class="cluster" stm-action="note.delete">
+                                            <button type="submit">Delete</button>
+                                        </form>
+                                    </td>
+                                </tr>
+                            </tbody>
+                        </table>
+                    </div>
+                </details>
 
-            <h3>Activity</h3>
-            <p class="muted">The last <span stm-text="dashboard.activity.length">0</span> events
-                across all tasks, newest first.</p>
-            <table>
-                <thead><tr><th>When</th><th>Task</th><th>Event</th></tr></thead>
-                <tbody>
-                    <tr stm-for-item-in="dashboard.activity">
-                        <td stm-text="item.when_display">when</td>
-                        <td stm-if="item.goal_id > 0">
-                            <span class="badge badge-kind-goal">goal</span>
-                            <a stm-attribute.href="'/goals/' + item.goal_id" stm-text="item.goal_title">goal</a>
-                            <a stm-if="item.task_id > 0" class="muted"
-                               stm-attribute.href="'/tasks/' + item.task_id"
-                               >&rarr; <span stm-text="'task #' + item.task_id">task</span></a></td>
-                        <td stm-if="item.goal_id == 0"><a stm-attribute.href="'/tasks/' + item.task_id" stm-text="item.task_title">task</a>
-                        <td><span stm-attribute.class="'badge badge-' + item.kind" stm-text="item.kind">kind</span>
-                            <!-- Comment rows read as footnotes (plan 13
-                                 B4): the body leads, muted and smaller. -->
-                            <span stm-attribute.class="item.kind == 'comment' || item.kind == 'goal-comment' || item.kind == 'goal-system' ? 'footnote' : ''"
-                                  stm-text="item.text">activity</span></td>
-                    </tr>
-                    <tr stm-if="dashboard.activity.length == 0"><td colspan="3" class="muted">Nothing yet.</td></tr>
-                </tbody>
-            </table>
+                <!-- The five newest discoveries (goal 24): the fleet's
+                     knowledge base at a glance; the Discoveries page owns
+                     the searchable listing. -->
+                <details stm-if="dashboard.discoveries.length > 0">
+                    <summary>Latest discoveries <span class="badge"
+                            stm-text="dashboard.discoveries.length">0</span></summary>
+                    <div class="dash-ambient-body">
+                        <ul>
+                            <li stm-for-discovery-in="dashboard.discoveries" class="md footnote"
+                                stm-markdown="discovery.body">body</li>
+                        </ul>
+                        <p class="text-muted"><a href="/discoveries">All discoveries →</a></p>
+                    </div>
+                </details>
+            </div>
         </div>
-    </section>
+    </div>
 </body>
 </html>

+ 23 - 11
src/pages/login.html

@@ -6,6 +6,14 @@
     <pstm-uri>/login</pstm-uri>
     <pstm-title>ClankerSaddle — Log in</pstm-title>
     <script pstm-res-src="statum.js"></script>
+    <link rel="stylesheet" pstm-res-href="spry.css">
+    <link rel="stylesheet" pstm-res-href="spry-theme-airy.css">
+    <link rel="stylesheet" pstm-res-href="spry-theme-dwarf.css">
+    <link rel="stylesheet" pstm-res-href="spry-theme-formal.css">
+    <link rel="stylesheet" pstm-res-href="spry-theme-glassy.css">
+    <link rel="stylesheet" pstm-res-href="spry-theme-h2o.css">
+    <link rel="stylesheet" pstm-res-href="spry-theme-living-sky.css">
+    <link rel="stylesheet" pstm-res-href="spry-theme-modern.css">
     <link rel="stylesheet" pstm-res-href="app.css">
     <link rel="icon" type="image/png" pstm-res-href="clanker-saddle.png">
     <link rel="apple-touch-icon" type="image/png" pstm-res-href="clanker-saddle.png">
@@ -27,21 +35,25 @@
     </script>
 </head>
 <body>
-    <section class="login">
-        <div class="login-card">
+    <main class="login">
+        <div class="card login-card">
             <img class="login-emblem" alt="" pstm-res-src="clanker-saddle-emblem.png">
             <h1>ClankerSaddle</h1>
-            <p class="muted">The agent works while you sleep.</p>
+            <p class="text-muted">The agent works while you sleep.</p>
             <p stm-if="auth != null">Welcome <strong stm-text="auth.display_name">user</strong>!</p>
-            <form stm-if="auth == null" stm-action="page.login">
-                <div class="form-row"><label for="login-username">Username</label>
-                    <input id="login-username" type="text" name="username" required autocomplete="username" autofocus></div>
-                <div class="form-row"><label for="login-password">Password</label>
-                    <input id="login-password" type="password" name="password" required autocomplete="current-password"></div>
-                <button type="submit" class="primary">Log in</button>
+            <form stm-if="auth == null" stm-action="page.login" class="stack">
+                <div class="field">
+                    <label for="login-username">Username</label>
+                    <input id="login-username" type="text" name="username" required autocomplete="username" autofocus>
+                </div>
+                <div class="field">
+                    <label for="login-password">Password</label>
+                    <input id="login-password" type="password" name="password" required autocomplete="current-password">
+                </div>
+                <button type="submit" class="btn-primary btn-block">Log in</button>
             </form>
-            <p class="error" id="login-error" hidden></p>
+            <p class="text-danger" id="login-error" hidden></p>
         </div>
-    </section>
+    </main>
 </body>
 </html>

+ 58 - 46
src/pages/main.html

@@ -1,74 +1,86 @@
 <!DOCTYPE html>
-<html lang="en">
+<html lang="en" data-mode="auto">
 <head>
     <meta charset="utf-8">
     <meta name="viewport" content="width=device-width, initial-scale=1.0">
     <pstm-name>main</pstm-name>
     <pstm-title>ClankerSaddle</pstm-title>
     <script pstm-res-src="statum.js"></script>
+    <link rel="stylesheet" pstm-res-href="spry.css">
+    <link rel="stylesheet" pstm-res-href="spry-theme-airy.css">
+    <link rel="stylesheet" pstm-res-href="spry-theme-dwarf.css">
+    <link rel="stylesheet" pstm-res-href="spry-theme-formal.css">
+    <link rel="stylesheet" pstm-res-href="spry-theme-glassy.css">
+    <link rel="stylesheet" pstm-res-href="spry-theme-h2o.css">
+    <link rel="stylesheet" pstm-res-href="spry-theme-living-sky.css">
+    <link rel="stylesheet" pstm-res-href="spry-theme-modern.css">
     <link rel="stylesheet" pstm-res-href="app.css">
     <link rel="icon" type="image/png" pstm-res-href="clanker-saddle.png">
-    <link rel="apple-touch-icon" pstm-res-href="clanker-saddle.png">
+    <link rel="apple-touch-icon" type="image/png" pstm-res-href="clanker-saddle.png">
     <link rel="stm-guard" require="auth != null || location.pathname == '/login'" href="/login">
     <script pstm-res-src="app.js"></script>
 </head>
 <body>
-    <header>
-        <h1><a href="/"><img class="brand-emblem" alt="" pstm-res-src="clanker-saddle-emblem.png">ClankerSaddle</a></h1>
-        <nav stm-if="auth != null">
+    <header class="topbar">
+        <a class="topbar-brand" href="/"><img class="brand-emblem" alt="" pstm-res-src="clanker-saddle-emblem.png">ClankerSaddle</a>
+        <nav class="nav" stm-if="auth != null">
             <!-- spry:nav-begin -->
-            <details class="nav-menu" name="nav-menu">
-                <summary>Work</summary>
-                <div class="nav-dropdown">
-                    <a href="/">Dashboard</a>
-                    <a href="/tasks">Tasks</a>
-                    <a class="nav-goal" href="/goals"><em class="goal-icon">🎯</em> Goals</a>
-                    <a href="/chats">Chats</a>
-                </div>
-            </details>
-            <details class="nav-menu" name="nav-menu">
-                <summary>Library</summary>
-                <div class="nav-dropdown">
-                    <a href="/projects">Projects</a>
-                    <a href="/rules">Rules</a>
-                    <a href="/stacks">Stacks</a>
-                    <a href="/discoveries">Discoveries</a>
-                </div>
-            </details>
-            <details class="nav-menu" name="nav-menu">
-                <summary>Admin</summary>
-                <div class="nav-dropdown">
-                    <a href="/saddles">Saddles</a>
-                    <!-- The users and model links are display-guarded
-                         (the pages' entrypoint guards are
-                         authoritative); auth.permissions rides the
-                         signed auth slot, so an admin sees them
-                         everywhere. -->
-                    <a stm-if="auth != null && auth.permissions.indexOf('admin') != -1" href="/users">Users</a>
-                    <a stm-if="auth != null && auth.permissions.indexOf('admin') != -1" href="/models">Model</a>
-                </div>
-            </details>
+            <ul class="nav" role="menubar">
+                <li class="has-submenu">
+                    <a href="/">Work</a>
+                    <ul class="submenu" role="menu">
+                        <li><a href="/">Dashboard</a></li>
+                        <li><a href="/tasks">Tasks</a></li>
+                        <li><a class="nav-goal" href="/goals"><em class="goal-icon">🎯</em> Goals</a></li>
+                        <li><a href="/chats">Chats</a></li>
+                    </ul>
+                </li>
+                <li class="has-submenu">
+                    <a href="/projects">Library</a>
+                    <ul class="submenu" role="menu">
+                        <li><a href="/projects">Projects</a></li>
+                        <li><a href="/rules">Rules</a></li>
+                        <li><a href="/stacks">Stacks</a></li>
+                        <li><a href="/discoveries">Discoveries</a></li>
+                    </ul>
+                </li>
+                <li class="has-submenu">
+                    <a href="/saddles">Admin</a>
+                    <ul class="submenu" role="menu">
+                        <li><a href="/saddles">Saddles</a></li>
+                        <!-- The users and model links are display-guarded
+                             (the pages' entrypoint guards are
+                             authoritative); auth.permissions rides the
+                             signed auth slot, so an admin sees them
+                             everywhere. -->
+                        <li stm-if="auth != null && auth.permissions.indexOf('admin') != -1"><a href="/users">Users</a></li>
+                        <li stm-if="auth != null && auth.permissions.indexOf('admin') != -1"><a href="/models">Model</a></li>
+                    </ul>
+                </li>
+            </ul>
             <!-- spry:nav-end -->
             <span class="nav-auth">
-                <details class="nav-menu" name="nav-menu">
-                    <summary>Account</summary>
-                    <div class="nav-dropdown">
-                        <span class="nav-user" stm-text="auth.username">user</span>
-                        <a href="/profile" title="Your profile">Profile</a>
-                        <button type="button" class="inline" stm-action="auth.logout">Log out</button>
-                    </div>
-                </details>
+                <ul class="menu" role="menu">
+                    <li class="has-submenu">
+                        <a href="/profile">Account</a>
+                        <ul class="submenu" role="menu">
+                            <li class="nav-user" stm-text="auth.username">user</li>
+                            <li><a href="/profile" title="Your profile">Profile</a></li>
+                            <li><button type="button" class="btn-ghost" stm-action="auth.logout">Log out</button></li>
+                        </ul>
+                    </li>
+                </ul>
                 <!-- spry:nav-auth-begin -->
                 <!-- spry:nav-auth-end -->
             </span>
         </nav>
     </header>
-    <main>
+    <main class="container">
         <div stm-preloader>Loading…</div>
         <div stm-content hidden>
             <pstm-content></pstm-content>
         </div>
     </main>
-    <footer><p>ClankerSaddle — the agent works while you sleep.</p></footer>
+    <footer class="site-footer"><p>ClankerSaddle — the agent works while you sleep.</p></footer>
 </body>
 </html>

+ 58 - 40
src/pages/model_settings.html

@@ -5,56 +5,74 @@
     <pstm-template>main</pstm-template>
 </head>
 <body>
-    <section>
+    <!-- Goal 41 T8: the model page on the Spry form conventions — the
+         readiness line as an alert, the two endpoint groups as cards
+         with capped-width fields (.form-rows), labelled controls.
+         The form itself is unchanged (same stm-action, same
+         write-only key handling); it was never an expander, so no
+         dialog is needed — the page IS the editor. -->
+    <div class="stack">
         <h2>Model</h2>
-        <p class="muted">The deployment's model configuration: the OpenAI-compatible endpoint
+        <p class="text-muted">The deployment's model configuration: the OpenAI-compatible endpoint
             every saddle assignment is sent to, and the advisor's optional overrides. Stored
             in the database — environment variables no longer configure it (a pre-page
             deployment's SADDLE_LLM_* environment is imported once, on first read).</p>
         <p stm-if="model == null">Loading…</p>
 
-        <div stm-if="model != null">
-            <p stm-if="!model.dispatch_ready"><span class="badge badge-offline">paused</span>
-                <span class="muted">No base URL is configured — task assignment is paused and
-                    the advisor is disabled until one is saved.</span></p>
-            <p stm-if="model.dispatch_ready"><span class="badge badge-done">ready</span>
-                <span class="muted">Updated <span stm-text="model.updated_display">when</span>.</span></p>
+        <div stm-if="model != null" class="stack">
+            <p stm-if="!model.dispatch_ready" class="alert alert-warning"><span class="badge badge-offline">paused</span>
+                No base URL is configured — task assignment is paused and
+                the advisor is disabled until one is saved.</p>
+            <p stm-if="model.dispatch_ready" class="alert alert-info"><span class="badge badge-done">ready</span>
+                Updated <span stm-text="model.updated_display">when</span>.</p>
 
-            <form stm-action="model.save">
-                <h3>Main endpoint</h3>
-                <p class="muted">Used by every saddle assignment (the harness's LLM).</p>
-                <div class="form-row"><label>Base URL</label>
-                    <input type="text" name="base_url" placeholder="http://127.0.0.1:9999/v1"
-                           stm-attribute.value="model.base_url"></div>
-                <div class="form-row"><label>Model</label>
-                    <input type="text" name="model" placeholder="(the provider's default)"
-                           stm-attribute.value="model.model"></div>
-                <div class="form-row"><label>API key</label>
-                    <input type="password" name="api_key" autocomplete="off"
-                           placeholder="Leave empty to keep the stored key">
-                    <span class="muted" stm-if="model.api_key_set">A key is stored
-                        (<span stm-text="model.api_key_hint">hint</span>)</span>
-                    <label class="inline"><input type="checkbox" name="clear_api_key" value="1"> Clear the stored key</label></div>
+            <form stm-action="model.save" class="stack">
+                <section class="card">
+                    <div class="card-header">
+                        <h3>Main endpoint</h3>
+                        <p class="text-muted">Used by every saddle assignment (the harness's LLM).</p>
+                    </div>
+                    <div class="card-body form-rows">
+                        <div class="field"><label for="model-base-url">Base URL</label>
+                            <input type="text" id="model-base-url" name="base_url" placeholder="http://127.0.0.1:9999/v1"
+                                   stm-attribute.value="model.base_url"></div>
+                        <div class="field"><label for="model-name">Model</label>
+                            <input type="text" id="model-name" name="model" placeholder="(the provider's default)"
+                                   stm-attribute.value="model.model"></div>
+                        <div class="field"><label for="model-api-key">API key</label>
+                            <input type="password" id="model-api-key" name="api_key" autocomplete="off"
+                                   placeholder="Leave empty to keep the stored key">
+                            <span class="text-muted" stm-if="model.api_key_set">A key is stored
+                                (<span stm-text="model.api_key_hint">hint</span>)</span>
+                            <label class="choice"><input type="checkbox" name="clear_api_key" value="1"> Clear the stored key</label></div>
+                    </div>
+                </section>
 
-                <h3>Advisor endpoint (optional)</h3>
-                <p class="muted">Overrides for the AI Chat advisor. Leave the base URL empty to
-                    use the main endpoint's fields.</p>
-                <div class="form-row"><label>Base URL</label>
-                    <input type="text" name="advisor_base_url" placeholder="(use the main endpoint)"
-                           stm-attribute.value="model.advisor_base_url"></div>
-                <div class="form-row"><label>Model</label>
-                    <input type="text" name="advisor_model" placeholder="(use the main endpoint's model)"
-                           stm-attribute.value="model.advisor_model"></div>
-                <div class="form-row"><label>API key</label>
-                    <input type="password" name="advisor_api_key" autocomplete="off"
-                           placeholder="Leave empty to keep the stored key">
-                    <span class="muted" stm-if="model.advisor_api_key_set">A key is stored
-                        (<span stm-text="model.advisor_api_key_hint">hint</span>)</span>
-                    <label class="inline"><input type="checkbox" name="clear_advisor_api_key" value="1"> Clear the stored key</label></div>
+                <section class="card">
+                    <div class="card-header">
+                        <h3>Advisor endpoint <span class="text-muted">(optional)</span></h3>
+                        <p class="text-muted">Overrides for the AI Chat advisor. Leave the base URL empty to
+                            use the main endpoint's fields.</p>
+                    </div>
+                    <div class="card-body form-rows">
+                        <div class="field"><label for="advisor-base-url">Base URL</label>
+                            <input type="text" id="advisor-base-url" name="advisor_base_url" placeholder="(use the main endpoint)"
+                                   stm-attribute.value="model.advisor_base_url"></div>
+                        <div class="field"><label for="advisor-model">Model</label>
+                            <input type="text" id="advisor-model" name="advisor_model" placeholder="(use the main endpoint's model)"
+                                   stm-attribute.value="model.advisor_model"></div>
+                        <div class="field"><label for="advisor-api-key">API key</label>
+                            <input type="password" id="advisor-api-key" name="advisor_api_key" autocomplete="off"
+                                   placeholder="Leave empty to keep the stored key">
+                            <span class="text-muted" stm-if="model.advisor_api_key_set">A key is stored
+                                (<span stm-text="model.advisor_api_key_hint">hint</span>)</span>
+                            <label class="choice"><input type="checkbox" name="clear_advisor_api_key" value="1"> Clear the stored key</label></div>
+                    </div>
+                </section>
 
-                <button type="submit">Save configuration</button>
+                <div><button type="submit" class="btn-primary">Save configuration</button></div>
             </form>
         </div>
-    </section>
+    </div>
 </body>
 </html>

+ 103 - 18
src/pages/profile.html

@@ -3,35 +3,120 @@
 <head>
     <pstm-uri>/profile</pstm-uri>
     <pstm-template>main</pstm-template>
+<script>
+    // The Notifications card (goal 41 task 3): the push-bell
+    // preference UI moved here from the dashboard's full-width opener
+    // card. The per-browser engine is shared with the dashboard's
+    // compact rail strip (window.initPushBell in app.js); it reads
+    // THIS page's slot (stateType: 'profile'), which carries the same
+    // VAPID key, endpoint fingerprints and push actions the dashboard
+    // exposes - the server actions are the existing DTOs, untouched.
+    if (window.statum && window.initPushBell) {
+        window.initPushBell({ stateType: 'profile' });
+    }
+</script>
 </head>
 <body>
-    <section>
+    <section class="stack">
         <h2>Your profile</h2>
         <p stm-if="profile == null">Loading…</p>
 
-        <div stm-if="profile != null">
+        <div stm-if="profile != null" class="stack">
             <div class="card">
                 <h3 stm-text="profile.name_display">name</h3>
                 <p><span stm-attribute.class="profile.is_admin ? 'badge badge-admin' : 'badge'"
                          stm-text="profile.is_admin ? 'admin' : 'user'">role</span></p>
-                <p class="muted">Username: <strong stm-text="profile.username">username</strong></p>
-                <p class="muted">Email: <span stm-text="profile.email">email</span></p>
-                <p class="muted">Member since <span stm-text="profile.created_display">when</span></p>
+                <p class="text-muted">Username: <strong stm-text="profile.username">username</strong></p>
+                <p class="text-muted">Email: <span stm-text="profile.email">email</span></p>
+                <p class="text-muted">Member since <span stm-text="profile.created_display">when</span></p>
             </div>
 
-            <h3>Change password</h3>
-            <form stm-action="profile.change_password" style="max-width: 24rem">
-                <div class="form-row"><label for="current-password">Current password</label>
-                    <input id="current-password" type="password" name="current_password"
-                           required autocomplete="current-password"></div>
-                <div class="form-row"><label for="new-password">New password</label>
-                    <input id="new-password" type="password" name="new_password"
-                           required autocomplete="new-password"></div>
-                <div class="form-row"><label for="confirm-password">Confirm new password</label>
-                    <input id="confirm-password" type="password" name="confirm_password"
-                           required autocomplete="new-password"></div>
-                <button type="submit">Change password</button>
-            </form>
+            <div class="card">
+                <h3>Appearance</h3>
+                <form stm-action="profile.set_theme" class="stack">
+                    <div class="field">
+                        <label>Theme</label>
+                        <div class="choices">
+                            <label class="choice"><input type="radio" name="theme" value=""
+                                stm-attribute.checked="profile.theme == '' || profile.theme == 'core'">
+                                Spry (default)</label>
+                            <label class="choice"><input type="radio" name="theme" value="airy"
+                                stm-attribute.checked="profile.theme == 'airy'">
+                                Airy</label>
+                            <label class="choice"><input type="radio" name="theme" value="dwarf"
+                                stm-attribute.checked="profile.theme == 'dwarf'">
+                                Dwarf</label>
+                            <label class="choice"><input type="radio" name="theme" value="formal"
+                                stm-attribute.checked="profile.theme == 'formal'">
+                                Formal</label>
+                            <label class="choice"><input type="radio" name="theme" value="glassy"
+                                stm-attribute.checked="profile.theme == 'glassy'">
+                                Glassy</label>
+                            <label class="choice"><input type="radio" name="theme" value="h2o"
+                                stm-attribute.checked="profile.theme == 'h2o'">
+                                H2O</label>
+                            <label class="choice"><input type="radio" name="theme" value="living-sky"
+                                stm-attribute.checked="profile.theme == 'living-sky'">
+                                Living Sky</label>
+                            <label class="choice"><input type="radio" name="theme" value="modern"
+                                stm-attribute.checked="profile.theme == 'modern'">
+                                Modern</label>
+                        </div>
+                        <p class="hint">The theme repaints the whole interface. Your choice is
+                            remembered for this account on this device and applies immediately.</p>
+                    </div>
+                    <div class="field">
+                        <label>Colour mode</label>
+                        <div class="choices">
+                            <label class="choice"><input type="radio" name="mode" value="auto"
+                                stm-attribute.checked="profile.mode == 'auto'">
+                                Auto (follow system)</label>
+                            <label class="choice"><input type="radio" name="mode" value="light"
+                                stm-attribute.checked="profile.mode == 'light'">
+                                Light</label>
+                            <label class="choice"><input type="radio" name="mode" value="dark"
+                                stm-attribute.checked="profile.mode == 'dark'">
+                                Dark</label>
+                        </div>
+                        <p class="hint">Auto follows your device's light/dark preference.</p>
+                    </div>
+                    <div><button type="submit" class="btn-primary">Save appearance</button></div>
+                </form>
+            </div>
+
+            <div class="card push-bell-card">
+                <h3>Notifications</h3>
+                <p class="text-muted">Be told when an agent asks a question, raises an RFC or
+                    review. The preference is per browser: each one you enable receives the
+                    alerts; per-task "Notify me" adds that task's start, failure and
+                    completion.</p>
+                <form class="stack">
+                    <button type="button" data-push-bell data-bell-on
+                            hidden>Enable notifications</button>
+                    <button type="button" data-push-bell data-bell-off
+                            hidden>Turn off notifications</button>
+                    <button type="button" data-push-test
+                            hidden>Send test notification</button>
+                    <p class="hint" data-bell-help>— be told when an agent asks a question,
+                        raises an RFC or review</p>
+                </form>
+            </div>
+
+            <div class="card">
+                <h3>Change password</h3>
+                <form stm-action="profile.change_password" class="stack">
+                    <div class="field"><label for="current-password">Current password</label>
+                        <input id="current-password" type="password" name="current_password"
+                               required autocomplete="current-password"></div>
+                    <div class="field"><label for="new-password">New password</label>
+                        <input id="new-password" type="password" name="new_password"
+                               required autocomplete="new-password"></div>
+                    <div class="field"><label for="confirm-password">Confirm new password</label>
+                        <input id="confirm-password" type="password" name="confirm_password"
+                               required autocomplete="new-password"></div>
+                    <div><button type="submit">Change password</button></div>
+                </form>
+            </div>
         </div>
     </section>
 </body>

+ 2 - 2
src/pages/progress_feed.html

@@ -13,12 +13,12 @@
     <pstm-input name="entries"/>
     <div stm-if="entries.length > 0">
         <div class="progress-latest md" stm-markdown="entries[0].summary">latest</div>
-        <p class="muted" stm-text="'reported ' + entries[0].when_display">when</p>
+        <p class="text-muted" stm-text="'reported ' + entries[0].when_display">when</p>
         <details class="history-expander" stm-if="entries.length > 1">
             <summary>Show history (<span stm-text="entries.length">0</span> updates)</summary>
             <ul class="progress-history">
                 <li stm-for-item-in="entries">
-                    <span class="muted" stm-text="item.when_display">when</span> —
+                    <span class="text-muted" stm-text="item.when_display">when</span> —
                     <span stm-text="item.summary">summary</span>
                 </li>
             </ul>

+ 97 - 49
src/pages/project_detail.html

@@ -7,35 +7,54 @@
 <body>
     <section>
         <h2 stm-if="project != null && project.exists" stm-text="project.name">Project</h2>
-        <p stm-if="project != null && !project.exists" class="muted">Project not found.</p>
+        <p stm-if="project != null && !project.exists" class="text-muted">Project not found.</p>
 
         <div stm-if="project != null && project.exists">
-            <p><a href="/projects">All projects</a></p>
-            <div class="cards">
-                <section class="card">
+            <!-- Goal 41 T9: breadcrumbs replace the hand-drawn "All
+                 projects" anchor (the partial is T1's; the crumbs
+                 vector is the entrypoint's). -->
+            <pstm-fragment name="breadcrumb" crumbs="project.crumbs"/>
+
+            <!-- Goal 41 T9 (ux-notes projects): Details and Edit were two
+                 side-by-side cards - a form for exactly what Details
+                 displays. The card is now ONE read view with an in-place
+                 Edit toggle (the data-long-form engine in app.js: the
+                 edit half prefills from the slot's current values via
+                 data-lf-src, so the editor shows what IS, not a cold
+                 empty form). -->
+            <section class="card" data-long-form data-field="project">
+                <div class="card-header cluster cluster-between">
                     <h3>Details</h3>
+                    <button type="button" class="btn-ghost btn-sm" data-lf-toggle>Edit</button>
+                </div>
+                <div class="card-body" data-lf-view>
                     <p><strong stm-text="project.git_url">git_url</strong> · branch
                         <em stm-text="project.default_branch">main</em></p>
-                    <p class="muted" stm-if="project.summary.length > 0" stm-text="project.summary">summary</p>
-                    <p class="muted">Created <span stm-text="project.created_display">when</span></p>
-                </section>
-
-                <section class="card">
-                    <h3>Edit</h3>
-                    <form stm-action="project.update">
+                    <p class="text-muted" stm-if="project.summary.length > 0" stm-text="project.summary">summary</p>
+                    <p class="text-muted">Created <span stm-text="project.created_display">when</span></p>
+                </div>
+                <div class="card-body" data-lf-edit hidden>
+                    <form stm-action="project.update" class="form-rows">
                         <input type="hidden" name="project_id" stm-attribute.value="project.id">
-                        <div class="form-row"><label>Name</label>
-                            <input type="text" name="name" stm-attribute.value="project.name"></div>
-                        <div class="form-row"><label>Git URL</label>
-                            <input type="text" name="git_url" stm-attribute.value="project.git_url"></div>
-                        <div class="form-row"><label>Branch</label>
-                            <input type="text" name="default_branch" stm-attribute.value="project.default_branch"></div>
-                        <div class="form-row"><label>Summary</label>
-                            <input type="text" name="summary" stm-attribute.value="project.summary"></div>
-                        <button type="submit">Save</button>
+                        <div class="field"><label for="project-name">Name</label>
+                            <input type="text" id="project-name" name="name"
+                                   data-lf-src="project.name"></div>
+                        <div class="field"><label for="project-git">Git URL</label>
+                            <input type="text" id="project-git" name="git_url"
+                                   data-lf-src="project.git_url"></div>
+                        <div class="field"><label for="project-branch">Branch</label>
+                            <input type="text" id="project-branch" name="default_branch"
+                                   data-lf-src="project.default_branch"></div>
+                        <div class="field"><label for="project-summary">Summary</label>
+                            <input type="text" id="project-summary" name="summary"
+                                   data-lf-src="project.summary"></div>
+                        <div class="cluster">
+                            <button type="submit" class="btn-primary">Save</button>
+                            <button type="button" class="btn-ghost" data-lf-toggle>Cancel</button>
+                        </div>
                     </form>
-                </section>
-            </div>
+                </div>
+            </section>
 
             <h3>Linked projects</h3>
             <table>
@@ -47,10 +66,10 @@
                         <td><span stm-attribute.class="'badge badge-' + link.relationship" stm-text="link.relationship">relationship</span></td>
                         <td><button type="button" stm-action="link.remove" stm-confirm="Remove this link?">Remove</button></td>
                     </tr>
-                    <tr stm-if="project.links.length == 0"><td colspan="4" class="muted">No linked projects.</td></tr>
+                    <tr stm-if="project.links.length == 0"><td colspan="4" class="text-muted">No linked projects.</td></tr>
                 </tbody>
             </table>
-            <form class="inline" stm-action="project.add_link">
+            <form class="cluster" stm-action="project.add_link">
                 <label for="link-target">Link project</label>
                 <select id="link-target" name="linked_project_id" required>
                     <option stm-for-option-in="project.linkable" stm-attribute.value="option.id" stm-text="option.name">project</option>
@@ -62,7 +81,7 @@
                 <button type="submit">Add link</button>
             </form>
 
-            <form class="inline" stm-action="project.apply_stack" stm-if="project.stack_options.length > 0">
+            <form class="cluster" stm-action="project.apply_stack" stm-if="project.stack_options.length > 0">
                 <label for="stack-target">Apply stack</label>
                 <select id="stack-target" name="stack_id" required>
                     <option stm-for-stack-in="project.stack_options" stm-attribute.value="stack.id"
@@ -70,20 +89,20 @@
                 </select>
                 <button type="submit">Apply</button>
             </form>
-            <p class="muted" stm-if="project.stack_options.length == 0">Manage reference
+            <p class="text-muted" stm-if="project.stack_options.length == 0">Manage reference
                 collections on the <a href="/stacks">stacks page</a> and apply them here.</p>
 
             <h3>Rules</h3>
-            <p class="muted">Attached rules ride along with every task dispatched for this project.</p>
+            <p class="text-muted">Attached rules ride along with every task dispatched for this project.</p>
             <div stm-for-rule-in="project.rules" stm-key="rule.link_id" class="card">
                 <h3 stm-text="rule.name">name</h3>
                 <div class="md" stm-markdown="rule.body">body</div>
-                <form class="inline">
+                <form class="cluster">
                     <button type="button" stm-action="rule.detach" stm-confirm="Detach this rule?">Detach</button>
                 </form>
             </div>
-            <p stm-if="project.rules.length == 0" class="muted">No rules attached.</p>
-            <form class="inline" stm-action="project.attach_rule" stm-if="project.rule_options.length > 0">
+            <p stm-if="project.rules.length == 0" class="text-muted">No rules attached.</p>
+            <form class="cluster" stm-action="project.attach_rule" stm-if="project.rule_options.length > 0">
                 <label for="rule-target">Attach rule</label>
                 <select id="rule-target" name="rule_id" required>
                     <option stm-for-option-in="project.rule_options" stm-attribute.value="option.id"
@@ -91,13 +110,13 @@
                 </select>
                 <button type="submit">Attach</button>
             </form>
-            <p class="muted" stm-if="project.rule_options.length == 0">Create rules on the <a href="/rules">rules page</a>.</p>
+            <p class="text-muted" stm-if="project.rule_options.length == 0">Create rules on the <a href="/rules">rules page</a>.</p>
 
             <h3>Understanding</h3>
-            <p class="muted">A markdown map of the project's structure and anything
+            <p class="text-muted">A markdown map of the project's structure and anything
                 unusual, written by an agent against a commit and refreshed on demand.</p>
             <div class="card" stm-if="project.understanding_current.body.length > 0">
-                <p class="muted"><span stm-if="project.understanding_current.has_commit">commit
+                <p class="text-muted"><span stm-if="project.understanding_current.has_commit">commit
                         <code stm-text="project.understanding_current.commit_hash">hash</code> · </span>refreshed
                     <span stm-text="project.understanding_current.created_display">when</span><span
                         stm-if="project.understanding_current.source_task_id > 0"> · from task
@@ -105,13 +124,13 @@
                            stm-text="'#' + project.understanding_current.source_task_id">task</a></span></p>
                 <div class="md" stm-markdown="project.understanding_current.body">body</div>
             </div>
-            <p class="muted" stm-if="project.understanding_current.body.length == 0">No understanding yet —
+            <p class="text-muted" stm-if="project.understanding_current.body.length == 0">No understanding yet —
                 press Refresh to have an agent explore the project.</p>
-            <form class="inline" stm-action="project.refresh_understanding">
+            <form class="cluster" stm-action="project.refresh_understanding">
                 <button type="submit" stm-confirm="Spin up an agent task to refresh the understanding?">Refresh</button>
             </form>
             <div stm-for-revision-in="project.understanding_history" stm-key="revision.id" class="card">
-                <p class="muted"><span stm-if="revision.has_commit">commit
+                <p class="text-muted"><span stm-if="revision.has_commit">commit
                         <code stm-text="revision.commit_hash">hash</code> · </span>refreshed
                     <span stm-text="revision.created_display">when</span><span
                         stm-if="revision.source_task_id > 0"> · from task
@@ -121,22 +140,30 @@
             </div>
 
             <h3>Project notes</h3>
-            <p class="muted">Written by agents via <code>saddle note</code>; curated here — delete what
+            <p class="text-muted">Written by agents via <code>saddle note</code>; curated here — delete what
                 stops being useful. There is no manual create.</p>
             <div stm-for-note-in="project.notes" stm-key="note.id" class="card">
                 <p><span class="badge" stm-text="note.project_name">project</span>
-                    <span class="muted" stm-text="note.created_display">when</span>
-                    <span class="muted" stm-if="note.source_task_id > 0"> · from task
+                    <span class="text-muted" stm-text="note.created_display">when</span>
+                    <span class="text-muted" stm-if="note.source_task_id > 0"> · from task
                         <a stm-attribute.href="'/tasks/' + note.source_task_id" stm-text="'#' + note.source_task_id">task</a></span></p>
                 <div class="md" stm-markdown="note.body">body</div>
-                <form class="inline">
+                <form class="cluster">
                     <button type="button" stm-action="note.delete" stm-confirm="Delete this note?">Delete</button>
                 </form>
             </div>
-            <p stm-if="project.notes.length == 0" class="muted">No notes yet — they appear as agents
+            <p stm-if="project.notes.length == 0" class="text-muted">No notes yet — they appear as agents
                 record them with <code>saddle note</code>.</p>
 
             <h3>Tasks in this project</h3>
+            <!-- Goal 41 T9: the create affordance deep-links to /tasks
+                 with this project preselected in the New task dialog
+                 (the tasks page owns the form; the hash carries the
+                 prefill, see tasks.html's new-task-prefill). -->
+            <p class="cluster">
+                <a class="btn btn-primary btn-sm"
+                   stm-attribute.href="'/tasks#new?project=' + project.id">+ New task in this project</a>
+            </p>
             <table>
                 <thead><tr><th>#</th><th>Title</th><th>Status</th><th>Priority</th></tr></thead>
                 <tbody>
@@ -147,9 +174,30 @@
                         <td><span stm-attribute.class="'badge badge-' + task.status" stm-text="task.display">status</span></td>
                         <td stm-text="task.priority">priority</td>
                     </tr>
-                    <tr stm-if="project.tasks.length == 0"><td colspan="4" class="muted">No tasks yet — create one from the <a href="/tasks">queue</a>.</td></tr>
+                    <tr stm-if="project.tasks.length == 0"><td colspan="4" class="text-muted">No tasks yet — create one above or from the <a href="/tasks">queue</a>.</td></tr>
+                </tbody>
+            </table>
+
+            <!-- Goal 41 T9 (ux-notes navigation): the missing reverse
+                 cross-link - the project's own goals plus those linked
+                 as co-developed, so "what is being built here" is
+                 answerable from this page. -->
+            <h3>Goals in this project</h3>
+            <table stm-if="project.goals.length > 0">
+                <thead><tr><th>Goal</th><th>Status</th><th>Branch</th><th>Relationship</th></tr></thead>
+                <tbody>
+                    <tr stm-for-goal-in="project.goals">
+                        <td><a stm-attribute.href="'/goals/' + goal.id"
+                               stm-text="goal.title">title</a></td>
+                        <td><span stm-attribute.class="'badge badge-' + goal.status"
+                                  stm-text="goal.display">status</span></td>
+                        <td><code stm-if="goal.branch_name.length > 0" stm-text="goal.branch_name">branch</code>
+                            <span class="text-muted" stm-if="goal.branch_name.length == 0">—</span></td>
+                        <td stm-text="goal.relationship">primary</td>
+                    </tr>
                 </tbody>
             </table>
+            <p stm-if="project.goals.length == 0" class="text-muted">No goals build here yet — start one from the <a href="/goals">goals page</a>.</p>
 
             <!-- Admin-only deletion (ownership cascade). The dialog asks
                  for the project's EXACT name — the server re-checks both
@@ -157,17 +205,17 @@
                  delete a renamed project or one with running work. -->
             <div stm-if="project.is_admin" class="card">
                 <h3>Delete project</h3>
-                <p class="muted">Removing <strong stm-text="project.name">name</strong> also removes
+                <p class="text-muted">Removing <strong stm-text="project.name">name</strong> also removes
                     what it owns: <strong stm-text="project.delete_counts">0 goals, 0 tasks</strong>
                     (tasks include the goal agents' own run rows), with their comments, progress
                     and logs.</p>
-                <p stm-if="project.active_tasks > 0" class="muted">Deletion is refused while
+                <p stm-if="project.active_tasks > 0" class="text-muted">Deletion is refused while
                     <span stm-text="project.active_tasks">0</span> task(s) are still queued, working
                     or awaiting review — cancel or let them finish first.</p>
-                <p class="muted"><strong>Linked projects' work is untouched</strong> — projects,
+                <p class="text-muted"><strong>Linked projects' work is untouched</strong> — projects,
                     tasks and goals in other projects that reference this one as co-developed or
                     reference are kept; only their link entry pointing here is removed.</p>
-                <button type="button" class="reject"
+                <button type="button" class="btn-danger"
                         onclick="document.getElementById('project-delete-dialog').showModal()">Delete
                     project…</button>
             </div>
@@ -179,7 +227,7 @@
                 <p><strong>Linked projects' work is untouched.</strong> Other projects' tasks,
                     goals and rows that merely reference this project survive — only their link
                     entry pointing here goes.</p>
-                <p stm-if="project.active_tasks > 0" class="muted">Note: this project still has
+                <p stm-if="project.active_tasks > 0" class="text-muted">Note: this project still has
                     <span stm-text="project.active_tasks">0</span> live task(s) — the deletion
                     will be refused until they are cancelled or finished.</p>
                 <p>Type the project name <strong stm-text="project.name">name</strong> to
@@ -189,8 +237,8 @@
                     <input type="text" name="confirm_name" autocomplete="off"
                            stm-attribute.placeholder="'Type ' + project.name + ' to confirm'"
                            style="width:100%; margin:0.5em 0;">
-                    <button type="submit" class="reject">Delete this project forever</button>
-                    <button type="button" class="inline"
+                    <button type="submit" class="btn-danger">Delete this project forever</button>
+                    <button type="button" class="cluster"
                             onclick="document.getElementById('project-delete-dialog').close()">Cancel</button>
                 </form>
             </dialog>

+ 56 - 25
src/pages/projects.html

@@ -5,41 +5,72 @@
     <pstm-template>main</pstm-template>
 </head>
 <body>
-    <section>
-        <h2>Projects</h2>
+    <div class="stack">
+        <div class="cluster cluster-between">
+            <h2>Projects</h2>
+            <button type="button" class="btn-primary"
+                    onclick="window.openAppDialog('project-create-dialog')">+ New project</button>
+        </div>
         <p stm-if="projects == null">Loading…</p>
 
-        <div stm-if="projects != null">
-            <details>
-                <summary><strong>New project</strong></summary>
-                <form stm-action="projects.create">
-                    <div class="form-row"><label>Name</label>
-                        <input type="text" name="name" required placeholder="web-stack"></div>
-                    <div class="form-row"><label>Git URL</label>
-                        <input type="text" name="git_url" required placeholder="https://example.com/repo.git"></div>
-                    <div class="form-row"><label>Default branch</label>
-                        <input type="text" name="default_branch" placeholder="main"></div>
-                    <div class="form-row"><label>Summary</label>
-                        <input type="text" name="summary" placeholder="What this project is for"></div>
-                    <button type="submit">Create project</button>
-                </form>
-            </details>
+        <div stm-if="projects != null" class="stack">
+            <div class="table-wrap">
+                <table class="table-hover">
 
-            <table>
-                <thead><tr><th>Name</th><th>Git URL</th><th>Branch</th><th>Tasks</th></tr></thead>
+                <thead><tr><th>Name</th><th>Repo</th><th>Tasks</th></tr></thead>
                 <tbody>
                     <tr stm-for-project-in="projects.projects">
                         <td><a stm-attribute.href="'/projects/' + project.id" stm-text="project.name">name</a>
-                            <div class="muted" stm-if="project.summary.length > 0" stm-text="project.summary">summary</div></td>
-                        <td stm-text="project.git_url">git_url</td>
-                        <td stm-text="project.default_branch">branch</td>
+                            <div class="text-muted" stm-if="project.summary.length > 0" stm-text="project.summary">summary</div></td>
+                        <!-- Goal 41 T4: the long Git URL column folded
+                             into one "repo" cell - host/path/code>
+                             truncated, branch on the line below - so
+                             the table scans as name → repo → work
+                             instead of drowning in clone URLs
+                             (ux-notes projects list). -->
+                        <td>
+                            <div class="truncate mono repo-url" stm-attribute.title="project.git_url"
+                                 stm-text="project.git_url">git_url</div>
+                            <div class="text-muted"><span stm-if="project.default_branch.length > 0"
+                                     stm-text="' ' + project.default_branch">branch</span></div>
+                        </td>
                         <td><span stm-text="project.open_tasks">0</span> open ·
                             <span stm-text="project.done_tasks">0</span> closed</td>
                     </tr>
-                    <tr stm-if="projects.projects.length == 0"><td colspan="4" class="muted">No projects yet.</td></tr>
+                    <tr stm-if="projects.projects.length == 0"><td colspan="3" class="text-muted">No projects yet.</td></tr>
                 </tbody>
-            </table>
+            
+                </table>
+            </div>
+        </div>
+    </div>
+
+    <!-- Goal 41 T4: the create form moved from a collapsed expander
+         into a Spry <dialog> behind the "+ New project" button -
+         same fields, same stm-action. -->
+    <dialog id="project-create-dialog" class="dialog-sm">
+        <div class="dialog-titlebar">
+            <p class="dialog-title">New project</p>
+            <button type="button" class="dialog-close" aria-label="Close"
+                    onclick="window.closeAppDialog('project-create-dialog')">×</button>
         </div>
-    </section>
+        <form stm-action="projects.create">
+            <div class="dialog-body form-rows">
+                <div class="field"><label for="project-name">Name</label>
+                    <input type="text" id="project-name" name="name" required placeholder="web-stack"></div>
+                <div class="field"><label for="project-git-url">Git URL</label>
+                    <input type="text" id="project-git-url" name="git_url" required placeholder="https://example.com/repo.git"></div>
+                <div class="field"><label for="project-default-branch">Default branch</label>
+                    <input type="text" id="project-default-branch" name="default_branch" placeholder="main"></div>
+                <div class="field"><label for="project-summary">Summary</label>
+                    <input type="text" id="project-summary" name="summary" placeholder="What this project is for"></div>
+            </div>
+            <div class="dialog-footer">
+                <button type="button" class="btn-ghost"
+                        onclick="window.closeAppDialog('project-create-dialog')">Cancel</button>
+                <button type="submit" class="btn-primary">Create project</button>
+            </div>
+        </form>
+    </dialog>
 </body>
 </html>

+ 99 - 31
src/pages/rules.html

@@ -6,45 +6,113 @@
 </head>
 <body>
     <section>
-        <h2>Rules</h2>
-        <p class="muted">Reusable instructions dispatched with every task that carries them —
+        <div class="cluster cluster-between">
+            <h1>Rules</h1>
+            <!-- Goal 41 T9 (ux-notes expander fatigue): "New rule" hid
+                 behind a collapsed <details>. The primary action is now
+                 a persistent button opening a Spry dialog (the
+                 openAppDialog helper closes it on the action's
+                 notify). -->
+            <button type="button" class="btn-primary"
+                    onclick="window.openAppDialog('rule-create-dialog')">+ New rule</button>
+        </div>
+        <p class="text-muted">Reusable instructions dispatched with every task that carries them —
             attach rules on the project and task pages.</p>
         <p stm-if="rules == null">Loading…</p>
 
         <div stm-if="rules != null">
-            <details>
-                <summary><strong>New rule</strong></summary>
-                <form stm-action="rules.create">
-                    <div class="form-row"><label>Name</label>
-                        <input type="text" name="name" required placeholder="Use Invercargill"></div>
-                    <div class="form-row"><label>Body</label>
-                        <textarea name="body" rows="3" required
-                                  placeholder="Never use libgee; use Invercargill enumerables."></textarea></div>
-                    <button type="submit">Create rule</button>
-                </form>
-            </details>
 
             <div stm-for-rule-in="rules.rules" stm-key="rule.id" class="card">
-                <h3 stm-text="rule.name">name</h3>
-                <div class="md" stm-markdown="rule.body">body</div>
-                <p class="muted">Created <span stm-text="rule.created_display">when</span></p>
-                <details>
-                    <summary>Edit</summary>
-                    <form stm-action="rule.update">
-                        <input type="hidden" name="rule_id" stm-attribute.value="rule.id">
-                        <div class="form-row"><label>Name</label>
-                            <input type="text" name="name" stm-attribute.value="rule.name"></div>
-                        <div class="form-row"><label>Body</label>
-                            <textarea name="body" rows="4" stm-attribute.value="rule.body"></textarea></div>
-                        <button type="submit">Save</button>
-                    </form>
-                </details>
-                <form class="inline">
-                    <button type="button" stm-action="rule.delete" stm-confirm="Delete this rule everywhere?">Delete</button>
-                </form>
+                <!-- Goal 41 T9 (ux-notes rules): the Edit expander became
+                     the in-place editor (the data-long-form engine in
+                     app.js) — the edit half prefills from the slot's
+                     current values, so it edits what IS instead of a
+                     cold empty form. Delete left the row's face for a
+                     visually secondary danger control inside the
+                     confirm, inverting the old destructive-more-
+                     prominent-than-edit accident. -->
+                <div data-long-form data-field="rule-row">
+                    <div class="card-header cluster cluster-between">
+                        <h3 stm-text="rule.name">name</h3>
+                        <button type="button" class="btn-ghost btn-sm" data-lf-toggle>Edit</button>
+                    </div>
+                    <div class="card-body" data-lf-view>
+                        <div class="md" stm-markdown="rule.body">body</div>
+                        <p class="text-muted">Created <span stm-text="rule.created_display">when</span></p>
+                    </div>
+                    <div class="card-body" data-lf-edit hidden>
+                        <!-- No data-lf-src here: these editors live INSIDE a
+                             stm-for loop, so no slot path can address the
+                             row. The textarea instead prefills from
+                             stm-text (textContent pre-fills a pristine
+                             textarea; this page re-renders only on its
+                             own actions, never on a pulse) and the input
+                             from stm-attribute.value. The lf engine's
+                             prefill pass simply finds no data-lf-src
+                             controls and leaves both as rendered. -->
+                        <form stm-action="rule.update" class="form-rows">
+                            <input type="hidden" name="rule_id" stm-attribute.value="rule.id">
+                            <div class="field"><label>Name</label>
+                                <input type="text" name="name"
+                                       stm-attribute.value="rule.name"></div>
+                            <div class="field"><label>Body</label>
+                                <textarea name="body" rows="4"
+                                          stm-text="rule.body">body</textarea></div>
+                            <div class="cluster">
+                                <button type="submit" class="btn-primary">Save</button>
+                                <button type="button" class="btn-ghost" data-lf-toggle>Cancel</button>
+                            </div>
+                        </form>
+                    </div>
+                </div>
+
+                <div class="card-body">
+                    <!-- Goal 41 T9 (ux-notes rules): the blast radius —
+                         where this rule is attached. A delete here
+                         detaches it from every listed project and
+                         task; naming them makes the confirm honest. -->
+                    <p stm-if="rule.attachments.length > 0" class="text-muted">Attached to
+                        <span stm-for-attachment-in="rule.attachments">
+                            <a stm-if="attachment.kind == 'project'"
+                               stm-attribute.href="'/projects/' + attachment.id"
+                               stm-text="attachment.label">project</a>
+                            <a stm-if="attachment.kind == 'task'"
+                               stm-attribute.href="'/tasks/' + attachment.id"
+                               stm-text="'#' + attachment.id + ' ' + attachment.label">task</a>
+                        </span>
+                    </p>
+                    <p stm-if="rule.attachments.length == 0" class="text-muted">Not attached
+                        anywhere — deleting it changes no dispatch.</p>
+                    <button type="button" class="btn-ghost btn-sm"
+                            stm-action="rule.delete"
+                            stm-confirm="Delete this rule everywhere? It detaches from every project and task listed above.">Delete rule…</button>
+                </div>
             </div>
-            <p stm-if="rules.rules.length == 0" class="muted">No rules yet.</p>
+            <p stm-if="rules.rules.length == 0" class="text-muted">No rules yet.</p>
         </div>
+
+        <dialog id="rule-create-dialog" class="dialog-sm">
+            <div class="dialog-titlebar">
+                <p class="dialog-title">New rule</p>
+                <button type="button" class="dialog-close" aria-label="Close"
+                        onclick="window.closeAppDialog('rule-create-dialog')">×</button>
+            </div>
+            <form stm-action="rules.create">
+                <div class="dialog-body form-rows">
+                    <div class="field"><label for="rule-new-name">Name</label>
+                        <input type="text" id="rule-new-name" name="name" required
+                               placeholder="Use Invercargill"></div>
+                    <div class="field"><label for="rule-new-body">Body</label>
+                        <textarea name="body" id="rule-new-body" rows="3" required
+                                  placeholder="Never use libgee; use Invercargill enumerables."></textarea></div>
+                </div>
+                <div class="dialog-footer">
+                    <button type="button" class="btn-ghost"
+                            onclick="window.closeAppDialog('rule-create-dialog')">Cancel</button>
+                    <button type="submit" class="btn-primary">Create rule</button>
+                </div>
+            </form>
+        </dialog>
     </section>
 </body>
 </html>

+ 194 - 110
src/pages/saddles.html

@@ -5,138 +5,222 @@
     <pstm-template>main</pstm-template>
 </head>
 <body>
-    <section>
-        <h2>Saddles</h2>
+    <!-- Goal 41 T8 (ux-notes quick win 8): each saddle card is split
+         into a "now" HEAD (name, status badge, the one-line health
+         verdict, current work) and an "admin" TAIL (keys, git
+         identity, prompt note, capabilities, token, refresh/remove)
+         folding under one "Keys &amp; admin" expander. The three
+         health fragments (badge / daemon / up X / contacted Y) are
+         consolidated server-side into one verdict line
+         (saddle.health_verdict). The fleet summary line above the
+         grid surfaces offline/stale/fenced saddles immediately, and
+         the capability catalogue became its own h2 "Routing tags"
+         section (recommendation C). -->
+    <div class="stack">
+        <div class="cluster cluster-between">
+            <h2>Saddles</h2>
+            <button type="button" class="btn-primary" stm-if="saddles != null && saddles.is_admin"
+                    onclick="window.openAppDialog('saddle-register-dialog')">+ Register saddle</button>
+        </div>
+        <p class="text-muted">Fleet health and configuration. The dispatcher pairs queued tasks
+            with idle saddles carrying every tag a task requires.</p>
         <p stm-if="saddles == null">Loading…</p>
 
-        <div stm-if="saddles != null">
-            <details stm-if="saddles.is_admin">
-                <summary><strong>Register saddle</strong></summary>
-                <form stm-action="saddles.register">
-                    <div class="form-row"><label>Name</label>
-                        <input type="text" name="name" required placeholder="saddle-1"></div>
-                    <div class="form-row"><label>Host</label>
-                        <input type="text" name="host" required placeholder="localhost:7100"></div>
-                    <button type="submit">Register</button>
-                </form>
-            </details>
+        <div stm-if="saddles != null" class="stack">
+
+            <!-- Fleet health summary (recommendation B): one line when
+                 any saddle is offline/stale/fenced, naming the worst
+                 offenders so the dispatcher risk leads the page. -->
+            <p stm-if="saddles.fleet_alert.length > 0" class="alert alert-warning"
+               stm-text="saddles.fleet_alert">fleet alert</p>
 
             <div class="cards">
                 <section class="card" stm-for-saddle-in="saddles.saddles">
-                    <h3 stm-text="saddle.name">name</h3>
-                    <p><span stm-attribute.class="'badge badge-' + saddle.status" stm-text="saddle.status">status</span>
-                        <span class="muted" stm-text="saddle.host">host</span></p>
-                    <p stm-if="saddle.current_goal_id > 0">Supervising goal:
-                        <a stm-attribute.href="'/goals/' + saddle.current_goal_id"
-                           stm-text="saddle.current_goal_title">goal</a>
-                        <span class="badge badge-kind-goal">goal</span>
-                        <span class="muted" stm-if="saddle.allocated_display.length > 0" stm-text="'since ' + saddle.allocated_display">since</span></p>
-                    <p stm-if="saddle.current_task_id > 0 && saddle.current_goal_id == 0">Current task:
-                        <a stm-attribute.href="'/tasks/' + saddle.current_task_id" stm-text="saddle.current_task_title">task</a>
-                        <span class="muted" stm-if="saddle.allocated_display.length > 0" stm-text="'since ' + saddle.allocated_display">since</span></p>
-                    <p stm-if="saddle.current_task_id == 0" class="muted">No current task</p>
-                    <p><span stm-attribute.class="'badge badge-health-' + saddle.health"
-                              stm-text="saddle.health">healthy</span>
-                        <span class="muted" stm-if="saddle.daemon_state.length > 0"
-                              stm-text="'daemon ' + saddle.daemon_state">daemon</span>
-                        <span class="muted" stm-if="saddle.daemon_state.length > 0 && saddle.uptime_display.length > 0">&middot; up <span stm-text="saddle.uptime_display">uptime</span></span>
-                        <span class="muted" stm-text="'contacted ' + saddle.last_contact_display + ' ago'">contacted</span></p>
-                    <p stm-if="saddles.is_admin && saddle.token.length > 0" class="muted">API token:
-                        <code stm-text="saddle.token">token</code></p>
-                    <details stm-if="saddles.is_admin && saddle.ssh_public_key.length > 0">
-                        <summary>SSH public key</summary>
-                        <p class="muted">Add this as a <strong>deploy key</strong> on the git server
-                            (with write access) so this saddle can clone and push.</p>
-                        <pre class="ssh-key"><code stm-text="saddle.ssh_public_key">key</code></pre>
-                        <button type="button"
-                                stm-attribute.data-clipboard="saddle.ssh_public_key"
-                                onclick="navigator.clipboard.writeText(this.getAttribute('data-clipboard')).then(function () { this.textContent = 'Copied'; }.bind(this))">Copy key</button>
-                    </details>
-                    <p stm-if="saddles.is_admin && saddle.ssh_public_key.length == 0" class="muted">No SSH key reported yet —
-                        it appears here once the daemon generates one.</p>
-                    <details stm-if="saddles.is_admin">
-                        <summary>Git identity</summary>
-                        <form stm-action="saddle.update_identity" class="inline">
-                            <label>Committed as</label>
-                            <input type="text" name="git_name" stm-attribute.value="saddle.git_name"
-                                   placeholder="saddle-1">
-                            <input type="text" name="git_email" stm-attribute.value="saddle.git_email"
-                                   placeholder="saddle-1@example.net">
-                            <button type="submit">Save</button>
-                            <span class="muted">Empty fields fall back to the daemon's default identity.</span>
-                        </form>
-                    </details>
-                    <details stm-if="saddles.is_admin">
-                        <summary>Prompt note</summary>
-                        <form stm-action="saddle.update_prompt_note" class="inline">
-                            <label for="prompt-note">Served inside every assignment's system prompt</label>
-                            <textarea id="prompt-note" name="prompt_note" rows="3"
-                                      stm-attribute.placeholder="'e.g. You have root and may install development tooling via DNF'"
-                                      stm-text="saddle.prompt_note"></textarea>
-                            <button type="submit">Save</button>
-                            <span class="muted">A standing message about THIS machine for its agents; empty removes it (capped at 2000 chars).</span>
-                        </form>
-                    </details>
-                    <p stm-if="saddles.is_admin && saddle.capabilities.length > 0">Capabilities:
-                        <span stm-for-capability-in="saddle.capabilities" stm-key="capability.link_id">
-                            <span class="badge"><span stm-text="capability.name">tag</span>
-                                <button type="button" class="inline chip-remove"
-                                        stm-action="capability.detach" stm-confirm="Remove this capability?">×</button></span>
-                        </span>
-                    </p>
-                    <p stm-if="!saddles.is_admin && saddle.capabilities.length > 0">Capabilities:
-                        <span stm-for-capability-in="saddle.capabilities" stm-key="capability.link_id">
-                            <span class="badge"><span stm-text="capability.name">tag</span></span>
-                        </span>
-                    </p>
-                    <p stm-if="saddle.capabilities.length == 0" class="muted">No capabilities — matches only tasks with none required.</p>
-                    <form class="inline" stm-if="saddles.is_admin && saddles.capability_options.length > 0"
-                          stm-action="saddle.attach_capability">
-                        <label stm-attribute.for="'cap-' + saddle.id">Add capability</label>
-                        <select stm-attribute.id="'cap-' + saddle.id" name="capability_id" required>
-                            <option stm-for-option-in="saddles.capability_options" stm-attribute.value="option.id"
-                                     stm-text="option.name">capability</option>
-                        </select>
-                        <button type="submit">Add</button>
-                    </form>
-                    <form class="inline">
-                        <button type="button" stm-if="saddles.is_admin" stm-action="saddle.refresh">Refresh status</button>
-                        <button type="button" stm-if="saddles.is_admin" stm-action="saddle.unregister"
-                                stm-confirm="Remove this saddle? A running task is re-queued.">Remove</button>
-                    </form>
+                    <div class="card-header">
+                        <h3 stm-text="saddle.name">name</h3>
+                        <p class="cluster">
+                            <span stm-attribute.class="'badge badge-' + saddle.status" stm-text="saddle.status">status</span>
+                            <span class="text-muted" stm-text="saddle.host">host</span>
+                        </p>
+                    </div>
+                    <div class="card-body stack">
+                        <!-- The one-line health verdict consolidates the
+                             old three fragments; the badge keeps the
+                             at-a-glance colour the head is for. -->
+                        <p><span stm-attribute.class="'badge badge-health-' + saddle.health"
+                                  stm-text="saddle.health">healthy</span>
+                            <span class="text-muted" stm-text="saddle.health_verdict">verdict</span></p>
+                        <p stm-if="saddle.current_goal_id > 0">Supervising goal:
+                            <a stm-attribute.href="'/goals/' + saddle.current_goal_id"
+                               stm-text="saddle.current_goal_title">goal</a>
+                            <span class="text-muted" stm-if="saddle.allocated_display.length > 0" stm-text="'since ' + saddle.allocated_display">since</span></p>
+                        <p stm-if="saddle.current_task_id > 0 && saddle.current_goal_id == 0">Current task:
+                            <a stm-attribute.href="'/tasks/' + saddle.current_task_id" stm-text="saddle.current_task_title">task</a>
+                            <span class="text-muted" stm-if="saddle.allocated_display.length > 0" stm-text="'since ' + saddle.allocated_display">since</span></p>
+                        <p stm-if="saddle.current_task_id == 0" class="text-muted">No current task</p>
+
+                        <!-- The admin tail: everything below the fold
+                             folds under ONE expander; the inner
+                             blocks keep their own details so the
+                             disclosure stays gradual. -->
+                        <details stm-if="saddles.is_admin" class="saddle-fold">
+                            <summary>Keys &amp; admin</summary>
+                            <div class="stack saddle-admin">
+                                <p class="text-muted" stm-if="saddle.token.length > 0">API token:
+                                    <code stm-text="saddle.token">token</code></p>
+                                <details stm-if="saddle.ssh_public_key.length > 0">
+                                    <summary>SSH public key</summary>
+                                    <p class="text-muted">Add this as a <strong>deploy key</strong> on the git server
+                                        (with write access) so this saddle can clone and push.</p>
+                                    <pre class="ssh-key"><code stm-text="saddle.ssh_public_key">key</code></pre>
+                                    <button type="button"
+                                            stm-attribute.data-clipboard="saddle.ssh_public_key"
+                                            onclick="navigator.clipboard.writeText(this.getAttribute('data-clipboard')).then(function () { this.textContent = 'Copied'; }.bind(this))">Copy key</button>
+                                </details>
+                                <p stm-if="saddle.ssh_public_key.length == 0" class="text-muted">No SSH key reported yet —
+                                    it appears here once the daemon generates one.</p>
+                                <details>
+                                    <summary>Git identity</summary>
+                                    <form stm-action="saddle.update_identity" class="cluster">
+                                        <label>Committed as</label>
+                                        <input type="text" name="git_name" stm-attribute.value="saddle.git_name"
+                                               placeholder="saddle-1">
+                                        <input type="text" name="git_email" stm-attribute.value="saddle.git_email"
+                                               placeholder="saddle-1@example.net">
+                                        <button type="submit" class="btn-sm">Save</button>
+                                        <span class="text-muted">Empty fields fall back to the daemon's default identity.</span>
+                                    </form>
+                                </details>
+                                <details>
+                                    <summary>Prompt note</summary>
+                                    <form stm-action="saddle.update_prompt_note" class="cluster">
+                                        <label for="prompt-note">Served inside every assignment's system prompt</label>
+                                        <textarea id="prompt-note" name="prompt_note" rows="3"
+                                                  stm-attribute.placeholder="'e.g. You have root and may install development tooling via DNF'"
+                                                  stm-text="saddle.prompt_note"></textarea>
+                                        <button type="submit" class="btn-sm">Save</button>
+                                        <span class="text-muted">A standing message about THIS machine for its agents; empty removes it (capped at 2000 chars).</span>
+                                    </form>
+                                </details>
+                                <div>
+                                    <p stm-if="saddle.capabilities.length > 0">Capabilities:
+                                        <span stm-for-capability-in="saddle.capabilities" stm-key="capability.link_id">
+                                            <span class="badge"><span stm-text="capability.name">tag</span>
+                                                <button type="button" class="btn-ghost chip-remove"
+                                                        stm-action="capability.detach" stm-confirm="Remove this capability?">×</button></span>
+                                        </span>
+                                    </p>
+                                    <p stm-if="saddle.capabilities.length == 0" class="text-muted">No capabilities — matches only tasks with none required.</p>
+                                    <form class="cluster" stm-if="saddles.capability_options.length > 0"
+                                          stm-action="saddle.attach_capability">
+                                        <label stm-attribute.for="'cap-' + saddle.id">Add capability</label>
+                                        <select stm-attribute.id="'cap-' + saddle.id" name="capability_id" required>
+                                            <option stm-for-option-in="saddles.capability_options" stm-attribute.value="option.id"
+                                                     stm-text="option.name">capability</option>
+                                        </select>
+                                        <button type="submit" class="btn-sm">Add</button>
+                                    </form>
+                                </div>
+                                <form class="cluster">
+                                    <button type="button" class="btn-sm" stm-action="saddle.refresh">Refresh status</button>
+                                    <button type="button" class="btn-sm btn-danger" stm-action="saddle.unregister"
+                                            stm-confirm="Remove this saddle? A running task is re-queued.">Remove</button>
+                                </form>
+                            </div>
+                        </details>
+
+                        <!-- Non-admins see the routing tags without any
+                             admin machinery (the roles decision). -->
+                        <p stm-if="!saddles.is_admin && saddle.capabilities.length > 0">Capabilities:
+                            <span stm-for-capability-in="saddle.capabilities" stm-key="capability.link_id">
+                                <span class="badge"><span stm-text="capability.name">tag</span></span>
+                            </span>
+                        </p>
+                    </div>
                 </section>
                 <section class="card" stm-if="saddles.saddles.length == 0">
-                    <p class="muted">No saddles registered. The dispatcher pairs queued tasks with idle saddles automatically.</p>
+                    <p class="text-muted">No saddles registered. The dispatcher pairs queued tasks with idle saddles automatically.</p>
                 </section>
             </div>
+        </div>
 
-            <h3>Capabilities</h3>
-            <p class="muted">Tags the dispatcher routes by: a task is only assigned to a saddle carrying every
+        <!-- The capability catalogue (recommendation C): its own
+             h2-level section, a different modality than the card
+             grid above, so the page reads as two deliberate
+             sections. -->
+        <div class="stack" stm-if="saddles != null">
+            <h2 id="routing-tags">Routing tags</h2>
+            <p class="text-muted">Tags the dispatcher routes by: a task is only assigned to a saddle carrying every
                 tag the task requires. Manage a saddle's tags on its card above; require tags on a task's page.</p>
-            <details stm-if="saddles.is_admin">
-                <summary><strong>New capability</strong></summary>
-                <form stm-action="saddles.create_capability" class="inline">
-                    <label for="new-capability">Name</label>
-                    <input type="text" id="new-capability" name="name" required placeholder="gpu">
-                    <button type="submit">Create capability</button>
-                </form>
-            </details>
-            <table>
+            <div class="cluster">
+                <button type="button" class="btn-primary" stm-if="saddles.is_admin"
+                        onclick="window.openAppDialog('capability-create-dialog')">+ New tag</button>
+            </div>
+            <div class="table-wrap">
+                <table class="table-hover">
+
                 <thead><tr><th>Tag</th><th>Saddles carrying it</th><th>Tasks requiring it</th><th></th></tr></thead>
                 <tbody>
                     <tr stm-for-capability-in="saddles.capabilities">
                         <td><span class="badge" stm-text="capability.name">tag</span></td>
                         <td stm-text="capability.saddle_count">0</td>
                         <td stm-text="capability.task_count">0</td>
-                        <td stm-if="saddles.is_admin"><button type="button" stm-action="capability.delete"
+                        <td stm-if="saddles.is_admin"><button type="button" class="btn-sm btn-danger" stm-action="capability.delete"
                                     stm-confirm="Delete this capability everywhere?">Delete</button></td>
                     </tr>
                     <tr stm-if="saddles.capabilities.length == 0">
-                        <td colspan="4" class="muted">No capabilities defined yet.</td>
+                        <td colspan="4" class="text-muted">No capabilities defined yet.</td>
                     </tr>
                 </tbody>
-            </table>
+
+                </table>
+            </div>
         </div>
-    </section>
+    </div>
+
+    <!-- Goal 41 T8: the register form moved from a collapsed expander
+         into a Spry <dialog> behind the "+ Register saddle" button -
+         same fields, same stm-action (the shared openAppDialog helper
+         closes it on the action's notify). -->
+    <dialog id="saddle-register-dialog" class="dialog-sm">
+        <div class="dialog-titlebar">
+            <p class="dialog-title">Register saddle</p>
+            <button type="button" class="dialog-close" aria-label="Close"
+                    onclick="window.closeAppDialog('saddle-register-dialog')">×</button>
+        </div>
+        <form stm-action="saddles.register">
+            <div class="dialog-body form-rows">
+                <div class="field"><label for="saddle-name">Name</label>
+                    <input type="text" id="saddle-name" name="name" required placeholder="saddle-2"></div>
+                <div class="field"><label for="saddle-host">Host</label>
+                    <input type="text" id="saddle-host" name="host" required placeholder="localhost:7100"></div>
+            </div>
+            <div class="dialog-footer">
+                <button type="button" class="btn-ghost"
+                        onclick="window.closeAppDialog('saddle-register-dialog')">Cancel</button>
+                <button type="submit" class="btn-primary">Register</button>
+            </div>
+        </form>
+    </dialog>
+
+    <!-- The capability create form, same treatment. -->
+    <dialog id="capability-create-dialog" class="dialog-sm">
+        <div class="dialog-titlebar">
+            <p class="dialog-title">New routing tag</p>
+            <button type="button" class="dialog-close" aria-label="Close"
+                    onclick="window.closeAppDialog('capability-create-dialog')">×</button>
+        </div>
+        <form stm-action="saddles.create_capability">
+            <div class="dialog-body form-rows">
+                <div class="field"><label for="new-capability">Name</label>
+                    <input type="text" id="new-capability" name="name" required placeholder="gpu"></div>
+            </div>
+            <div class="dialog-footer">
+                <button type="button" class="btn-ghost"
+                        onclick="window.closeAppDialog('capability-create-dialog')">Cancel</button>
+                <button type="submit" class="btn-primary">Create tag</button>
+            </div>
+        </form>
+    </dialog>
 </body>
 </html>

+ 82 - 49
src/pages/stack_detail.html

@@ -3,65 +3,98 @@
 <head>
     <pstm-uri>/stacks/{stack_id}</pstm-uri>
     <pstm-template>main</pstm-template>
+<script>
+    // The Details card's in-place editor (goal 41 T8): the shared
+    // data-long-form engine in app.js toggles the read/edit halves
+    // and prefills the inputs from the stack slot. No page-specific
+    // JS beyond the init call.
+    if (window.statum && window.initLongFormEditors) {
+        window.initLongFormEditors();
+    }
+</script>
 </head>
 <body>
-    <section>
+    <!-- Goal 41 T8 ("history folds, actions surface" for the stack
+         detail page, mirroring T9's project-detail treatment): the
+         Details card becomes the read view with an in-place Edit
+         toggle (the data-long-form pattern) instead of a second
+         side-by-side edit card duplicating it; membership editing
+         stays a table with the add form surfaced next to it; the
+         destructive Delete stack is demoted into a visually
+         secondary danger zone at the bottom. -->
+    <div class="stack">
         <h2 stm-if="stack != null && stack.exists" stm-text="stack.name">Stack</h2>
-        <p stm-if="stack != null && !stack.exists" class="muted">Stack not found.</p>
+        <p stm-if="stack != null && !stack.exists" class="text-muted">Stack not found.</p>
 
-        <div stm-if="stack != null && stack.exists">
+        <div stm-if="stack != null && stack.exists" class="stack">
             <p><a href="/stacks">All stacks</a></p>
-            <div class="cards">
-                <section class="card">
-                    <h3>Details</h3>
-                    <p class="muted" stm-if="stack.summary.length > 0" stm-text="stack.summary">summary</p>
-                    <p class="muted">Created <span stm-text="stack.created_display">when</span></p>
-                </section>
 
-                <section class="card">
-                    <h3>Edit</h3>
-                    <form stm-action="stack.update">
+            <section class="card" data-long-form data-field="stack">
+                <div class="card-header cluster cluster-between">
+                    <h3>Details</h3>
+                    <button type="button" class="btn-ghost btn-sm" data-lf-toggle>Edit</button>
+                </div>
+                <div class="card-body">
+                    <div data-lf-view>
+                        <p class="text-muted" stm-if="stack.summary.length > 0" stm-text="stack.summary">summary</p>
+                        <p class="text-muted" stm-if="stack.summary.length == 0">No summary.</p>
+                        <p class="text-muted">Created <span stm-text="stack.created_display">when</span></p>
+                    </div>
+                    <form data-lf-edit hidden stm-action="stack.update" class="form-rows">
                         <input type="hidden" name="stack_id" stm-attribute.value="stack.id">
-                        <div class="form-row"><label>Name</label>
-                            <input type="text" name="name" stm-attribute.value="stack.name"></div>
-                        <div class="form-row"><label>Summary</label>
-                            <input type="text" name="summary" stm-attribute.value="stack.summary"></div>
-                        <button type="submit">Save</button>
+                        <div class="field"><label for="stack-name">Name</label>
+                            <input type="text" id="stack-name" name="name" data-lf-src="stack.name"
+                                   stm-attribute.value="stack.name"></div>
+                        <div class="field"><label for="stack-summary">Summary</label>
+                            <input type="text" id="stack-summary" name="summary" data-lf-src="stack.summary"
+                                   stm-attribute.value="stack.summary"></div>
+                        <div class="cluster">
+                            <button type="submit" class="btn-primary btn-sm">Save</button>
+                            <button type="button" class="btn-ghost btn-sm" data-lf-toggle>Cancel</button>
+                        </div>
                     </form>
-                </section>
-            </div>
+                </div>
+            </section>
 
-            <h3>Projects in this stack</h3>
-            <p class="muted">Applying the stack adds each of these to a project's or a goal's
-                linked projects with the relationship shown here.</p>
-            <table>
-                <thead><tr><th>Project</th><th>Git URL</th><th>Relationship</th><th></th></tr></thead>
-                <tbody>
-                    <tr stm-for-member-in="stack.members">
-                        <td><a stm-attribute.href="'/projects/' + member.project_id" stm-text="member.name">name</a></td>
-                        <td stm-text="member.git_url">git_url</td>
-                        <td><span stm-attribute.class="'badge badge-' + member.relationship" stm-text="member.relationship">relationship</span></td>
-                        <td><button type="button" stm-action="member.remove" stm-confirm="Remove this project from the stack?">Remove</button></td>
-                    </tr>
-                    <tr stm-if="stack.members.length == 0"><td colspan="4" class="muted">No projects in this stack yet.</td></tr>
-                </tbody>
-            </table>
-            <form class="inline" stm-action="stack.add_member" stm-if="stack.options.length > 0">
-                <label for="member-target">Add project</label>
-                <select id="member-target" name="project_id" required>
-                    <option stm-for-option-in="stack.options" stm-attribute.value="option.id" stm-text="option.name">project</option>
-                </select>
-                <select name="relationship">
-                    <option value="reference" selected>reference</option>
-                    <option value="co-developed">co-developed</option>
-                </select>
-                <button type="submit">Add</button>
-            </form>
+            <div class="stack">
+                <h3>Projects in this stack</h3>
+                <p class="text-muted">Applying the stack adds each of these to a project's or a goal's
+                    linked projects with the relationship shown here.</p>
+                <div class="table-wrap">
+                    <table class="table-hover">
+                        <thead><tr><th>Project</th><th>Git URL</th><th>Relationship</th><th></th></tr></thead>
+                        <tbody>
+                            <tr stm-for-member-in="stack.members">
+                                <td><a stm-attribute.href="'/projects/' + member.project_id" stm-text="member.name">name</a></td>
+                                <td stm-text="member.git_url">git_url</td>
+                                <td><span stm-attribute.class="'badge badge-' + member.relationship" stm-text="member.relationship">relationship</span></td>
+                                <td><button type="button" class="btn-sm" stm-action="member.remove" stm-confirm="Remove this project from the stack?">Remove</button></td>
+                            </tr>
+                            <tr stm-if="stack.members.length == 0"><td colspan="4" class="text-muted">No projects in this stack yet.</td></tr>
+                        </tbody>
+                    </table>
+                </div>
+                <form class="cluster" stm-action="stack.add_member" stm-if="stack.options.length > 0">
+                    <label for="member-target">Add project</label>
+                    <select id="member-target" name="project_id" required>
+                        <option stm-for-option-in="stack.options" stm-attribute.value="option.id" stm-text="option.name">project</option>
+                    </select>
+                    <select name="relationship" aria-label="Relationship">
+                        <option value="reference" selected>reference</option>
+                        <option value="co-developed">co-developed</option>
+                    </select>
+                    <button type="submit">Add</button>
+                </form>
+            </div>
 
-            <form class="inline">
-                <button type="button" stm-action="stack.delete" stm-confirm="Delete this stack? Its projects keep any links already applied.">Delete stack</button>
-            </form>
+            <section class="card card-flat">
+                <div class="card-body cluster">
+                    <span class="text-muted">Deleting a stack keeps any links already applied.</span>
+                    <button type="button" class="btn-danger btn-sm" stm-action="stack.delete"
+                            stm-confirm="Delete this stack? Its projects keep any links already applied.">Delete stack</button>
+                </div>
+            </section>
         </div>
-    </section>
+    </div>
 </body>
 </html>

+ 41 - 20
src/pages/stacks.html

@@ -5,38 +5,59 @@
     <pstm-template>main</pstm-template>
 </head>
 <body>
-    <section>
-        <h2>Stacks</h2>
-        <p class="muted">Named bundles of reference projects — apply one to a project or a
+    <div class="stack">
+        <div class="cluster cluster-between">
+            <h2>Stacks</h2>
+            <button type="button" class="btn-primary"
+                    onclick="window.openAppDialog('stack-create-dialog')">+ New stack</button>
+        </div>
+        <p class="text-muted">Named bundles of reference projects — apply one to a project or a
             goal to attach its whole collection of links in a single click.</p>
         <p stm-if="stacks == null">Loading…</p>
 
         <div stm-if="stacks != null">
-            <details>
-                <summary><strong>New stack</strong></summary>
-                <form stm-action="stacks.create">
-                    <div class="form-row"><label>Name</label>
-                        <input type="text" name="name" required placeholder="Web stack references"></div>
-                    <div class="form-row"><label>Summary <span class="muted">(optional)</span></label>
-                        <input type="text" name="summary" placeholder="What this collection is for"></div>
-                    <button type="submit">Create stack</button>
-                </form>
-            </details>
-
-            <table stm-if="stacks.stacks.length > 0">
+            <div class="table-wrap">
+                <table stm-if="stacks.stacks.length >
+ 0" class="table-hover">
                 <thead><tr><th>#</th><th>Name</th><th>Projects</th><th>Summary</th><th>Created</th></tr></thead>
                 <tbody>
                     <tr stm-for-stack-in="stacks.stacks">
                         <td stm-text="stack.id">id</td>
                         <td><a stm-attribute.href="'/stacks/' + stack.id" stm-text="stack.name">name</a></td>
                         <td stm-text="stack.member_count">0</td>
-                        <td class="muted" stm-text="stack.summary">summary</td>
-                        <td class="muted" stm-text="stack.created_display">when</td>
+                        <td class="text-muted" stm-text="stack.summary">summary</td>
+                        <td class="text-muted" stm-text="stack.created_display">when</td>
                     </tr>
                 </tbody>
-            </table>
-            <p stm-if="stacks.stacks.length == 0" class="muted">No stacks yet.</p>
+            
+                </table>
+            </div>
+            <p stm-if="stacks.stacks.length == 0" class="text-muted">No stacks yet.</p>
+        </div>
+    </div>
+
+    <!-- Goal 41 T4: the create form moved from a collapsed expander
+         into a Spry <dialog> behind the "+ New stack" button - same
+         fields, same stm-action. -->
+    <dialog id="stack-create-dialog" class="dialog-sm">
+        <div class="dialog-titlebar">
+            <p class="dialog-title">New stack</p>
+            <button type="button" class="dialog-close" aria-label="Close"
+                    onclick="window.closeAppDialog('stack-create-dialog')">×</button>
         </div>
-    </section>
+        <form stm-action="stacks.create">
+            <div class="dialog-body form-rows">
+                <div class="field"><label for="stack-name">Name</label>
+                    <input type="text" id="stack-name" name="name" required placeholder="Web stack references"></div>
+                <div class="field"><label for="stack-summary">Summary <span class="text-muted">(optional)</span></label>
+                    <input type="text" id="stack-summary" name="summary" placeholder="What this collection is for"></div>
+            </div>
+            <div class="dialog-footer">
+                <button type="button" class="btn-ghost"
+                        onclick="window.closeAppDialog('stack-create-dialog')">Cancel</button>
+                <button type="submit" class="btn-primary">Create stack</button>
+            </div>
+        </form>
+    </dialog>
 </body>
 </html>

+ 471 - 330
src/pages/task_detail.html

@@ -3,16 +3,25 @@
 <head>
     <pstm-uri>/tasks/{task_id}</pstm-uri>
     <pstm-template>main</pstm-template>
+    <!-- Goal 41 T7: the page's Log tab mounts the SAME client engine
+         the standalone log page runs (SSE feed, filters, search,
+         paging) - it binds the [data-log-*] hooks the task-log-feed
+         partial ships, so loading the resource here is all the
+         wiring the embed needs. -->
+    <script pstm-res-src="task_log.js"></script>
 <script>
     // Live updates: the page subscribes to the global pulse (see the
     // entrypoint); whenever task or saddle state changes anywhere in
     // the app, the sealed refresh action rehydrates the task slot -
     // no fixed-interval polling. RFC bodies get the shared
     // text-highlight -> comment popover (see main.html).
-    if (window.statum) {
-        (function () {
-            'use strict';
-
+    //
+    // Goal 41 T5 shipped the anchor sub-nav chassis inline here; T11
+    // unified it with the goal page's variant into app.js's shared
+    // initSubnav engine (measured-offset jumps, closest-to-fold
+    // highlight, is-stuck, --topbar-bottom publishing - one
+    // implementation, both pages). The strip below carries
+    // data-subnav; its sections carry id + data-subnav-section.
             function refreshTask(source) {
                 var task = statum.read('task');
                 if (!task || !task.refresh) return;
@@ -52,6 +61,11 @@
             if (window.initLongFormEditors) {
                 window.initLongFormEditors();
             }
+
+            // The shared anchor sub-nav engine (app.js).
+            if (window.initSubnav) {
+                window.initSubnav();
+            }
         })();
     }
 </script>
@@ -60,353 +74,480 @@
     <section>
         <h2 stm-if="task != null && task.exists">#<span stm-text="task.id">id</span> —
             <span stm-text="task.title">title</span></h2>
-        <p stm-if="task != null && !task.exists" class="muted">Task not found.</p>
+        <p stm-if="task != null && !task.exists" class="text-muted">Task not found.</p>
 
         <div stm-if="task != null && task.exists">
-            <!-- Goal 8 task 4: a standalone task (no project) shows
-                 "(standalone)" instead of a project link — its
-                 workspace holds no clones. -->
-            <p stm-if="task.project_id > 0"><a href="/tasks">All tasks</a> · project
-                <a stm-attribute.href="'/projects/' + task.project_id" stm-text="task.project_name">project</a>
-                <span stm-if="task.enqueue_after_task_id > 0"> · auto-enqueues after
-                    <a stm-attribute.href="'/tasks/' + task.enqueue_after_task_id"
-                       stm-text="'#' + task.enqueue_after_task_id">after</a></span></p>
-            <p stm-if="task.project_id == 0"><a href="/tasks">All tasks</a> ·
-                <strong>(standalone)</strong>
-                <span class="muted">— no project: the agent works in its own ephemeral
-                    workspace and delivers its completion report</span></p>
+            <!-- Goal 41 T5: breadcrumbs replace the hand-drawn "All
+                 tasks" context line (the partial is T1's; the crumbs
+                 vector is the entrypoint's). The project / enqueue
+                 context now rides the crumb chain itself. -->
+            <pstm-fragment name="breadcrumb" crumbs="task.crumbs"/>
 
-            <!-- Progress feed ABOVE the status card (plan 11 task D):
-                 the agent's newest line leads the page, its history
-                 behind the "Show history" expander. -->
-            <h3>Progress</h3>
-            <pstm-fragment name="progress-feed" entries="task.progress"/>
-            <p stm-if="task.progress.length == 0" class="muted">No progress reports yet — the agent reports via
-                <code>saddle progress-update</code>.</p>
+            <!-- Goal 41 T7 (ux-notes structural bet 3): the task page
+                 and its event log are ONE investigation flow, not two
+                 URLs and a new tab. Spry's CSS tabs (radios first,
+                 .tab-list labels, .tab-panels) mount the whole
+                 Overview surface as panel 1 and the event log's shell
+                 (the SAME task-log-feed partial the standalone
+                 /tasks/{id}/log page renders) as panel 2 — the page's
+                 entrypoint publishes both slots. The log link beside
+                 Progress toggles the tab in place (a <label> for the
+                 radio, not an anchor); the standalone route keeps its
+                 URL for deep links and the forensic full-window view. -->
+            <div class="tabs task-tabs">
+                <input type="radio" name="task-view" id="task-view-overview" checked>
+                <input type="radio" name="task-view" id="task-view-log">
+                <div class="tab-list">
+                    <label for="task-view-overview">Overview</label>
+                    <label for="task-view-log">Event log</label>
+                </div>
+                <div class="tab-panels">
+                    <section class="task-overview-panel" aria-label="Overview">
 
-            <!-- Status + description side by side in a 50/50 grid (plan
-                 11 task D), collapsing to stacked
-                 status-then-description on small screens. The
-                 lifecycle buttons ride the status card (plan 11): new
-                 tasks are drafts — Enqueue and Delete in draft (delete
-                 guarded by dependents), Cancel in non-draft
-                 non-terminal; completion is agent-only. -->
-            <div class="grid-2">
-                <section class="card status-head">
-                    <h3>Status</h3>
-                    <p><span stm-attribute.class="'badge badge-' + task.status" stm-text="task.display">status</span>
-                        <span stm-attribute.class="'badge badge-mode-' + task.mode" stm-text="task.mode">mode</span>
-                        · priority <strong stm-text="task.priority">5</strong></p>
-                    <p class="muted" stm-if="task.enqueue_after_task_id > 0">Auto-enqueues after
-                        <a stm-attribute.href="'/tasks/' + task.enqueue_after_task_id"
-                           stm-text="'#' + task.enqueue_after_task_id + ' ' + task.enqueue_after_title">after</a></p>
-                    <p class="muted">Created <span stm-text="task.created_display">when</span></p>
-                    <p class="muted" stm-if="task.started_display.length > 0">Started <span stm-text="task.started_display">when</span></p>
-                    <p class="muted" stm-if="task.completed_display.length > 0">Completed <span stm-text="task.completed_display">when</span></p>
-                    <!-- S5: the aggregate LLM usage stat line (empty for
-                         zero-usage tasks renders nothing). -->
-                    <p class="muted" stm-if="task.usage_line.length > 0"
-                       stm-text="task.usage_line">LLM: 1,234 calls · 45.2M in / 1.1M out tok · 3h 12m</p>
-                    <p stm-if="task.task_branch.length > 0">Branch: <strong stm-text="task.task_branch">branch</strong>
-                        <span class="muted" stm-if="task.branch_from.length > 0"> from <span stm-text="task.branch_from">base</span></span></p>
-                    <p stm-if="task.status == 'suspended'" class="muted">Parked (`saddle suspend`) —
-                        the goal agent resumes on its goal's task events, with the
-                        batched status report riding the resume.</p>
-                    <form class="inline">
-                        <input type="hidden" name="task_id" stm-attribute.value="task.id">
-                        <button type="button" stm-if="task.draft" class="primary"
-                                stm-action="task.enqueue">Enqueue task</button>
-                        <button type="button" stm-if="task.draft"
-                                stm-action="task.delete"
-                                stm-confirm="Delete this draft task? This cannot be undone.">Delete</button>
-                        <button type="button" stm-if="task.status == 'failed'" class="primary"
-                                stm-action="task.retry">Retry task</button>
-                        <button type="button" stm-if="task.resume_available"
-                                stm-action="task.resume"
-                                stm-confirm="Requeue this task? The daemon restores its workspace context on the next dispatch.">Requeue to resume</button>
-                        <!-- Goal 4 task 1: Force wake — the parked-task
-                             recovery (a goal run routes to the goal's
-                             wake). -->
-                        <button type="button" stm-if="task.force_wake != null"
-                                stm-action="task.force_wake"
-                                stm-confirm="Force-woke this parked task? Its agent resumes immediately on its saddle - one wake per press.">Force wake</button>
-                        <button type="button" stm-if="!task.draft && !task.terminal"
-                                stm-action="task.cancel" stm-confirm="Cancel this task?">Cancel task</button>
-                    </form>
-                    <form class="inline">
-                        <input type="hidden" name="task_id" stm-attribute.value="task.id">
-                        <button type="button" stm-action="task.toggle_notify"
-                                stm-text="task.notify_me ? 'Don&#39;t notify me' : 'Notify me'">Notify me</button>
-                        <span class="muted">— this task&#39;s first start, failure and completion
-                            (questions and reviews always notify subscribed users)</span>
-                    </form>
-                </section>
+            <!-- Goal 41 T5: the anchor sub-nav - the page's map. Pure
+                 anchors into the [data-subnav-section] regions below;
+                 the page script owns jumps, in-view highlighting and
+                 the strip's stickiness. The open-block count rides
+                 the Blocks link so the page's verb announces itself
+                 in the map. -->
+            <nav class="subnav" aria-label="Sections" data-subnav>
+                <a href="#task-progress">Status &amp; progress</a>
+                <a href="#task-blocks">Blocks</a>
+                <a href="#task-comments">Comments</a>
+                <a href="#task-config">Configuration</a>
+            </nav>
 
-                <!-- Plan 12: the goal reference — a goal task or the
-                     goal agent's own run links back to its goal. -->
-                <section class="card" stm-if="task.goal_id > 0">
-                    <h3>Goal</h3>
-                    <p><span stm-attribute.class="'badge badge-' + task.goal_status"
-                             stm-text="task.goal_status.replace('_', ' ')">status</span>
-                        <a stm-attribute.href="'/goals/' + task.goal_id" stm-text="task.goal_title">goal</a></p>
-                    <p class="muted">Goal branch <code stm-text="task.goal_branch">branch</code>
-                        <span stm-if="task.is_goal_run">— this task IS the goal agent's run
-                            (orchestrate mode; the goal page owns its lifecycle)</span>
-                        <span stm-if="!task.is_goal_run">— this task runs under the goal agent's
-                            supervision on an ephemeral branch</span></p>
-                </section>
+            <!-- Goal 41 T5: the Act|Consult split (ux-notes task
+                 detail A + structural bet 1). Left column: everything
+                 a human ACTS on - progress, blocks (the page's verb),
+                 the completion report, the status/lifecycle surface,
+                 the comment composer. Right column: everything a
+                 human CONSULTS - mandate, goal reference, the folded
+                 configuration + session history. Stacks
+                 Act-then-Consult under the framework breakpoint
+                 (app.css .act-consult - the shared detail-page
+                 .dash-board precedent). -->
+            <div class="with-sidebar act-consult">
+                <!-- ================= ACT ================= -->
+                <div class="stack">
+                    <section data-subnav-section="task-progress" id="task-progress">
+                        <!-- Progress feed leads the page (plan 11 task
+                             D): the agent's newest line first, its
+                             history behind the "Show history"
+                             expander. ux-notes D + goal 41 T7: the
+                             event log is per-task, not per-session -
+                             it rides here at top level, and T7 makes
+                             it a same-tab toggle: the label checks the
+                             log radio, flipping the whole page into
+                             the log view without a navigation. -->
+                        <div class="cluster cluster-between">
+                            <h3>Progress</h3>
+                            <label class="tab-link" for="task-view-log">Event log</label>
+                        </div>
+                        <pstm-fragment name="progress-feed" entries="task.progress"/>
+                        <p stm-if="task.progress.length == 0" class="text-muted">No progress reports yet — the agent reports via
+                            <code>saddle progress-update</code>.</p>
+                    </section>
 
-                <!-- Plan 12: the mandate — the plan-11 "Description"
-                     card, framed as the binding work order. Task 12:
-                     drafts swap the rendered markdown for an in-place
-                     editor (initLongFormEditors in main.html) — the
-                     expander no longer carries the long-form field,
-                     and non-draft/terminal tasks keep the plain read
-                     view. -->
-                <section class="card" data-long-form data-field="task"
-                         data-lf-open="0">
-                    <h3>Mandate
-                        <button type="button" class="inline" stm-if="task.editable_all"
-                                data-lf-toggle>Edit</button></h3>
-                    <div data-lf-view>
-                        <div stm-if="task.description.length > 0" class="md" stm-markdown="task.description">description</div>
-                        <p class="muted" stm-if="task.description.length == 0">No mandate.</p>
-                    </div>
-                    <!-- The stm-if wrapper (not the form itself) owns
-                         draft-gating: statum toggles `hidden` on
-                         stm-if branches, so the form inside keeps its
-                         visibility JS-owned (initLongFormEditors). -->
-                    <div stm-if="task.editable_all">
-                        <form data-lf-edit hidden stm-action="task.update">
-                            <input type="hidden" name="task_id" stm-attribute.value="task.id">
-                            <div class="form-row"><label>Mandate</label>
-                                <textarea name="description" rows="8"
-                                          data-lf-src="task.description">description</textarea></div>
-                            <button type="submit">Save</button>
-                            <button type="button" data-lf-toggle>Cancel</button>
-                        </form>
-                    </div>
-                </section>
-            </div>
+                    <!-- Current blocks render in full (plan 11 task D);
+                         answered ones fold behind the history expander.
+                         This is the page's VERB: it leads the acting
+                         column, above every reference surface. -->
+                    <section data-subnav-section="task-blocks" id="task-blocks">
+                        <h3>Blocks <span class="text-muted" stm-if="task.open_blocks.length > 0">(<span stm-text="task.open_blocks.length">0</span> open)</span></h3>
+                        <div stm-for-block-in="task.open_blocks" stm-key="block.id"
+                             stm-attribute.class="'block-card ' + block.block_type + (block.answered ? ' answered' : '')">
+                            <div stm-if="!block.answered && block.block_type == 'review'" class="review-banner">
+                                <h3>Review requested</h3>
+                                <div class="md" stm-markdown="block.body.length > 0 ? block.body : block.question">summary</div>
+                                <p>Branch <strong stm-text="task.task_branch">branch</strong>
+                                    <a stm-if="task.review_diff_url.length > 0" stm-attribute.href="task.review_diff_url"
+                                       target="_blank" rel="noopener">· compare changes</a></p>
+                                <div class="actions">
+                                    <form stm-action="block.answer_action" class="cluster">
+                                        <input type="hidden" name="verdict" value="accept">
+                                        <button type="submit" class="btn-primary">Approve</button>
+                                    </form>
+                                    <form stm-action="block.answer_action" class="cluster">
+                                        <textarea name="feedback" rows="2" placeholder="What should change?"></textarea>
+                                        <button type="submit" class="btn-danger">Request changes</button>
+                                    </form>
+                                </div>
+                            </div>
+
+                            <p><span stm-attribute.class="'badge badge-' + block.block_type" stm-text="block.block_type">type</span>
+                                <span class="text-muted" stm-text="block.created_display">when</span></p>
+
+                            <div stm-if="block.show_body && block.body.length > 0" class="md rfc-body"
+                                 stm-markdown="block.body">body</div>
 
-            <!-- The heredoc completion report (`cat << 'EOF' | saddle
-                 done`): terminal tasks render tasks.summary as
-                 markdown (XSS-safe via stm-markdown) in this
-                 full-width card directly below the status/description
-                 grid — "Completion report" when done, "Failure
-                 summary" when failed; an empty summary renders no
-                 card. -->
-            <section class="card completion-report"
-                     stm-if="task.terminal && task.summary.length > 0">
-                <h3 stm-text="task.report_title">Completion report</h3>
-                <div class="md" stm-markdown="task.summary">summary</div>
-            </section>
+                            <div stm-if="block.answered">
+                                <p>Answer: <strong stm-text="block.answer">answer</strong>
+                                    <span class="text-muted" stm-text="block.answered_display">when</span>
+                                    <span class="badge" stm-if="block.answered_by == 'auto'">auto</span></p>
+                            </div>
 
-            <!-- The edit expander sits UNDER the "Show history" expander
-                 (plan 11 task D): draft tasks edit every field except
-                 status; non-draft non-terminal tasks edit priority
-                 only; terminal tasks edit nothing. Task 12: the
-                 long-form mandate moved to its own in-place editor on
-                 the mandate card — the expander keeps the short
-                 properties only. -->
-            <details class="edit-expander" stm-if="task.editable_all || task.editable_priority">
-                <summary><strong>Edit task</strong>
-                    <span class="muted" stm-if="task.editable_all">— every field except status while in draft</span>
-                    <span class="muted" stm-if="!task.editable_all && task.editable_priority">— priority only once work has begun</span></summary>
-                <form stm-action="task.update" stm-if="task.editable_all">
-                    <input type="hidden" name="task_id" stm-attribute.value="task.id">
-                    <div class="form-row"><label>Title</label>
-                        <input type="text" name="title" stm-attribute.value="task.title"></div>
-                    <div class="form-row" stm-if="!task.is_goal_run"><label>Mode</label>
-                        <select name="mode" stm-attribute.value="task.mode">
-                            <option value="implement">implement — build the change on a task branch</option>
-                            <option value="investigate">investigate — explore and report, no code changes</option>
-                            <option value="review">review — read and critique existing work</option>
-                            <option value="test">test — write and run tests</option>
-                        </select></div>
-                    <div class="form-row" stm-if="task.creator == 'human' && !task.is_goal_run"><label>Auto-enqueue after
-                            <span class="muted">(enqueues itself when the predecessor is done; none clears it)</span></label>
-                        <select name="enqueue_after_task_id">
-                            <option value="">none</option>
-                            <option stm-for-option-in="task.enqueue_after_options" stm-attribute.value="option.id"
-                                     stm-text="'#' + option.id + ' ' + option.title">task</option>
-                        </select></div>
-                    <div class="form-row"><label>Priority (1–9)</label>
-                        <input type="number" name="priority" min="1" max="9" stm-attribute.value="task.priority"></div>
-                    <div class="form-row"><label>Task branch</label>
-                        <input type="text" name="task_branch" stm-attribute.value="task.task_branch" placeholder="task/<id>-<slug>"></div>
-                    <div class="form-row"><label>Branch from</label>
-                        <input type="text" name="branch_from" stm-attribute.value="task.branch_from"></div>
-                    <button type="submit">Save</button>
-                </form>
-                <form stm-action="task.update" stm-if="!task.editable_all && task.editable_priority">
-                    <input type="hidden" name="task_id" stm-attribute.value="task.id">
-                    <div class="form-row"><label>Priority (1–9)</label>
-                        <input type="number" name="priority" min="1" max="9" stm-attribute.value="task.priority"></div>
-                    <button type="submit">Save</button>
-                </form>
-            </details>
+                            <pstm-fragment name="block-answer"/>
+                            <pstm-fragment name="block-reply"/>
+                        </div>
+                        <p stm-if="task.open_blocks.length == 0 && task.answered_blocks.length == 0" class="text-muted">No blocks — the agent has no open questions.</p>
 
-<!-- Current blocks render in full (plan 11 task D);
-                 answered ones fold behind the history expander. -->
-            <h3>Blocks <span class="muted" stm-if="task.open_blocks.length > 0">(<span stm-text="task.open_blocks.length">0</span> open)</span></h3>
-            <div stm-for-block-in="task.open_blocks" stm-key="block.id"
-                 stm-attribute.class="'block-card ' + block.block_type + (block.answered ? ' answered' : '')">
-                <div stm-if="!block.answered && block.block_type == 'review'" class="review-banner">
-                    <h3>Review requested</h3>
-                    <div class="md" stm-markdown="block.body.length > 0 ? block.body : block.question">summary</div>
-                    <p>Branch <strong stm-text="task.task_branch">branch</strong>
-                        <a stm-if="task.review_diff_url.length > 0" stm-attribute.href="task.review_diff_url"
-                           target="_blank" rel="noopener">· compare changes</a></p>
-                     <div class="actions">
-                         <form stm-action="block.answer_action" class="inline">
-                             <input type="hidden" name="verdict" value="accept">
-                             <button type="submit" class="approve">Approve</button>
-                         </form>
-                        <form stm-action="block.answer_action" class="inline">
-                            <textarea name="feedback" rows="2" placeholder="What should change?"></textarea>
-                            <button type="submit" class="reject">Request changes</button>
+                        <!-- Historical (answered) blocks behind an expander, the
+                             current ones in full above (plan 11 task D). -->
+                        <details class="blocks-history" stm-if="task.answered_blocks.length > 0">
+                            <summary>Answered blocks (<span stm-text="task.answered_blocks.length">0</span>)</summary>
+                            <div stm-for-block-in="task.answered_blocks" stm-key="block.id"
+                                 stm-attribute.class="'block-card answered ' + block.block_type">
+                                <p><span stm-attribute.class="'badge badge-' + block.block_type" stm-text="block.block_type">type</span>
+                                    <span stm-text="block.question">question</span>
+                                    <span class="text-muted" stm-text="block.created_display">when</span></p>
+                                <div stm-if="block.show_body && block.body.length > 0" class="md rfc-body"
+                                     stm-markdown="block.body">body</div>
+                                <p>Answer: <strong stm-text="block.answer">answer</strong>
+                                    <span class="text-muted" stm-text="block.answered_display">when</span>
+                                    <span class="badge" stm-if="block.answered_by == 'auto'">auto</span></p>
+                            </div>
+                        </details>
+                    </section>
+
+                    <!-- The heredoc completion report (`cat << 'EOF' | saddle
+                         done`): terminal tasks render tasks.summary as
+                         markdown (XSS-safe via stm-markdown) in this
+                         card - "Completion report" when done, "Failure
+                         summary" when failed; an empty summary renders
+                         no card. -->
+                    <section class="card completion-report"
+                             stm-if="task.terminal && task.summary.length > 0">
+                        <h3 stm-text="task.report_title">Completion report</h3>
+                        <div class="md" stm-markdown="task.summary">summary</div>
+                    </section>
+
+                    <!-- Status + lifecycle: the acting column's anchor.
+                         The lifecycle buttons ride the status card (plan
+                         11): new tasks are drafts - Enqueue and Delete in
+                         draft (delete guarded by dependents), Cancel in
+                         non-draft non-terminal; completion is agent-only.
+                         The old 50/50 grid-2 paired status with the
+                         mandate; the Act|Consult split replaces it - the
+                         mandate reads in the consult column. -->
+                    <section class="card status-head">
+                        <h3>Status</h3>
+                        <p><span stm-attribute.class="'badge badge-' + task.status" stm-text="task.display">status</span>
+                            <span stm-attribute.class="'badge badge-mode-' + task.mode" stm-text="task.mode">mode</span>
+                            · priority <strong stm-text="task.priority">5</strong></p>
+                        <p class="text-muted" stm-if="task.enqueue_after_task_id > 0">Auto-enqueues after
+                            <a stm-attribute.href="'/tasks/' + task.enqueue_after_task_id"
+                               stm-text="'#' + task.enqueue_after_task_id + ' ' + task.enqueue_after_title">after</a></p>
+                        <p class="text-muted">Created <span stm-text="task.created_display">when</span></p>
+                        <p class="text-muted" stm-if="task.started_display.length > 0">Started <span stm-text="task.started_display">when</span></p>
+                        <p class="text-muted" stm-if="task.completed_display.length > 0">Completed <span stm-text="task.completed_display">when</span></p>
+                        <!-- S5: the aggregate LLM usage stat line (empty for
+                             zero-usage tasks renders nothing). -->
+                        <p class="text-muted" stm-if="task.usage_line.length > 0"
+                           stm-text="task.usage_line">LLM: 1,234 calls · 45.2M in / 1.1M out tok · 3h 12m</p>
+                        <p stm-if="task.task_branch.length > 0">Branch: <strong stm-text="task.task_branch">branch</strong>
+                            <span class="text-muted" stm-if="task.branch_from.length > 0"> from <span stm-text="task.branch_from">base</span></span></p>
+                        <p stm-if="task.status == 'suspended'" class="text-muted">Parked (`saddle suspend`) —
+                            the goal agent resumes on its goal's task events, with the
+                            batched status report riding the resume.</p>
+                        <form class="cluster">
+                            <input type="hidden" name="task_id" stm-attribute.value="task.id">
+                            <button type="button" stm-if="task.draft" class="btn-primary"
+                                    stm-action="task.enqueue">Enqueue task</button>
+                            <button type="button" stm-if="task.draft"
+                                    stm-action="task.delete"
+                                    stm-confirm="Delete this draft task? This cannot be undone.">Delete</button>
+                            <button type="button" stm-if="task.status == 'failed'" class="btn-primary"
+                                    stm-action="task.retry">Retry task</button>
+                            <button type="button" stm-if="task.resume_available"
+                                    stm-action="task.resume"
+                                    stm-confirm="Requeue this task? The daemon restores its workspace context on the next dispatch.">Requeue to resume</button>
+                            <!-- Goal 4 task 1: Force wake — the parked-task
+                                 recovery (a goal run routes to the goal's
+                                 wake). -->
+                            <button type="button" stm-if="task.force_wake != null"
+                                    stm-action="task.force_wake"
+                                    stm-confirm="Force-woke this parked task? Its agent resumes immediately on its saddle - one wake per press.">Force wake</button>
+                            <button type="button" stm-if="!task.draft && !task.terminal"
+                                    stm-action="task.cancel" stm-confirm="Cancel this task?">Cancel task</button>
                         </form>
-                    </div>
-                </div>
+                        <form class="cluster">
+                            <input type="hidden" name="task_id" stm-attribute.value="task.id">
+                            <button type="button" stm-action="task.toggle_notify"
+                                    stm-text="task.notify_me ? 'Don&#39;t notify me' : 'Notify me'">Notify me</button>
+                            <span class="text-muted">— this task&#39;s first start, failure and completion
+                                (questions and reviews always notify subscribed users)</span>
+                        </form>
+                    </section>
 
-                <p><span stm-attribute.class="'badge badge-' + block.block_type" stm-text="block.block_type">type</span>
-                    <span class="muted" stm-text="block.created_display">when</span></p>
+                    <!-- The short-property in-place editor (goal 41 T5):
+                         the edit surface adopts the data-long-form
+                         in-place pattern (ux-notes expander-fatigue
+                         rule 3: short forms prefer in-place - a read
+                         view + Edit toggle shows the current values
+                         instead of a cold form behind an expander).
+                         Drafts edit every field except status; active
+                         tasks edit priority only; terminal tasks edit
+                         nothing. The mandate keeps its own in-place
+                         editor on the mandate card (task 12). -->
+                    <section class="card" data-long-form data-field="task"
+                             data-lf-open="0"
+                             stm-if="task.editable_all || task.editable_priority">
+                        <h3>Edit task
+                            <button type="button" class="btn-ghost btn-sm" data-lf-toggle>Edit</button></h3>
+                        <div data-lf-view>
+                            <p class="text-muted" stm-if="task.editable_all">Draft — every field except status is editable.</p>
+                            <p class="text-muted" stm-if="!task.editable_all && task.editable_priority">Work has begun — priority only.</p>
+                        </div>
+                        <div stm-if="task.editable_all">
+                            <form data-lf-edit hidden stm-action="task.update">
+                                <input type="hidden" name="task_id" stm-attribute.value="task.id">
+                                <div class="field"><label>Title</label>
+                                    <input type="text" name="title" stm-attribute.value="task.title"></div>
+                                <div class="field" stm-if="!task.is_goal_run"><label>Mode</label>
+                                    <select name="mode" stm-attribute.value="task.mode">
+                                        <option value="implement">implement — build the change on a task branch</option>
+                                        <option value="investigate">investigate — explore and report, no code changes</option>
+                                        <option value="review">review — read and critique existing work</option>
+                                        <option value="test">test — write and run tests</option>
+                                    </select></div>
+                                <div class="field" stm-if="task.creator == 'human' && !task.is_goal_run"><label>Auto-enqueue after
+                                        <span class="text-muted">(enqueues itself when the predecessor is done; none clears it)</span></label>
+                                    <select name="enqueue_after_task_id">
+                                        <option value="">none</option>
+                                        <option stm-for-option-in="task.enqueue_after_options" stm-attribute.value="option.id"
+                                                 stm-text="'#' + option.id + ' ' + option.title">task</option>
+                                    </select></div>
+                                <div class="field"><label>Priority (1–9)</label>
+                                    <input type="number" name="priority" min="1" max="9" stm-attribute.value="task.priority"></div>
+                                <div class="field"><label>Task branch</label>
+                                    <input type="text" name="task_branch" stm-attribute.value="task.task_branch" placeholder="task/<id>-<slug>"></div>
+                                <div class="field"><label>Branch from</label>
+                                    <input type="text" name="branch_from" stm-attribute.value="task.branch_from"></div>
+                                <button type="submit" class="btn-primary">Save</button>
+                                <button type="button" data-lf-toggle>Cancel</button>
+                            </form>
+                        </div>
+                        <div stm-if="!task.editable_all && task.editable_priority">
+                            <form data-lf-edit hidden stm-action="task.update">
+                                <input type="hidden" name="task_id" stm-attribute.value="task.id">
+                                <div class="field"><label>Priority (1–9)</label>
+                                    <input type="number" name="priority" min="1" max="9" stm-attribute.value="task.priority"></div>
+                                <button type="submit" class="btn-primary">Save</button>
+                                <button type="button" data-lf-toggle>Cancel</button>
+                            </form>
+                        </div>
+                    </section>
 
-                <div stm-if="block.show_body && block.body.length > 0" class="md rfc-body"
-                     stm-markdown="block.body">body</div>
+                    <section data-subnav-section="task-comments" id="task-comments">
+                        <h3>Comments</h3>
+                        <!-- Plan 15 C2: harness-authored bookkeeping (aborts,
+                             requeues, retries, session restorations) renders as a
+                             distinct muted SYSTEM row — never an agent comment. -->
+                        <div stm-for-comment-in="task.comments" stm-attribute.class="'comment' + (comment.from_agent ? ' agent' : '') + (comment.is_system ? ' system' : '')">
+                            <strong stm-if="!comment.is_system" stm-text="comment.author">author</strong>
+                            <span stm-if="comment.is_system" class="system-label">system</span>
+                            <span class="text-muted" stm-text="comment.when_display">when</span>
+                            <div class="md" stm-markdown="comment.body">body</div>
+                            <blockquote stm-if="comment.quote.length > 0" stm-text="comment.quote">quote</blockquote>
+                        </div>
+                        <p stm-if="task.comments.length == 0" class="text-muted">No comments yet.</p>
+                        <!-- Goal 8 task 4: comments stay OPEN on terminal tasks.
+                             A terminal NON-GOAL task resurrects on comment (it
+                             re-queues with the comment injected); a terminal
+                             goal-owned worker keeps its goal agent as the only
+                             voice — the hint says so and the service refuses. -->
+                        <form stm-if="!(task.terminal && task.goal_id > 0)" stm-action="task.comment">
+                            <input type="hidden" name="task_id" stm-attribute.value="task.id">
+                            <div class="field"><label for="comment-body">Add comment</label>
+                                <textarea id="comment-body" name="body" rows="3" placeholder="A comment for the agent"></textarea></div>
+                            <p stm-if="task.terminal && task.goal_id == 0" class="text-muted">
+                                This task is terminal — commenting re-queues it for another round
+                                with your comment injected.</p>
+                            <button type="submit" class="btn-primary">Comment</button>
+                        </form>
+                        <p stm-if="task.terminal && task.goal_id > 0" class="text-muted">
+                            Comments are closed — this task belongs to its goal; the goal agent
+                            (its goal's page) owns the conversation.</p>
 
-                <div stm-if="block.answered">
-                    <p>Answer: <strong stm-text="block.answer">answer</strong>
-                        <span class="muted" stm-text="block.answered_display">when</span>
-                        <span class="badge" stm-if="block.answered_by == 'auto'">auto</span></p>
+                        <!-- The highlight-to-comment popover (main.html) fills and opens
+                             this dialog; it went missing in a template rework, which broke
+                             quoting on RFC bodies. T11 normalised it onto the shared
+                             dialog shape (titlebar/body/footer). -->
+                        <dialog id="quote-comment-dialog" class="dialog-sm">
+                            <div class="dialog-titlebar">
+                                <p class="dialog-title">Comment on selected text</p>
+                                <button type="button" class="dialog-close" aria-label="Close"
+                                        onclick="document.getElementById('quote-comment-dialog').close()">×</button>
+                            </div>
+                            <form stm-action="task.comment">
+                                <div class="dialog-body form-rows">
+                                    <blockquote id="quote-text" class="term-comment-quote"></blockquote>
+                                    <input type="hidden" name="task_id" stm-attribute.value="task.id">
+                                    <input type="hidden" name="quote">
+                                    <input type="hidden" name="return_to">
+                                    <textarea name="body" rows="4" placeholder="Your comment…"></textarea>
+                                </div>
+                                <div class="dialog-footer">
+                                    <button type="button" onclick="document.getElementById('quote-comment-dialog').close()">Cancel</button>
+                                    <button type="submit" class="btn-primary">Send comment</button>
+                                </div>
+                            </form>
+                        </dialog>
+                    </section>
                 </div>
 
-                <pstm-fragment name="block-answer"/>
-                <pstm-fragment name="block-reply"/>
-            </div>
-            <p stm-if="task.open_blocks.length == 0 && task.answered_blocks.length == 0" class="muted">No blocks — the agent has no open questions.</p>
+                <!-- ================ CONSULT ================ -->
+                <div class="stack">
+                    <!-- Plan 12: the mandate — the plan-11 "Description"
+                         card, framed as the binding work order. Task 12:
+                         drafts swap the rendered markdown for an in-place
+                         editor (initLongFormEditors in main.html).
+                         Consult column lead: the single most useful read. -->
+                    <section class="card" data-long-form data-field="task"
+                             data-lf-open="0">
+                        <h3>Mandate
+                            <button type="button" class="cluster" stm-if="task.editable_all"
+                                    data-lf-toggle>Edit</button></h3>
+                        <div data-lf-view>
+                            <div stm-if="task.description.length > 0" class="md" stm-markdown="task.description">description</div>
+                            <p class="text-muted" stm-if="task.description.length == 0">No mandate.</p>
+                        </div>
+                        <!-- The stm-if wrapper (not the form itself) owns
+                             draft-gating: statum toggles `hidden` on
+                             stm-if branches, so the form inside keeps its
+                             visibility JS-owned (initLongFormEditors). -->
+                        <div stm-if="task.editable_all">
+                            <form data-lf-edit hidden stm-action="task.update">
+                                <input type="hidden" name="task_id" stm-attribute.value="task.id">
+                                <div class="field"><label>Mandate</label>
+                                    <textarea name="description" rows="8"
+                                              data-lf-src="task.description">description</textarea></div>
+                                <button type="submit">Save</button>
+                                <button type="button" data-lf-toggle>Cancel</button>
+                            </form>
+                        </div>
+                    </section>
 
-            <!-- Historical (answered) blocks behind an expander, the
-                 current ones in full above (plan 11 task D). -->
-            <details class="blocks-history" stm-if="task.answered_blocks.length > 0">
-                <summary>Answered blocks (<span stm-text="task.answered_blocks.length">0</span>)</summary>
-                <div stm-for-block-in="task.answered_blocks" stm-key="block.id"
-                     stm-attribute.class="'block-card answered ' + block.block_type">
-                    <p><span stm-attribute.class="'badge badge-' + block.block_type" stm-text="block.block_type">type</span>
-                        <span stm-text="block.question">question</span>
-                        <span class="muted" stm-text="block.created_display">when</span></p>
-                    <div stm-if="block.show_body && block.body.length > 0" class="md rfc-body"
-                         stm-markdown="block.body">body</div>
-                    <p>Answer: <strong stm-text="block.answer">answer</strong>
-                        <span class="muted" stm-text="block.answered_display">when</span>
-                        <span class="badge" stm-if="block.answered_by == 'auto'">auto</span></p>
-                </div>
-            </details>
+                    <!-- Plan 12: the goal reference — a goal task or the
+                         goal agent's own run links back to its goal. -->
+                    <section class="card" stm-if="task.goal_id > 0">
+                        <h3>Goal</h3>
+                        <p><span stm-attribute.class="'badge badge-' + task.goal_status"
+                                 stm-text="task.goal_status.replace('_', ' ')">status</span>
+                            <a stm-attribute.href="'/goals/' + task.goal_id" stm-text="task.goal_title">goal</a></p>
+                        <p class="text-muted">Goal branch <code stm-text="task.goal_branch">branch</code>
+                            <span stm-if="task.is_goal_run">— this task IS the goal agent's run
+                                (orchestrate mode; the goal page owns its lifecycle)</span>
+                            <span stm-if="!task.is_goal_run">— this task runs under the goal agent's
+                                supervision on an ephemeral branch</span></p>
+                    </section>
 
-            <h3>Comments</h3>
-            <!-- Plan 15 C2: harness-authored bookkeeping (aborts,
-                 requeues, retries, session restorations) renders as a
-                 distinct muted SYSTEM row — never an agent comment. -->
-            <div stm-for-comment-in="task.comments" stm-attribute.class="'comment' + (comment.from_agent ? ' agent' : '') + (comment.is_system ? ' system' : '')">
-                <strong stm-if="!comment.is_system" stm-text="comment.author">author</strong>
-                <span stm-if="comment.is_system" class="system-label">system</span>
-                <span class="muted" stm-text="comment.when_display">when</span>
-                <div class="md" stm-markdown="comment.body">body</div>
-                <blockquote stm-if="comment.quote.length > 0" stm-text="comment.quote">quote</blockquote>
-            </div>
-            <p stm-if="task.comments.length == 0" class="muted">No comments yet.</p>
-            <!-- Goal 8 task 4: comments stay OPEN on terminal tasks.
-                 A terminal NON-GOAL task resurrects on comment (it
-                 re-queues with the comment injected); a terminal
-                 goal-owned worker keeps its goal agent as the only
-                 voice — the hint says so and the service refuses. -->
-            <form stm-if="!(task.terminal && task.goal_id > 0)" stm-action="task.comment">
-                <input type="hidden" name="task_id" stm-attribute.value="task.id">
-                <div class="form-row"><label for="comment-body">Add comment</label>
-                    <textarea id="comment-body" name="body" rows="3" placeholder="A comment for the agent"></textarea></div>
-                <p stm-if="task.terminal && task.goal_id == 0" class="muted">
-                    This task is terminal — commenting re-queues it for another round
-                    with your comment injected.</p>
-                <button type="submit" class="primary">Comment</button>
-            </form>
-            <p stm-if="task.terminal && task.goal_id > 0" class="muted">
-                Comments are closed — this task belongs to its goal; the goal agent
-                (its goal's page) owns the conversation.</p>
+                    <!-- Goal 41 T5 (ux-notes task detail C + quick win 6):
+                         the low-traffic reference sections - Rules,
+                         Required capabilities, Sessions - cluster into
+                         ONE collapsed "Configuration & history" region
+                         with a summary count line, so reference
+                         material no longer competes with the action
+                         surfaces. Spry styles the bare details. -->
+                    <section data-subnav-section="task-config" id="task-config">
+                        <details class="config-history">
+                            <summary><strong>Configuration &amp; history</strong>
+                                <span class="text-muted">— <span stm-text="task.rules.length">0</span> rules ·
+                                    <span stm-text="task.capabilities.length">0</span> capabilities ·
+                                    <span stm-text="task.sessions.length">0</span> sessions</span></summary>
+                            <div class="stack mt-2">
+                                <div>
+                                    <h4>Rules</h4>
+                                    <p class="text-muted">Task rules lead the effective set dispatched with this task.</p>
+                                    <div stm-for-rule-in="task.rules" stm-key="rule.link_id" class="card">
+                                        <h3 stm-text="rule.name">name</h3>
+                                        <div class="md" stm-markdown="rule.body">body</div>
+                                        <form class="cluster">
+                                            <button type="button" stm-action="rule.detach" stm-confirm="Detach this rule?">Detach</button>
+                                        </form>
+                                    </div>
+                                    <p stm-if="task.rules.length == 0" class="text-muted">No task-specific rules.</p>
+                                    <form class="cluster" stm-action="task.attach_rule" stm-if="task.rule_options.length > 0">
+                                        <label for="task-rule-target">Attach rule</label>
+                                        <select id="task-rule-target" name="rule_id" required>
+                                            <option stm-for-option-in="task.rule_options" stm-attribute.value="option.id"
+                                                    stm-text="option.name">rule</option>
+                                        </select>
+                                        <button type="submit">Attach</button>
+                                    </form>
+                                </div>
 
-            <!-- The highlight-to-comment popover (main.html) fills and opens
-                 this dialog; it went missing in a template rework, which broke
-                 quoting on RFC bodies. -->
-            <dialog id="quote-comment-dialog">
-                <h3>Comment on selected text</h3>
-                <blockquote id="quote-text" class="term-comment-quote"></blockquote>
-                <form stm-action="task.comment">
-                    <input type="hidden" name="task_id" stm-attribute.value="task.id">
-                    <input type="hidden" name="quote">
-                    <input type="hidden" name="return_to">
-                    <textarea name="body" rows="4" placeholder="Your comment…" style="width:100%; margin:0.5em 0;"></textarea>
-                    <button type="submit" class="primary">Send comment</button>
-                    <button type="button" onclick="document.getElementById('quote-comment-dialog').close()">Cancel</button>
-                </form>
-            </dialog>
+                                <div>
+                                    <h4>Required capabilities</h4>
+                                    <p class="text-muted">The dispatcher only assigns this task to a saddle carrying all of these tags.</p>
+                                    <p stm-if="task.capabilities.length > 0">
+                                        <span stm-for-capability-in="task.capabilities" stm-key="capability.link_id">
+                                            <span class="badge"><span stm-text="capability.name">tag</span>
+                                                <button type="button" class="btn-ghost chip-remove"
+                                                        stm-action="capability.detach" stm-confirm="Remove this requirement?">×</button></span>
+                                        </span>
+                                    </p>
+                                    <p stm-if="task.capabilities.length == 0" class="text-muted">No requirements — any idle saddle may run it.</p>
+                                    <form class="cluster" stm-action="task.attach_capability" stm-if="task.capability_options.length > 0">
+                                        <label for="task-capability-target">Require capability</label>
+                                        <select id="task-capability-target" name="capability_id" required>
+                                            <option stm-for-option-in="task.capability_options" stm-attribute.value="option.id"
+                                                    stm-text="option.name">capability</option>
+                                        </select>
+                                        <button type="submit">Require</button>
+                                    </form>
+                                </div>
 
-            <h3>Rules</h3>
-            <p class="muted">Task rules lead the effective set dispatched with this task.</p>
-            <div stm-for-rule-in="task.rules" stm-key="rule.link_id" class="card">
-                <h3 stm-text="rule.name">name</h3>
-                <div class="md" stm-markdown="rule.body">body</div>
-                <form class="inline">
-                    <button type="button" stm-action="rule.detach" stm-confirm="Detach this rule?">Detach</button>
-                </form>
+                                <div>
+                                    <h4>Sessions</h4>
+                                    <p stm-if="task.saddle_health.length > 0" class="text-muted"
+                                       stm-text="task.saddle_health">saddle health</p>
+                                    <div class="table-wrap">
+                                        <table>
+                                            <thead><tr><th>#</th><th>Saddle</th><th>Started</th><th>Ended</th><th>Outcome</th></tr></thead>
+                                            <tbody>
+                                                <tr stm-for-session-in="task.sessions">
+                                                    <td stm-text="session.id">id</td>
+                                                    <td stm-text="session.saddle_name">saddle</td>
+                                                    <td stm-text="session.started_display">started</td>
+                                                    <td stm-text="session.ended_display">ended</td>
+                                                    <td stm-if="session.open"><span class="badge badge-working">open</span></td>
+                                                    <td stm-if="!session.open" stm-text="session.outcome">outcome</td>
+                                                </tr>
+                                                <tr stm-if="task.sessions.length == 0"><td colspan="5" class="text-muted">No sessions yet.</td></tr>
+                                            </tbody>
+                                        </table>
+                                    </div>
+                                </div>
+                            </div>
+                        </details>
+                    </section>
+                </div>
             </div>
-            <p stm-if="task.rules.length == 0" class="muted">No task-specific rules.</p>
-            <form class="inline" stm-action="task.attach_rule" stm-if="task.rule_options.length > 0">
-                <label for="task-rule-target">Attach rule</label>
-                <select id="task-rule-target" name="rule_id" required>
-                    <option stm-for-option-in="task.rule_options" stm-attribute.value="option.id"
-                             stm-text="option.name">rule</option>
-                </select>
-                <button type="submit">Attach</button>
-            </form>
+                    </section>
 
-            <h3>Required capabilities</h3>
-            <p class="muted">The dispatcher only assigns this task to a saddle carrying all of these tags.</p>
-            <p stm-if="task.capabilities.length > 0">
-                <span stm-for-capability-in="task.capabilities" stm-key="capability.link_id">
-                    <span class="badge"><span stm-text="capability.name">tag</span>
-                        <button type="button" class="inline chip-remove"
-                                stm-action="capability.detach" stm-confirm="Remove this requirement?">×</button></span>
-                </span>
-            </p>
-            <p stm-if="task.capabilities.length == 0" class="muted">No requirements — any idle saddle may run it.</p>
-            <form class="inline" stm-action="task.attach_capability" stm-if="task.capability_options.length > 0">
-                <label for="task-capability-target">Require capability</label>
-                <select id="task-capability-target" name="capability_id" required>
-                    <option stm-for-option-in="task.capability_options" stm-attribute.value="option.id"
-                             stm-text="option.name">capability</option>
-                </select>
-                <button type="submit">Require</button>
-            </form>
-
-            <h3>Sessions</h3>
-            <p stm-if="task.saddle_health.length > 0" class="muted"
-               stm-text="task.saddle_health">saddle health</p>
-            <table>
-                <thead><tr><th>#</th><th>Saddle</th><th>Started</th><th>Ended</th><th>Outcome</th><th></th></tr></thead>
-                <tbody>
-                    <tr stm-for-session-in="task.sessions">
-                        <td stm-text="session.id">id</td>
-                        <td stm-text="session.saddle_name">saddle</td>
-                        <td stm-text="session.started_display">started</td>
-                        <td stm-text="session.ended_display">ended</td>
-                        <td stm-if="session.open"><span class="badge badge-working">open</span></td>
-                        <td stm-if="!session.open" stm-text="session.outcome">outcome</td>
-                        <td><a stm-attribute.href="'/tasks/' + task.id + '/log'"
-                               target="_blank" rel="noopener">Event log</a></td>
-                    </tr>
-                    <tr stm-if="task.sessions.length == 0"><td colspan="6" class="muted">No sessions yet.</td></tr>
-                </tbody>
-            </table>
+                    <!-- Goal 41 T7: the LOG panel - the shared shell.
+                         The partial binds the page's `task_log` slot
+                         (the entrypoint publishes it beside `task`);
+                         task_log.js mounts the SSE feed from the same
+                         [data-log-*] hooks it uses on the standalone
+                         page. The standalone route keeps its URL; the
+                         tab IS the same feed, so the natural loop
+                         (task -> log -> task) never leaves the page.
+                         The feed's internal 72vh scroller keeps deep
+                         history navigable inside the panel. -->
+                    <section class="task-log-panel" aria-label="Event log">
+                        <div class="cluster cluster-between">
+                            <p class="text-muted">the unified event ledger · newest at the bottom ·
+                                loads older history on demand</p>
+                            <span class="log-live" data-log-live><span class="live-dot"></span><span
+                                    data-live-label>live</span></span>
+                        </div>
+                        <pstm-fragment name="task-log-feed"/>
+                    </section>
+                </div>
+            </div>
         </div>
     </section>
 </body>

+ 26 - 31
src/pages/task_log.html

@@ -7,37 +7,32 @@
 </head>
 <body>
     <div class="page log-page">
-        <p class="log-back"><a href="/tasks">&larr; Tasks</a></p>
-        <div stm-if="!task_log.exists" class="muted">No task exists with this id.</div>
-        <div stm-if="task_log.exists">
-            <header class="log-header">
-                <div class="log-title">
-                    <h2 data-log-title>Event log</h2>
-                    <p class="muted">the unified event ledger · newest at the bottom ·
-                        loads older history on demand</p>
-                </div>
-                <div class="log-actions">
-                    <span class="log-live" data-log-live><span class="live-dot"></span><span
-                            data-live-label>live</span></span>
-                    <a class="log-task-link" stm-attribute.href="task_log.task_url">Task page</a>
-                </div>
-            </header>
-            <section class="log-shell">
-                <div class="log-toolbar">
-                    <span class="log-toolbar-label">Show</span>
-                    <button type="button" class="log-filter active" data-log-filter="commands">commands</button>
-                    <button type="button" class="log-filter active" data-log-filter="results">results</button>
-                    <button type="button" class="log-filter active" data-log-filter="comments">comments</button>
-                    <button type="button" class="log-filter active" data-log-filter="transitions">transitions</button>
-                    <button type="button" class="log-filter active" data-log-filter="usage">usage</button>
-                    <input type="search" data-log-search placeholder="Search events…">
-                </div>
-                <div class="log-feed" data-log-feed>
-                    <button type="button" class="log-more" data-log-more hidden>Load older events</button>
-                    <p class="muted log-empty">Waiting for the first events…</p>
-                </div>
-            </section>
-        </div>
+        <!-- Goal 41 T5 (ux-notes event log B): one context header
+             instead of a link farm. The back-chevron, the task id +
+             title (the script fills data-log-title from the slot) and
+             the live badge sit in ONE row - the reader always knows
+             whose transcript this is and how to get back to its
+             action surface.
+
+             Goal 41 T7 (structural bet 3): the interactive shell
+             itself moved into the task-log-feed partial - the SAME
+             shell now rides the task page's "Log" tab, so this route
+             keeps its URL (deep links, the timeline's log links)
+             while both surfaces stay one markup. The standalone page
+             remains the forensic full-window view of the same feed. -->
+        <header class="log-header">
+            <div class="log-title">
+                <p class="log-back"><a stm-attribute.href="task_log.task_url">&larr; Task page</a></p>
+                <h2 data-log-title>Event log</h2>
+                <p class="text-muted">the unified event ledger · newest at the bottom ·
+                    loads older history on demand</p>
+            </div>
+            <div class="log-actions">
+                <span class="log-live" data-log-live><span class="live-dot"></span><span
+                        data-live-label>live</span></span>
+            </div>
+        </header>
+        <pstm-fragment name="task-log-feed"/>
     </div>
 </body>
 </html>

+ 48 - 5
src/pages/task_log.js

@@ -88,6 +88,7 @@
             };
             var feed = null;
             var moreButton = null;
+            var jumpButton = null;
             var stream = null;
             var liveBadge = null;
             var liveLabel = null;
@@ -182,9 +183,23 @@
             }
 
             // Pin the feed to its bottom edge (the newest row).
+            // Reaching the tail retires the jump button (goal 41 T5).
             function scrollToBottom() {
                 if (!feed) return;
                 feed.scrollTop = feed.scrollHeight;
+                updateJump();
+            }
+
+            // Goal 41 T5 (ux-notes event log A): the jump-to-latest
+            // affordance. The feed already owns the pinned/tail
+            // distinction for its stick rule; this surfaces it - the
+            // button appears once the reader scrolls away from the
+            // newest row and disappears at the tail.
+            function updateJump() {
+                if (!jumpButton) return;
+                var away = feed && feed.scrollHeight - feed.scrollTop
+                    - feed.clientHeight > STICK_THRESHOLD;
+                jumpButton.hidden = !away;
             }
 
             // The daemon JSON-encodes tool arguments and tool
@@ -410,7 +425,7 @@
                                 ? compact : JSON.stringify(extras, null, 2)));
                         } else {
                             var empty = document.createElement('p');
-                            empty.className = 'muted';
+                            empty.className = 'text-muted';
                             empty.textContent = '(no output)';
                             div.appendChild(empty);
                         }
@@ -595,7 +610,7 @@
                 pre.className = 'event-pre event-command';
                 if (bits && bits.length > 0) {
                     var tail = document.createElement('span');
-                    tail.className = 'muted';
+                    tail.className = 'text-muted';
                     tail.textContent = '  \u00b7 ' + bits.join(' \u00b7 ');
                     pre.appendChild(tail);
                 }
@@ -957,7 +972,7 @@
                 badge.textContent = String(evt.kind || '').replace(/_/g, ' ');
                 head.appendChild(badge);
                 var actor = document.createElement('span');
-                actor.className = 'muted';
+                actor.className = 'text-muted';
                 actor.textContent = ' ' + (evt.actor || '') + ' \u00b7 '
                     + (evt.created_display || '');
                 head.appendChild(actor);
@@ -1007,7 +1022,7 @@
                     var quote = document.createElement('blockquote');
                     if (p.quote) {
                         var q = document.createElement('p');
-                        q.className = 'muted';
+                        q.className = 'text-muted';
                         q.textContent = '> ' + p.quote;
                         quote.appendChild(q);
                     }
@@ -1227,7 +1242,7 @@
                 var visibleRows = state.events.filter(visible);
                 if (visibleRows.length === 0) {
                     var empty = document.createElement('p');
-                    empty.className = 'muted log-empty';
+                    empty.className = 'text-muted log-empty';
                     empty.textContent = state.events.length === 0
                         ? 'Waiting for the first events\u2026'
                         : 'No events match the filters.';
@@ -1402,11 +1417,24 @@
                 }
             });
 
+            // Goal 41 T5: the sticky toolbar parks under the sticky
+            // topbar; publish the measured topbar height as a custom
+            // property so app.css's `top:` tracks any wrap/viewport.
+            function syncTopbarOffset() {
+                var bar = document.querySelector('.topbar');
+                if (bar && bar.offsetHeight > 0) {
+                    document.documentElement.style.setProperty(
+                        '--topbar-bottom', bar.offsetHeight + 'px');
+                }
+            }
+
             statum.afterEvaluate(function () {
                 var page = statum.read('task_log');
                 if (!page || !page.exists) return;
+                syncTopbarOffset();
                 feed = document.querySelector('[data-log-feed]');
                 moreButton = document.querySelector('[data-log-more]');
+                jumpButton = document.querySelector('[data-log-jump]');
                 liveBadge = document.querySelector('[data-log-live]');
                 liveLabel = document.querySelector('[data-live-label]');
                 taskStatus = strText(page.task_status);
@@ -1416,6 +1444,21 @@
                     header.textContent = '#' + page.task_id + ' \u2014 '
                         + page.task_title;
                 }
+                // afterEvaluate fires every render cycle; the
+                // static toolbar elements are never rebuilt, so bind
+                // once and let later cycles only refresh state.
+                if (jumpButton && jumpButton.getAttribute('data-bound') !== '1') {
+                    jumpButton.setAttribute('data-bound', '1');
+                    jumpButton.addEventListener('click', function () {
+                        scrollToBottom();
+                    });
+                }
+                if (feed && feed.getAttribute('data-scroll-bound') !== '1') {
+                    feed.setAttribute('data-scroll-bound', '1');
+                    feed.addEventListener('scroll', function () {
+                        updateJump();
+                    }, { passive: true });
+                }
                 if (!stream && feed) connect();
                 render();
             });

+ 51 - 0
src/pages/task_log_feed.html

@@ -0,0 +1,51 @@
+<!DOCTYPE html>
+<html lang="en">
+<head>
+    <!-- A partial (goal 41 T7, ux-notes structural bet 3): the event
+         log's interactive shell - sticky toolbar, live feed, filters,
+         search, load-older - shared by TWO surfaces:
+
+         * the standalone /tasks/{task_id}/log page (task_log.html),
+           whose entrypoint publishes the `task_log` slot;
+         * the task page's embedded "Log" tab panel (task_detail.html),
+           whose entrypoint publishes the SAME slot shape alongside
+         `task`.
+
+         The including page supplies nothing: the shell binds the page's
+         `task_log` slot (exists / task_id / task_title / task_status /
+         task_url) and task_log.js - loaded once per page via
+         pstm-res-src - mounts everything from [data-log-*] hooks. The
+         surrounding page owns the context header (the standalone page
+         draws its own; the task page's tabs already name the task). -->
+    <pstm-name>task-log-feed</pstm-name>
+</head>
+<body>
+    <div stm-if="!task_log.exists" class="text-muted">No task exists with this id.</div>
+    <div stm-if="task_log.exists">
+        <section class="log-shell">
+            <!-- Goal 41 T5 (ux-notes event log A): the toolbar
+                 (filters + search) is STICKY inside the shell, so
+                 the controls survive deep scrolling through the
+                 feed - the position work lives in app.css's
+                 goal-41 log section; the DOM (and every filter
+                 behaviour) is unchanged. The jump-to-latest
+                 button rides the toolbar: the feed's own
+                 at-bottom/pinned state drives it from task_log.js. -->
+            <div class="log-toolbar">
+                <span class="log-toolbar-label">Show</span>
+                <button type="button" class="log-filter active" data-log-filter="commands">commands</button>
+                <button type="button" class="log-filter active" data-log-filter="results">results</button>
+                <button type="button" class="log-filter active" data-log-filter="comments">comments</button>
+                <button type="button" class="log-filter active" data-log-filter="transitions">transitions</button>
+                <button type="button" class="log-filter active" data-log-filter="usage">usage</button>
+                    <input type="search" data-log-search placeholder="Search events…">
+                <button type="button" class="log-jump" data-log-jump hidden>Jump to latest</button>
+            </div>
+            <div class="log-feed" data-log-feed>
+                <button type="button" class="log-more" data-log-more hidden>Load older events</button>
+                <p class="text-muted log-empty">Waiting for the first events…</p>
+            </div>
+        </section>
+    </div>
+</body>
+</html>

+ 409 - 74
src/pages/tasks.html

@@ -6,23 +6,58 @@
 <script>
     // The task queue. Plan 12: the filters (status / project / goal)
     // run CLIENT-SIDE - the Statum entrypoint fetch never carries the
-    // page's own query string to the server, so selects filter the
-    // rendered rows directly (window.taskFilters + taskRowVisible)
-    // and survive re-renders; the create form gains the mode select
-    // (with descriptions) and the auto-enqueue-after dropdown (the
-    // global human-task list; goal tasks are the goal agent's to
-    // sequence).
+    // page's own query string to the server, so the status chips and
+    // the selects filter the rendered rows directly (window.taskFilters
+    // + taskRowVisible) and survive re-renders; the create form gains
+    // the mode select (with descriptions) and the auto-enqueue-after
+    // dropdown (the global human-task list; goal tasks are the goal
+    // agent's to sequence).
+    //
+    // Goal 41 T4: the status select became faceted chips with counts
+    // (the goal-detail strip pattern) and the default view is OPEN
+    // WORK - queued+assigned+working+blocked+suspended - instead of
+    // "all", so months of done rows no longer bury the queue (ux-notes
+    // tasks A+B). taskFilters.status holds the chip state:
+    //   null          -> every status (the "All" chip)
+    //   'open'        -> the default open-work set
+    //   '<status>'    -> one exact status
     if (window.statum) {
         (function () {
             'use strict';
 
+            window.TASK_OPEN_STATUSES = ['queued', 'assigned', 'working',
+                'blocked', 'suspended'];
+
             window.taskFilters = window.taskFilters || {};
 
+            window.taskStatusCount = function (status) {
+                var tasks = statum.read('tasks');
+                var rows = (tasks && tasks.tasks) || [];
+                var count = 0;
+                for (var i = 0; i < rows.length; i++) {
+                    if (status === 'open') {
+                        if (window.TASK_OPEN_STATUSES.indexOf(rows[i].status) !== -1) {
+                            count += 1;
+                        }
+                    } else if (status === 'all') {
+                        count += 1;
+                    } else if (rows[i].status === status) {
+                        count += 1;
+                    }
+                }
+                return count;
+            };
+
             window.taskRowVisible = function (row) {
                 var filters = window.taskFilters || {};
-                if (filters.status && filters.status !== 'all'
-                        && row.status !== filters.status) {
-                    return false;
+                if (filters.status && filters.status !== 'all') {
+                    if (filters.status === 'open') {
+                        if (window.TASK_OPEN_STATUSES.indexOf(row.status) === -1) {
+                            return false;
+                        }
+                    } else if (row.status !== filters.status) {
+                        return false;
+                    }
                 }
                 if (filters.project && filters.project > 0
                         && String(row.project_id) !== String(filters.project)) {
@@ -42,6 +77,10 @@
 
             window.setTaskFilter = function (name, value) {
                 window.taskFilters[name] = value;
+                // Goal 41 T10: the URL IS the saved view - every filter
+                // change lands in location.hash (replaceState: filter
+                // clicks are not Back-button steps).
+                syncTaskHash();
                 var tasks = statum.read('tasks');
                 if (tasks && tasks.refresh) {
                     statum.act(tasks.refresh).catch(function () { });
@@ -51,27 +90,281 @@
                     document.body.setAttribute('data-filter-bump', String(Date.now()));
                 }
             };
+
+            window.taskChipClass = function (status) {
+                var current = (window.taskFilters || {}).status;
+                if (current == null || current === '') current = 'open';
+                return current === status ? 'goal-filter active' : 'goal-filter';
+            };
+
+            window.setTaskStatusFilter = function (status) {
+                var current = (window.taskFilters || {}).status;
+                if (current == null || current === '') current = 'open';
+                if (current === status) return; // one chip is always on
+                window.setTaskFilter('status', status);
+            };
+
+            // Default to open work until a chip says otherwise.
+            if (window.taskFilters.status == null) {
+                window.taskFilters.status = 'open';
+            }
+
+            // ---- Saved views / URL state (goal 41 T10, ux-notes bet 4) ----
+            // The statum entrypoint fetch carries no query string, so
+            // the queue's filter state lives in location.hash: every
+            // filter change serialises it
+            // (#view?status=working&project=3&goal=standalone), the
+            // hash seeds taskFilters BEFORE the first render, and a
+            // pasted URL restores the view. Namespaces: #new?... is
+            // T9's create-prefill deep-link (consumed and dropped on
+            // load); #view?... is the saved-view state. The serialised
+            // form keeps only NON-default axes so the default URL
+            // stays clean ('open' status, all projects, all goals).
+            var TASK_HASH_AXES = [
+                ['status', 'status'],
+                ['project', 'project'],
+                ['goal', 'goal']
+            ];
+
+            window.taskViewHash = function (filters) {
+                var source = filters || window.taskFilters || {};
+                var parts = [];
+                for (var i = 0; i < TASK_HASH_AXES.length; i++) {
+                    var key = TASK_HASH_AXES[i][0];
+                    var value = source[key];
+                    if (key === 'status') {
+                        if (value == null || value === '') value = 'open';
+                        if (value === 'open') continue; // the default view
+                    } else if (value == null || value === ''
+                            || value === '0' || value === 'all') {
+                        continue; // "every project" / "every goal"
+                    }
+                    parts.push(TASK_HASH_AXES[i][1] + '='
+                        + encodeURIComponent(String(value)));
+                }
+                return parts.length > 0 ? '#view?' + parts.join('&') : '';
+            };
+
+            window.taskFiltersFromHash = function (hash) {
+                var source = hash || window.location.hash || '';
+                if (source.indexOf('#view?') !== 0) return null;
+                var params = {};
+                source.slice(6).split('&').forEach(function (part) {
+                    var eq = part.indexOf('=');
+                    if (eq === -1) return;
+                    var key = decodeURIComponent(part.slice(0, eq));
+                    var value = decodeURIComponent(part.slice(eq + 1));
+                    if (key) params[key] = value;
+                });
+                var filters = {};
+                if (params.status) filters.status = params.status;
+                if (params.project && params.project !== '0') {
+                    filters.project = params.project;
+                }
+                if (params.goal && params.goal !== 'all') {
+                    filters.goal = params.goal;
+                }
+                return filters;
+            };
+
+            // Seed window.taskFilters from the hash (load or an
+            // edited URL): runs BEFORE the first render so the chips
+            // and rows paint the restored view immediately.
+            window.applyTaskViewHash = function () {
+                var filters = window.taskFiltersFromHash();
+                if (filters == null) return;
+                if (filters.status != null) {
+                    window.taskFilters.status = filters.status;
+                }
+                if (filters.project != null) {
+                    window.taskFilters.project = filters.project;
+                }
+                if (filters.goal != null) {
+                    window.taskFilters.goal = filters.goal;
+                }
+            };
+
+            // The selects reflect the seeded filters on first paint:
+            // statum builds the option list only once the slot's data
+            // arrives, so retry until the wanted option exists (the
+            // same shape as the #new prefill pick).
+            function taskFilterSelectsSynced() {
+                var filters = window.taskFilters || {};
+                var select = document.getElementById('filter-project');
+                if (!select || filters.project == null
+                        || filters.project === '0') {
+                    return true;
+                }
+                if (select.querySelector(
+                        'option[value="' + filters.project + '"]')) {
+                    select.value = String(filters.project);
+                    return true;
+                }
+                return false;
+            }
+
+            var taskSelectTries = 0;
+
+            function retryTaskFilterSelects() {
+                if (taskFilterSelectsSynced()) return;
+                if (taskSelectTries++ < 40) {
+                    window.setTimeout(retryTaskFilterSelects, 50);
+                }
+            }
+
+            window.applyTaskViewHash();
+            retryTaskFilterSelects();
+
+            // The URL tracks the view: strip any old hash, then write
+            // the serialised filters when the view is non-default.
+            function syncTaskHash() {
+                var next = window.taskViewHash();
+                var current = window.location.hash || '';
+                if (current === next) return;
+                // replaceState, not pushState: a filter click is not a
+                // navigation step - Back must not walk every chip the
+                // user tried.
+                window.history.replaceState(null, '',
+                    window.location.pathname + window.location.search + next);
+            }
+
+            // The view affordance (bet 4): one click copies the URL
+            // that recreates the current view. The clipboard API needs
+            // a secure context (https or localhost); a fallback builds
+            // the link by hand so the feature degrades to copy-paste
+            // instead of dying.
+            window.copyTaskViewLink = function (button) {
+                var url = window.location.origin
+                    + window.location.pathname + window.taskViewHash();
+                var done = function () {
+                    if (window.showToast) {
+                        window.showToast('View link copied - paste it '
+                            + 'anywhere to share this view');
+                    }
+                };
+                var fallback = function () {
+                    window.prompt('Copy this link to share the view:', url);
+                };
+                if (navigator.clipboard
+                        && navigator.clipboard.writeText) {
+                    navigator.clipboard.writeText(url).then(done, fallback);
+                } else {
+                    fallback();
+                }
+            };
+
+            // A pasted URL with a different #view hash re-seeds the
+            // filters (same-tab paste-and-go arrives as a hashchange
+            // without a reload; other tabs reload, where the load seed
+            // above already covers it).
+            window.addEventListener('hashchange', function () {
+                if (window.taskFiltersFromHash() == null) return;
+                window.applyTaskViewHash();
+                var tasks = statum.read('tasks');
+                if (tasks && tasks.refresh) {
+                    statum.act(tasks.refresh).catch(function () { });
+                }
+                retryTaskFilterSelects();
+            });
+
+            // Goal 41 T9: the project page's "+ New task in this
+            // project" deep-link. The hash #new?project=<id> opens the
+            // create dialog with the project preselected (the dialog
+            // is T4's; this prefill pass is additive and runs once on
+            // load, before the first render wires the select options -
+            // statum's attribute binding then keeps the chosen option).
+            // The history.replaceState drops the hash so a reload does
+            // not reopen the dialog behind the user's back.
+            window.applyNewTaskPrefill = function () {
+                var hash = window.location.hash || '';
+                if (hash.indexOf('#new') !== 0) return;
+                var query = hash.indexOf('?') === -1 ? ''
+                    : hash.slice(hash.indexOf('?') + 1);
+                var params = {};
+                query.split('&').forEach(function (part) {
+                    var eq = part.indexOf('=');
+                    if (eq === -1) return;
+                    var key = decodeURIComponent(part.slice(0, eq));
+                    var value = decodeURIComponent(part.slice(eq + 1));
+                    if (key) params[key] = value;
+                });
+                window.history.replaceState(null, '',
+                    window.location.pathname + window.location.search);
+                var select = document.getElementById('task-project');
+                if (params.project && select) {
+                    // The options may not be rendered yet (the entry's
+                    // slot arrives after first paint): retry briefly
+                    // until statum has built them.
+                    var tries = 0;
+                    var pick = function () {
+                        var option = select.querySelector(
+                            'option[value="' + params.project + '"]');
+                        if (option) {
+                            select.value = params.project;
+                            return;
+                        }
+                        if (tries++ < 40) window.setTimeout(pick, 50);
+                    };
+                    pick();
+                }
+                if (window.openAppDialog) {
+                    window.openAppDialog('task-create-dialog');
+                }
+            };
+            window.applyNewTaskPrefill();
         })();
     }
 </script>
 </head>
 <body>
-    <div class="page">
-        <h1>Tasks</h1>
+    <div class="stack">
 
-        <form class="inline" onsubmit="return false">
-            <label for="filter-status">Status</label>
-            <select id="filter-status" onchange="window.setTaskFilter('status', this.value)">
-                <option value="all">all</option>
-                <option value="draft">draft</option>
-                <option value="queued">queued</option>
-                <option value="assigned">assigned</option>
-                <option value="working">working</option>
-                <option value="blocked">blocked</option>
-                <option value="suspended">suspended</option>
-                <option value="done">done</option>
-                <option value="failed">failed</option>
-            </select>
+        <div class="cluster cluster-between">
+            <h1>Tasks</h1>
+            <button type="button" class="btn-primary"
+                    onclick="window.openAppDialog('task-create-dialog')">+ New task</button>
+        </div>
+
+        <!-- Goal 41 T4: faceted status chips with counts (the
+             goal-strip pattern ported to the queue). One chip is
+             always active; the default is "Open" - queued, assigned,
+             working, blocked, suspended - so the queue leads and
+             done/failed stay one click away with their counts
+             visible. -->
+        <p class="goal-strip">
+            <button type="button" stm-attribute.class="window.taskChipClass('open')"
+                    onclick="window.setTaskStatusFilter('open')"
+                    stm-text="'Open ' + window.taskStatusCount('open')">open</button>
+            <button type="button" stm-attribute.class="window.taskChipClass('draft')"
+                    onclick="window.setTaskStatusFilter('draft')"
+                    stm-text="'draft ' + window.taskStatusCount('draft')">draft</button>
+            <button type="button" stm-attribute.class="window.taskChipClass('queued')"
+                    onclick="window.setTaskStatusFilter('queued')"
+                    stm-text="'queued ' + window.taskStatusCount('queued')">queued</button>
+            <button type="button" stm-attribute.class="window.taskChipClass('assigned')"
+                    onclick="window.setTaskStatusFilter('assigned')"
+                    stm-text="'assigned ' + window.taskStatusCount('assigned')">assigned</button>
+            <button type="button" stm-attribute.class="window.taskChipClass('working')"
+                    onclick="window.setTaskStatusFilter('working')"
+                    stm-text="'working ' + window.taskStatusCount('working')">working</button>
+            <button type="button" stm-attribute.class="window.taskChipClass('blocked')"
+                    onclick="window.setTaskStatusFilter('blocked')"
+                    stm-text="'blocked ' + window.taskStatusCount('blocked')">blocked</button>
+            <button type="button" stm-attribute.class="window.taskChipClass('suspended')"
+                    onclick="window.setTaskStatusFilter('suspended')"
+                    stm-text="'suspended ' + window.taskStatusCount('suspended')">suspended</button>
+            <button type="button" stm-attribute.class="window.taskChipClass('done')"
+                    onclick="window.setTaskStatusFilter('done')"
+                    stm-text="'done ' + window.taskStatusCount('done')">done</button>
+            <button type="button" stm-attribute.class="window.taskChipClass('failed')"
+                    onclick="window.setTaskStatusFilter('failed')"
+                    stm-text="'failed ' + window.taskStatusCount('failed')">failed</button>
+            <button type="button" stm-attribute.class="window.taskChipClass('all')"
+                    onclick="window.setTaskStatusFilter('all')"
+                    stm-text="'all ' + window.taskStatusCount('all')">all</button>
+        </p>
+
+        <form class="cluster" onsubmit="return false">
             <label for="filter-project">Project</label>
             <select id="filter-project" onchange="window.setTaskFilter('project', this.value)">
                 <option value="0">all</option>
@@ -85,13 +378,80 @@
                 <option stm-for-goal-in="tasks.goal_options" stm-attribute.value="goal.id"
                          stm-text="goal.title">goal</option>
             </select>
-            <span class="muted">— filters apply instantly</span>
         </form>
 
-        <details class="history-expander">
-            <summary><strong>New task</strong></summary>
-            <form stm-action="tasks.create">
-                <div class="form-row"><label for="task-project">Project</label>
+        <!-- Goal 41 T10 (ux-notes bet 4): the view row. The presets
+             are the views the chips already express, now one click AND
+             one shareable URL each (the filter state serialises into
+             location.hash - #view?status=blocked is this row's whole
+             vocabulary); "Copy view link" puts the recreating URL on
+             the clipboard so any filtered combination - not just the
+             presets - is shareable. The chips above stay the status
+             axis; the selects stay the scoping axes. -->
+        <div class="cluster task-view-row" data-task-view-row>
+            <span class="text-muted" aria-hidden="true">View</span>
+            <button type="button" class="goal-filter"
+                    stm-attribute.class="window.taskChipClass('open')"
+                    onclick="window.setTaskStatusFilter('open')">Open work</button>
+            <button type="button" class="goal-filter"
+                    stm-attribute.class="window.taskChipClass('all')"
+                    onclick="window.setTaskStatusFilter('all')">All</button>
+            <button type="button" class="goal-filter"
+                    stm-attribute.class="window.taskChipClass('blocked')"
+                    onclick="window.setTaskStatusFilter('blocked')">Blocked</button>
+            <span class="task-view-sep" aria-hidden="true"></span>
+            <button type="button" class="btn-ghost btn-sm" title="Copy a link that reopens this view"
+                    onclick="window.copyTaskViewLink(this)">Copy view link</button>
+        </div>
+
+        <div class="table-wrap">
+            <table class="table-hover">
+
+            <thead>
+                <tr><th>#</th><th>Title</th><th>Status</th><th>Mode</th><th>Goal</th>
+                    <th>Priority</th><th>Project</th><th>Capabilities</th></tr>
+            </thead>
+            <tbody>
+                <tr stm-for-task-in="tasks.tasks" stm-if="window.taskRowVisible(task)">
+                    <td stm-text="task.id">id</td>
+                    <td>
+                        <a stm-attribute.href="'/tasks/' + task.id" stm-text="task.title">title</a>
+                    </td>
+                    <td><span stm-attribute.class="'badge badge-' + task.status" stm-text="task.display">status</span></td>
+                    <td><span stm-attribute.class="'badge badge-mode-' + task.mode" stm-text="task.mode">mode</span></td>
+                    <td stm-if="task.goal_id > 0">
+                        <a stm-attribute.href="'/goals/' + task.goal_id" stm-text="task.goal_title">goal</a>
+                        <span class="text-muted" stm-if="task.creator == 'goal'">(agent)</span>
+                    </td>
+                    <td stm-if="task.goal_id == 0" class="text-muted">—</td>
+                    <td stm-text="task.priority">5</td>
+                    <td stm-text="task.project_name">project</td>
+                    <td><span stm-if="task.capabilities.length > 0"
+                              stm-text="task.capabilities.join(', ')">caps</span>
+                        <span class="text-muted" stm-if="task.capabilities.length == 0">—</span></td>
+                </tr>
+                <tr stm-if="tasks.tasks.length == 0"><td colspan="8" class="text-muted">No tasks.</td></tr>
+            </tbody>
+        
+            </table>
+        </div>
+
+    </div>
+
+    <!-- Goal 41 T4: the create form moved from a collapsed expander
+         into a Spry-styled native <dialog> opened by the "+ New
+         task" button (ux-notes quick win 4). The form itself is
+         unchanged - same fields, same stm-action - and the shared
+         openAppDialog helper closes it on the action's notify. -->
+    <dialog id="task-create-dialog" class="dialog-sm">
+        <div class="dialog-titlebar">
+            <p class="dialog-title">New task</p>
+            <button type="button" class="dialog-close" aria-label="Close"
+                    onclick="window.closeAppDialog('task-create-dialog')">×</button>
+        </div>
+        <form stm-action="tasks.create">
+            <div class="dialog-body form-rows">
+                <div class="field"><label for="task-project">Project</label>
                     <!-- Goal 8 task 4: "No project" posts project_id 0 —
                          a STANDALONE task with its own ephemeral
                          workspace; the completion report is the
@@ -101,27 +461,27 @@
                         <option stm-for-project-in="tasks.projects" stm-attribute.value="project.id"
                                  stm-text="project.name">project</option>
                     </select></div>
-                <div class="form-row"><label for="task-title">Title</label>
+                <div class="field"><label for="task-title">Title</label>
                     <input type="text" id="task-title" name="title" required></div>
-                <div class="form-row"><label for="task-mode">Mode</label>
+                <div class="field"><label for="task-mode">Mode</label>
                     <select id="task-mode" name="mode">
                         <option value="implement" selected>implement — build the change on a task branch</option>
                         <option value="investigate">investigate — explore and report, no code changes</option>
                         <option value="review">review — read and critique existing work</option>
                         <option value="test">test — write and run tests</option>
                     </select></div>
-                <div class="form-row"><label for="task-description">Mandate</label>
+                <div class="field"><label for="task-description">Mandate</label>
                     <textarea id="task-description" name="description" rows="4"
                               placeholder="What the agent must accomplish (the mandate)"></textarea></div>
-                <div class="form-row"><label for="task-branch">Task branch name
-                        <span class="muted">(optional — empty lets the system name the branch; goal worker tasks auto-name task/&lt;goal-id&gt;-&lt;task-id&gt;)</span></label>
+                <div class="field"><label for="task-branch">Task branch name
+                        <span class="text-muted">(optional — empty lets the system name the branch; goal worker tasks auto-name task/&lt;goal-id&gt;-&lt;task-id&gt;)</span></label>
                     <input type="text" id="task-branch" name="task_branch"
                            placeholder="task/&lt;id&gt;-&lt;slug&gt;"></div>
-                <div class="form-row"><label for="task-branch-from">Branch from
-                        <span class="muted">(default: the project's default branch)</span></label>
+                <div class="field"><label for="task-branch-from">Branch from
+                        <span class="text-muted">(default: the project's default branch)</span></label>
                     <input type="text" id="task-branch-from" name="branch_from"
                            placeholder="main"></div>
-                <div class="form-row"><label for="task-priority">Priority</label>
+                <div class="field"><label for="task-priority">Priority</label>
                     <select id="task-priority" name="priority">
                         <option value="1">1 — highest</option>
                         <option value="2">2</option>
@@ -133,54 +493,29 @@
                         <option value="8">8</option>
                         <option value="9">9 — lowest</option>
                     </select></div>
-                <div class="form-row"><label for="task-enqueue-after">Auto-enqueue after
-                        <span class="muted">(optional — enqueues itself when the predecessor is done)</span></label>
+                <div class="field"><label for="task-enqueue-after">Auto-enqueue after
+                        <span class="text-muted">(optional — enqueues itself when the predecessor is done)</span></label>
                     <select id="task-enqueue-after" name="enqueue_after_task_id">
                         <option value="">none</option>
                         <option stm-for-option-in="tasks.enqueue_after_options"
                                  stm-attribute.value="option.id" stm-text="option.name">task</option>
                     </select></div>
-                <div class="form-row" stm-if="tasks.capability_options.length > 0">
+                <div class="field" stm-if="tasks.capability_options.length > 0">
                     <label for="task-capabilities">Capabilities
-                        <span class="muted">(the saddle fleet must carry them until assigned)</span></label>
+                        <span class="text-muted">(the saddle fleet must carry them until assigned)</span></label>
                     <select id="task-capabilities" name="required_capabilities" multiple
                             size="3">
                         <option stm-for-capability-in="tasks.capability_options"
                                  stm-attribute.value="capability.id"
                                  stm-text="capability.name">cap</option>
                     </select></div>
-                <button type="submit" class="primary">Create task</button>
-            </form>
-        </details>
-
-        <table>
-            <thead>
-                <tr><th>#</th><th>Title</th><th>Status</th><th>Mode</th><th>Goal</th>
-                    <th>Priority</th><th>Project</th><th>Capabilities</th></tr>
-            </thead>
-            <tbody>
-                <tr stm-for-task-in="tasks.tasks" stm-if="window.taskRowVisible(task)">
-                    <td stm-text="task.id">id</td>
-                    <td>
-                        <a stm-attribute.href="'/tasks/' + task.id" stm-text="task.title">title</a>
-                    </td>
-                    <td><span stm-attribute.class="'badge badge-' + task.status" stm-text="task.display">status</span></td>
-                    <td><span stm-attribute.class="'badge badge-mode-' + task.mode" stm-text="task.mode">mode</span></td>
-                    <td stm-if="task.goal_id > 0">
-                        <a stm-attribute.href="'/goals/' + task.goal_id" stm-text="task.goal_title">goal</a>
-                        <span class="muted" stm-if="task.creator == 'goal'">(agent)</span>
-                    </td>
-                    <td stm-if="task.goal_id == 0" class="muted">—</td>
-                    <td stm-text="task.priority">5</td>
-                    <td stm-text="task.project_name">project</td>
-                    <td><span stm-if="task.capabilities.length > 0"
-                              stm-text="task.capabilities.join(', ')">caps</span>
-                        <span class="muted" stm-if="task.capabilities.length == 0">—</span></td>
-                </tr>
-                <tr stm-if="tasks.tasks.length == 0"><td colspan="8" class="muted">No tasks.</td></tr>
-            </tbody>
-        </table>
-
-    </div>
+            </div>
+            <div class="dialog-footer">
+                <button type="button" class="btn-ghost"
+                        onclick="window.closeAppDialog('task-create-dialog')">Cancel</button>
+                <button type="submit" class="btn-primary">Create task</button>
+            </div>
+        </form>
+    </dialog>
 </body>
 </html>

+ 88 - 59
src/pages/users.html

@@ -5,37 +5,26 @@
     <pstm-template>main</pstm-template>
 </head>
 <body>
-    <section>
-        <h2>Users</h2>
-        <p class="muted">Accounts and their roles. Normal users manage projects, tasks,
+    <!-- Goal 41 T8: the create form moved from a collapsed expander
+         into a Spry <dialog> behind the "+ New user" button (the
+         expander-fatigue fix; ux-notes shorter notes, Users), the
+         edit expander became an in-place editor and the destructive
+         row actions sit visually secondary. Admin-only semantics are
+         untouched (same stm-actions, same server guards). -->
+    <div class="stack">
+        <div class="cluster cluster-between">
+            <h2>Users</h2>
+            <button type="button" class="btn-primary"
+                    onclick="window.openAppDialog('user-create-dialog')">+ New user</button>
+        </div>
+        <p class="text-muted">Accounts and their roles. Normal users manage projects, tasks,
             goals and rules; admins additionally manage users, saddles and capabilities.</p>
         <p stm-if="users == null">Loading…</p>
 
         <div stm-if="users != null">
-            <details>
-                <summary><strong>New user</strong></summary>
-                <form stm-action="users.create">
-                    <div class="form-row"><label>Username</label>
-                        <input type="text" name="username" required placeholder="ada"></div>
-                    <div class="form-row"><label>Email</label>
-                        <input type="email" name="email" required placeholder="ada@example.com"></div>
-                    <div class="form-row"><label>Forename</label>
-                        <input type="text" name="forename" required placeholder="Ada"></div>
-                    <div class="form-row"><label>Surname</label>
-                        <input type="text" name="surname" required placeholder="Lovelace"></div>
-                    <div class="form-row"><label>Date of birth</label>
-                        <input type="text" name="date_of_birth" required placeholder="1990-01-31"></div>
-                    <div class="form-row"><label>Password</label>
-                        <input type="password" name="password" required placeholder="At least 6 characters"></div>
-                    <div class="form-row"><label>Confirm password</label>
-                        <input type="password" name="confirm_password" required></div>
-                    <div class="form-row"><label>Role</label>
-                        <label class="inline"><input type="checkbox" name="admin" value="1"> Administrator</label></div>
-                    <button type="submit">Create user</button>
-                </form>
-            </details>
+            <div class="table-wrap">
+                <table class="table-hover">
 
-            <table>
                 <thead><tr>
                     <th>Username</th><th>Email</th><th>Name</th><th>Role</th>
                     <th>Enabled</th><th>Created</th><th></th>
@@ -49,45 +38,85 @@
                                   stm-text="user.is_admin ? 'admin' : 'user'">role</span></td>
                         <td><span stm-attribute.class="user.enabled ? 'badge badge-done' : 'badge badge-offline'"
                                   stm-text="user.enabled ? 'enabled' : 'disabled'">enabled</span></td>
-                        <td class="muted" stm-text="user.created_display">when</td>
+                        <td class="text-muted" stm-text="user.created_display">when</td>
                         <td>
-                            <details>
-                                <summary>Edit</summary>
-                                <form stm-action="user.alter">
-                                    <div class="form-row"><label>Username</label>
-                                        <input type="text" name="username" required stm-attribute.value="user.username"></div>
-                                    <div class="form-row"><label>Email</label>
-                                        <input type="email" name="email" required stm-attribute.value="user.email"></div>
-                                    <div class="form-row"><label>Forename</label>
-                                        <input type="text" name="forename" required stm-attribute.value="user.forename"></div>
-                                    <div class="form-row"><label>Surname</label>
-                                        <input type="text" name="surname" required stm-attribute.value="user.surname"></div>
-                                    <div class="form-row"><label>Date of birth</label>
-                                        <input type="text" name="date_of_birth" required stm-attribute.value="user.date_of_birth"></div>
-                                    <div class="form-row"><label>Enabled</label>
-                                        <label class="inline"><input type="checkbox" name="enabled" value="1"
-                                               stm-attribute.checked="user.enabled ? '' : undefined"> Account enabled</label></div>
-                                    <div class="form-row"><label>New password</label>
-                                        <input type="password" name="new_password"
-                                               placeholder="Leave empty to keep the current password"></div>
-                                    <button type="submit">Save</button>
-                                    <span class="muted">Your own account is edited on your profile page.</span>
-                                </form>
-                            </details>
-                            <form class="inline">
-                                <button type="button" stm-action="user.set_enabled"
+                            <div class="cluster">
+                                <details>
+                                    <summary>Edit</summary>
+                                    <form stm-action="user.alter" class="form-rows">
+                                        <div class="field"><label>Username</label>
+                                            <input type="text" name="username" required stm-attribute.value="user.username"></div>
+                                        <div class="field"><label>Email</label>
+                                            <input type="email" name="email" required stm-attribute.value="user.email"></div>
+                                        <div class="field"><label>Forename</label>
+                                            <input type="text" name="forename" required stm-attribute.value="user.forename"></div>
+                                        <div class="field"><label>Surname</label>
+                                            <input type="text" name="surname" required stm-attribute.value="user.surname"></div>
+                                        <div class="field"><label>Date of birth</label>
+                                            <input type="text" name="date_of_birth" required stm-attribute.value="user.date_of_birth"></div>
+                                        <div class="field"><label>Enabled</label>
+                                            <label class="choice"><input type="checkbox" name="enabled" value="1"
+                                                   stm-attribute.checked="user.enabled ? '' : undefined"> Account enabled</label></div>
+                                        <div class="field"><label>New password</label>
+                                            <input type="password" name="new_password"
+                                                   placeholder="Leave empty to keep the current password"></div>
+                                        <div class="cluster">
+                                            <button type="submit" class="btn-primary">Save</button>
+                                            <span class="text-muted">Your own account is edited on your profile page.</span>
+                                        </div>
+                                    </form>
+                                </details>
+                                <button type="button" class="btn-sm" stm-action="user.set_enabled"
                                         stm-text="user.enabled ? 'Disable' : 'Enable'">toggle</button>
-                                <button type="button" stm-if="user.is_admin && !user.is_me" stm-action="user.revoke_admin">Revoke admin</button>
-                                <button type="button" stm-if="!user.is_admin" stm-action="user.grant_admin">Make admin</button>
-                                <button type="button" stm-action="user.delete"
+                                <button type="button" class="btn-sm" stm-if="user.is_admin && !user.is_me" stm-action="user.revoke_admin">Revoke admin</button>
+                                <button type="button" class="btn-sm" stm-if="!user.is_admin" stm-action="user.grant_admin">Make admin</button>
+                                <button type="button" class="btn-sm btn-danger" stm-action="user.delete"
                                         stm-confirm="Delete this user? Their sessions outlive the account.">Delete</button>
-                            </form>
+                            </div>
                         </td>
                     </tr>
-                    <tr stm-if="users.users.length == 0"><td colspan="7" class="muted">No users.</td></tr>
+                    <tr stm-if="users.users.length == 0"><td colspan="7" class="text-muted">No users.</td></tr>
                 </tbody>
-            </table>
+
+                </table>
+            </div>
+        </div>
+    </div>
+
+    <!-- The create dialog: same fields, same stm-action as the old
+         expander form; the shared openAppDialog helper closes it on
+         the action's notify. -->
+    <dialog id="user-create-dialog" class="dialog-sm">
+        <div class="dialog-titlebar">
+            <p class="dialog-title">New user</p>
+            <button type="button" class="dialog-close" aria-label="Close"
+                    onclick="window.closeAppDialog('user-create-dialog')">×</button>
         </div>
-    </section>
+        <form stm-action="users.create">
+            <div class="dialog-body form-rows">
+                <div class="field"><label for="user-username">Username</label>
+                    <input type="text" id="user-username" name="username" required placeholder="ada"></div>
+                <div class="field"><label for="user-email">Email</label>
+                    <input type="email" id="user-email" name="email" required placeholder="ada@example.com"></div>
+                <div class="field"><label for="user-forename">Forename</label>
+                    <input type="text" id="user-forename" name="forename" required placeholder="Ada"></div>
+                <div class="field"><label for="user-surname">Surname</label>
+                    <input type="text" id="user-surname" name="surname" required placeholder="Lovelace"></div>
+                <div class="field"><label for="user-dob">Date of birth</label>
+                    <input type="text" id="user-dob" name="date_of_birth" required placeholder="1990-01-31"></div>
+                <div class="field"><label for="user-password">Password</label>
+                    <input type="password" id="user-password" name="password" required placeholder="At least 6 characters"></div>
+                <div class="field"><label for="user-confirm">Confirm password</label>
+                    <input type="password" id="user-confirm" name="confirm_password" required></div>
+                <div class="field"><label>Role</label>
+                    <label class="choice"><input type="checkbox" name="admin" value="1"> Administrator</label></div>
+            </div>
+            <div class="dialog-footer">
+                <button type="button" class="btn-ghost"
+                        onclick="window.closeAppDialog('user-create-dialog')">Cancel</button>
+                <button type="submit" class="btn-primary">Create user</button>
+            </div>
+        </form>
+    </dialog>
 </body>
 </html>

+ 5 - 1
test/bundles/__init__.py

@@ -5,10 +5,11 @@ order here is the parallel runner's shard order."""
 
 from . import (allocation, answers, dispatch, goalui, goals, lineage,
                lifecycle, notes, origins, pages, push, replay, resurrect,
-               stacks, subtasks, users)
+               stacks, subtasks, t4lists, t7log, t8fleet, t9library, users)
 
 BUNDLES = {
     "pages": pages,
+    "t7log": t7log,
     "dispatch": dispatch,
     "subtasks": subtasks,
     "lineage": lineage,
@@ -24,4 +25,7 @@ BUNDLES = {
     "origins": origins,
     "notes": notes,
     "resurrect": resurrect,
+    "t4lists": t4lists,
+    "t8fleet": t8fleet,
+    "t9library": t9library,
 }

+ 13 - 4
test/bundles/goals.py

@@ -173,13 +173,18 @@ def run(ctx):
     check(any(p["item_type"] == "goal" for p in
               slot_public(auth, "/", "dashboard")["pinned"]),
           "the goal's pending block pins onto the dashboard pile")
-    check("'card goal-card'" in home_html and 'badge-kind-goal' in home_html,
+    # Goal 41 T10: the dashboard's feed is the work stream now; the
+    # pinned goal cards keep their distinct styling through the
+    # dynamic class expressions (item_type 'goal' -> goal-card +
+    # badge-kind-goal at render time).
+    check("'card goal-card'" in home_html
+          and "'badge badge-kind-' + blockview.item_type" in home_html,
           "dashboard goal cards carry the distinct goal styling")
     goals_html = http_get_page(auth, "/goals")
     check("&#127919;" in goals_html and "goal-icon" in goals_html,
           "the goals list page carries the goal icon")
     goal_page_html = http_get_page(auth, "/goals/%d" % goal1)
-    check('class="card goal-head"' in goal_page_html and "goal-page" in goal_page_html,
+    check('class="card goal-head' in goal_page_html and "goal-page" in goal_page_html,
           "the goal page carries the goal-accented identity chrome")
     # Reject with feedback: the agent revises.
     fake.goal_script = [("plan", "# Plan v2\n\n1. Add tests\n2. Add docs\n3. Ship")]
@@ -484,8 +489,12 @@ def run(ctx):
           and 'stm-markdown="event.text"' in goal_page
           and 'stm-markdown="block.body"' in goal_page,
           "every long-form goal body renders through stm-markdown")
-    check('stm-attribute.href="run.terminal_url" target="_blank" rel="noopener"' in goal_page,
-          "the run table's terminal links open in a new tab")
+    # Goal 41 T7: the run table's log links navigate SAME-TAB (a new
+    # tab severed the back button; the log is one back-click away and
+    # the task page embeds the same feed as its Log tab).
+    check('stm-attribute.href="run.terminal_url"' in goal_page
+          and 'target="_blank"' not in goal_page.split("run.terminal_url")[1][:200],
+          "the run table's terminal links navigate in the same tab")
     check("progress-latest" in goal_page and "Agent progress" in goal_page,
           "the green progress feed renders on the goal page")
     # Plan revision history keeps its verdicts (approved + rejected).

+ 67 - 13
test/bundles/goalui.py

@@ -126,9 +126,12 @@ def run(ctx):
     check(goal_state(goal)["current_activity"] == "parked awaiting the plan review",
           "the head line reads 'parked awaiting the plan review' while one is open")
     goal_page = http_get_page(auth, "/goals/%d" % goal)
-    check(goal_page.index("Goal agent blocks") < goal_page.index("<h3>Plan</h3>")
-          < goal_page.index("<h3>Tasks</h3>"),
-          "the plan section sits between attention and tasks")
+    # Goal 41 T6 ("actions surface"): a pending plan review LEADS the
+    # acting column as its own card — before the status head, the
+    # blocks and the (history-only) plan card.
+    check(goal_page.index("Plan review") < goal_page.index('class="card goal-head')
+          < goal_page.index("Goal agent blocks") < goal_page.index("<h3>Plan</h3>"),
+          "a pending plan review leads the acting column (status, blocks, history after)")
     check("data-plan-review" in goal_page and "Approved plan &amp; revisions" in goal_page,
           "an open review renders expanded beside the collapsed approved-plan expander")
 
@@ -415,28 +418,79 @@ def run(ctx):
               for e in state["events"]),
           "the narrative records the supervision retry (a system row)")
 
-    print("== B5 + C4: order, effective rules, capability badges ==")
+    print("== B5 + C4 + goal 41 T6: act|consult layout, rules, badges ==")
     goal_page = http_get_page(auth, "/goals/%d" % goal)
-    markers = [
+    # Goal 41 T6 (ux-notes §5): the state strip leads, then the anchor
+    # sub-nav, then the Act|Consult two-column layout — the acting
+    # surfaces (status, attention, plan review, comments) in one
+    # column, the consulting ones (tasks, brief, projects, rules,
+    # narrative, runs) in the other, stacking act-first on narrow
+    # screens (DOM order IS the stack order).
+    check(goal_page.index('class="goal-state-strip"')
+          < goal_page.index('class="subnav"')
+          < goal_page.index("data-goal-act")
+          < goal_page.index("data-goal-consult"),
+          "the goal page reads strip → sub-nav → act column → consult column")
+    # mkpstm inlines pstm-fragment bodies at compile time, so the
+    # include directive itself never serves; the adopted breadcrumb
+    # (its ol/li markup) and the retired back anchor are the truth.
+    check('class="breadcrumb"' in goal_page
+          and 'aria-label="Breadcrumb"' in goal_page
+          and "&larr; Goals" not in goal_page,
+          "the shared breadcrumb replaces the hand-drawn back anchor")
+    check(goal_state(goal)["crumbs"] == [
+        {"href": "/", "label": "Dashboard"},
+        {"href": "/goals", "label": "Goals"},
+        {"href": None, "label": "#%d" % goal}],
+        "the entrypoint publishes the Dashboard → Goals → #id crumb trail")
+    act_markers = [
+        ('id="goal-sec-plan-review"', "plan review (when pending)"),
+        ('id="goal-sec-completion-review"', "completion review (when pending)"),
         ('class="card goal-head"', "status head"),
         ("Agent progress", "progress"),
         ("Goal agent blocks", "attention blocks"),
         ("<h3>Plan</h3>", "plan"),
+        ("<h3>Comments</h3>", "comments"),
+    ]
+    consult_markers = [
         ("<h3>Tasks</h3>", "tasks"),
         ("Initial brief", "brief"),
         ("Involved projects", "projects"),
         ("<h3>Rules</h3>", "rules"),
         ("Supervision narrative", "narrative"),
-        ("<h3>Comments</h3>", "comments"),
         ("Goal agent runs", "runs"),
     ]
-    positions = []
-    for marker, _ in markers:
-        check(marker in goal_page, "the goal page ships the %s section" % _)
-        positions.append(goal_page.index(marker))
-    check(positions == sorted(positions),
-          "the page reads status → progress → attention → plan → tasks → brief →"
-          " rules → narrative → comments → runs (in order)")
+    for markers, column in ((act_markers, "act"), (consult_markers, "consult")):
+        positions = []
+        for marker, _ in markers:
+            check(marker in goal_page, "the goal page ships the %s section" % _)
+            positions.append(goal_page.index(marker))
+        check(positions == sorted(positions),
+              "the %s column reads %s (in order)" % (column,
+              " → ".join(_ for _, __ in ((m, l) for m, l in markers))))
+    act_at = goal_page.index("data-goal-act")
+    consult_at = goal_page.index("data-goal-consult")
+    check(all(act_at < goal_page.index(m) < consult_at
+              for m, _ in act_markers),
+          "every acting surface sits inside the act column")
+    check(goal_page.index("data-goal-consult")
+          < min(goal_page.index(m) for m, _ in consult_markers),
+          "every consulting surface sits inside the consult column")
+    # The strip: one glance — status badge, the open-item verdict, the
+    # primary lifecycle button; the Status card keeps the detail.
+    strip_at = goal_page.index("data-goal-state-strip")
+    check(goal_page.index("'badge badge-' + goal.status", strip_at) > strip_at,
+          "the strip leads with the status badge")
+    for marker in ("goal.plan_pending_block", "goal.completion_pending",
+                   "goal.open_blocks.length"):
+        check(marker in goal_page[strip_at:strip_at + 2500],
+              "the strip's verdict is computed from %s" % marker)
+    check(goal_page.count("stm-action=\"goal.start\"") >= 2,
+          "the primary lifecycle button rides the strip (and the status ladder)")
+    check("window.initSubnav" in goal_page
+          and 'class="subnav"' in goal_page
+          and 'data-subnav-section="goal-sec-narrative"' in goal_page,
+          "the anchor sub-nav runs the shared initSubnav engine (T11 unification)")
     check("goal.current_activity" in goal_page and "Current activity" not in goal_page,
           "the activity lives as ONE muted head line (the card is gone)")
     check(".goal-page .card" in open(os.path.join(REPO, "src/pages/app.css")).read(),

+ 62 - 33
test/bundles/pages.py

@@ -416,12 +416,11 @@ def run(ctx):
     # Plan 11 task D: the progress feed sits ABOVE the status card;
     # status + description share a 50/50 grid; the plan detail hides
     # behind its expander below it.
-    check(status == 200 and "grid-2" in page
+    check(status == 200 and "with-sidebar act-consult" in page
           and page.index("<h3>Progress</h3>") < page.index("status-head"),
           "the progress feed renders above the status card")
-    check(page.index("grid-2") < page.index("status-head")
-          and page.index("status-head") < page.index('data-long-form data-field="task"'),
-          "status and description sit side by side inside the grid (status first)")
+    check(page.index('data-subnav-section="task-blocks"') < page.index("status-head"),
+          "the blocks section leads the acting column above the status card")
     check("Mandate" in page and "plan-expander" not in page,
           "the task page carries no plan expander (plans live on the goal)")
     # Answered blocks fold behind the history expander (this fixture's
@@ -448,9 +447,15 @@ def run(ctx):
     check(status == 200
           and history_page.index("progress-latest") < history_page.index("progress-history"),
           "the progress timeline renders newest-first (latest above the history)")
-    # The edit expander sits UNDER the "Show history" expander.
-    check(history_page.index("Show history") < history_page.index("edit-expander"),
-          "the edit expander sits under the Show history expander")
+    # Goal 41 T5: the edit surface is the in-place Edit task card
+    # (no expander) in the acting column, below the progress feed.
+    check(history_page.index("Show history") < history_page.index(">Edit task"),
+          "the Edit task card sits under the progress history expander")
+    check("edit-expander" not in history_page,
+          "the old edit expander is gone (in-place editor instead)")
+    check(history_page.index('data-subnav-section="task-blocks"')
+          < history_page.index('data-subnav-section="task-comments"'),
+          "the blocks section precedes comments in the acting column")
 
     print("== heredoc completion reports: markdown card + timeline snippet ==")
     # The daemon's `cat << 'EOF' | saddle done` stores a markdown
@@ -516,10 +521,10 @@ def run(ctx):
 
     status, page = http("GET", "/tasks/%d" % report_done, timeout=15)
     check(status == 200 and "completion-report" in page
-          and page.index("grid-2") < page.index("completion-report")
-          < page.index("edit-expander"),
-          "the completion-report card sits below the status/description grid, full width")
-    card = page[page.index("completion-report"):page.index("edit-expander")]
+          and page.index("data-subnav-section=\"task-blocks\"") < page.index("completion-report")
+          < page.index("status-head"),
+          "the completion-report card sits below the blocks, above the status card")
+    card = page[page.index("completion-report"):page.index("status-head")]
     check('stm-markdown="task.summary"' in card
           and 'stm-text="task.report_title"' in card,
           "the card renders the summary as markdown under its state-driven heading")
@@ -623,13 +628,13 @@ def run(ctx):
           "the lifecycle buttons ride the status card inside the grid")
     status, queue_page = http("GET", "/tasks", timeout=15)
     check(status == 200
-          and queue_page.index("filter-status") < queue_page.index("<table")
+          and queue_page.index("goal-strip") < queue_page.index("<table")
           and "filter-goal" in queue_page,
-          "the filter controls (status + goal) sit above the queue table")
+          "the filter controls (status chips + goal select) sit above the queue table")
     check("New task" in queue_page and "task-mode" in queue_page
           and "task-enqueue-after" in queue_page
           and 'name="plan_auto_approve"' not in queue_page,
-          "the create expander offers the mode + auto-enqueue-after selects"
+          "the create form offers the mode + auto-enqueue-after selects"
           " (no subtask checkboxes)")
 
     print("== plan 12: the web surfaces — pile, timeline, queue, task page ==")
@@ -723,9 +728,23 @@ def run(ctx):
     task_page_html = http_get_page(auth, "/tasks/%d" % dash_run)
     check("Mandate" in task_page_html and 'stm-markdown="task.description"' in task_page_html,
           "the task page frames the description as the mandate")
-    check(re.search(r"'/tasks/' \+ task\.id \+ '/log'\"\s+target=\"_blank\"",
+    # Goal 41 T7 (ux-notes structural bet 3): the event log is the
+    # task page's second tab, not a new-tab navigation - the toggle is
+    # a <label> for the log radio, the shared shell rides the panel,
+    # and no log link on the page opens a new tab any more.
+    check('id="task-view-log"' in task_page_html and 'class="tabs task-tabs"' in task_page_html,
+          "the task page mounts the Overview|Log tabs")
+    check(re.search(r'<label class="tab-link" for="task-view-log">Event log</label>',
                     task_page_html) is not None,
-          "the task page's event log links open in a new tab")
+          "the task page's event log control toggles the log tab in place")
+    check(re.search(r"/log'\s*\"\s*target=\"_blank\"", task_page_html) is None,
+          "the task page no longer opens the event log in a new tab")
+    check('class="log-shell"' in task_page_html,
+          "the task page embeds the shared log shell (compiled in from the partial)")
+    task_log_state = slot_public(auth, "/tasks/%d" % dash_run, "task_log")
+    check(task_log_state.get("exists") is True
+          and int(task_log_state["task_id"]) == dash_run,
+          "the task page state also publishes the embedded log slot")
     log_state = slot_public(auth, "/tasks/%d/log" % dash_run, "task_log")
     check(log_state.get("exists") is True and int(log_state["task_id"]) == dash_run,
           "the event log page state names the run task")
@@ -777,13 +796,16 @@ def run(ctx):
           "the task comment card shows the body before the quote")
     status, css = http("GET", "/_statum/resource/app.css", timeout=15)
     check(status == 200 and ".footnote" in css
-          and re.search(r"\.comment\s*\{[^}]*color:\s*var\(--ink-muted\)", css)
+          and re.search(r"\.comment\s*\{[^}]*color:\s*var\(--spry-fg-muted\)", css)
           and re.search(r"\.comment\s*\{[^}]*font-size:\s*0\.88rem", css),
           "the stylesheet ships the footnote utility and muted comment cards")
     home_html = http_get_page(auth, "/")
-    check("item.kind == 'comment' || item.kind == 'goal-comment'"
-          " || item.kind == 'goal-system' ? 'footnote'" in home_html,
-          "the dashboard timeline renders comment rows as footnotes")
+    # Goal 41 T10: the dashboard's feed is the work stream now; the
+    # comment-as-footnote contract lives on in its log-highlight rows
+    # (the compiled page escapes && as &amp;&amp; in the attribute).
+    check("item.kind == 'log_highlight' &amp;&amp; item.event_kind"
+          " == 'comment' ? 'footnote'" in home_html,
+          "the dashboard stream renders comment rows as footnotes")
 
     print("== list ordering: newest first everywhere ==")
     # The ordering sweep: every table leads with the most recent row.
@@ -804,7 +826,7 @@ def run(ctx):
     status, queue_page = http("GET", "/tasks", timeout=15)
     check(status == 200
           and queue_page.index("New task") < queue_page.index("<table"),
-          "the New task expander sits above the tasks table")
+          "the + New task button sits above the tasks table")
     # Comments/blocks threads: newest first since the sweep. The
     # comment list leads with the most recently added one.
     conn = db()
@@ -848,13 +870,17 @@ def run(ctx):
           "the mandate card carries the in-place Edit button and editor")
     check('name="description" rows="8"' in task_page,
           "the editor's textarea is the wide in-place one")
-    # The expander no longer carries the mandate field (short
-    # properties only) — and no other description textarea exists.
-    expander_at = task_page.index('class="edit-expander"')
-    expander_end = task_page.index('</details>', expander_at)
-    expander_html = task_page[expander_at:expander_end]
-    check('name="description"' not in expander_html,
-          "the edit expander no longer carries the mandate field")
+    # Goal 41 T5: the edit surface is the in-place Edit task card —
+    # short properties only (no mandate field).
+    edit_card_at = task_page.index('>Edit task')
+    edit_card_end = task_page.index('</section>', edit_card_at)
+    edit_html = task_page[edit_card_at:edit_card_end]
+    check('name="description"' not in edit_html,
+          "the Edit task card carries no mandate field")
+    check('data-lf-toggle' in edit_html and 'data-lf-edit' in edit_html,
+          "the Edit task card is the in-place editor (view + toggle + form)")
+    check(task_page.count('data-long-form data-field="task"') == 2,
+          "two in-place cards exist: the mandate and the Edit task card")
 
     # The PREFILL contract: the editor reads task.description from
     # state at open (JS-applied — stm-attribute.value is a no-op on
@@ -892,7 +918,9 @@ def run(ctx):
     # hides them at render), while the rendered mandate view carries
     # no gate at all.
     active_page = http_get_page(auth, "/tasks/%d" % active_task)
-    card_at = active_page.find('data-long-form data-field="task"')
+    # Goal 41 T5: two in-place cards exist (Edit task, Mandate); the
+    # draft-gate assertions address the MANDATE card.
+    card_at = active_page.find('>Mandate')
     card_end = active_page.find('</section>', card_at)
     card_html = active_page[card_at:card_end]
     toggle_at = card_html.find('data-lf-toggle')
@@ -920,13 +948,14 @@ def run(ctx):
         "select name from sqlite_master"
         " where type = 'index' and tbl_name = 'events'").fetchall()}
     conn.close()
-    check([s[0] for s in serials] == [1, 2, 3, 4, 5, 6, 7, 8, 9, 10],
-          "the clanker-saddle namespace carries exactly serials 1 through 10"
+    check([s[0] for s in serials] == [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11],
+          "the clanker-saddle namespace carries exactly serials 1 through 11"
           " (a fresh boot ran M0001 then the M0002 no-op then M0003,"
           " M0004 understandings, M0005 discoveries, M0006 advisor,"
           " M0007 the parking guard, M0008 answer-wake, M0009 the"
-          " turn-cap park and M0010 the model config)")
-    check(len(serials) == 10,
+          " turn-cap park, M0010 the model config and M0011 the user"
+          " preferences)")
+    check(len(serials) == 11,
           "no duplicate migration rows (each guarded up() applied once)")
     check({"idx_events_scope", "idx_events_terminal"} <= event_indexes,
           "M0003's event reader indexes exist (fresh boot)")

+ 16 - 2
test/bundles/push.py

@@ -527,8 +527,22 @@ def run(ctx):
     check(status == 200 and "data-push-test" in home_page
           and "data-bell-on" in home_page and "data-bell-off" in home_page,
           "the bell card ships the per-browser buttons + test button")
-    check("push_endpoints" in home_page and "crypto.subtle.digest" in home_page,
-          "the client fingerprints its own endpoint and matches the list")
+    # Goal 41 task 3: the fingerprint engine moved from home.html's
+    # inline script into app.js (window.initPushBell) so the dashboard's
+    # rail strip and /profile's settings card share it; the home page
+    # wires it and still reads the dashboard slot's endpoint list.
+    app_js = ""
+    try:
+        with open(os.path.join(REPO, "src", "pages", "app.js"), "r",
+                  errors="replace") as handle:
+            app_js = handle.read()
+    except OSError:
+        pass
+    check("initPushBell" in home_page and "stateType" in home_page,
+          "the home page wires the shared bell engine on the dashboard slot")
+    check("crypto.subtle.digest" in app_js
+          and "endpoint_fingerprint" in app_js,
+          "the shared engine fingerprints this browser's own endpoint")
 
     print("== cleanup: unsubscribe the probe fixtures ==")
     act(auth, dash["push_unsubscribe"], {"endpoint": endpoint_a})

+ 11 - 2
test/bundles/resurrect.py

@@ -47,8 +47,17 @@ def run(ctx):
     check(detail["status"] == "draft" and int(detail["project_id"]) == 0,
           "a project_id 0 task creates in draft (project_id %s)" % detail["project_id"])
     page = http_get_page(auth, "/tasks/%d" % standalone)
-    check("(standalone)" in page and "/projects/0" not in page,
-          "the task page renders the (standalone) marker, not a project link")
+    # Goal 41 T5: the context line is the breadcrumb trail now; a
+    # standalone task carries NO project crumb (and no project link).
+    crumbs = detail.get("crumbs", [])
+    check(len(crumbs) == 2 and crumbs[0]["label"] == "Tasks"
+          and crumbs[1]["href"] is None
+          and "#%d " % standalone in crumbs[1]["label"],
+          "a standalone task's crumbs are Tasks / #id (no project crumb): %r" % crumbs)
+    check('class="breadcrumb"' in page and "/projects/" not in page,
+          "the task page renders the breadcrumb trail, not a project link")
+    check("(standalone)" in http_get_page(auth, "/tasks"),
+          "the tasks list still marks the standalone task")
     check("No project (standalone)" in http_get_page(auth, "/tasks"),
           "the create form offers the No project (standalone) option")
     enqueue_task(auth, standalone)

+ 281 - 0
test/bundles/t10views.py

@@ -0,0 +1,281 @@
+"""The T10 views bundle (goal 41 task 10): the two phase-D structural
+bets — saved views / URL state on /tasks (ux-notes bet 4) and the
+cross-entity work stream on the dashboard (bet 5).
+
+Bet 4 is a CLIENT-side engine (the statum entrypoint fetch carries no
+query string, so filter state lives in location.hash): this bundle
+verifies the engine's round-trip as pure logic (a python port of the
+serialise/parse pair driving the exact page functions) plus the
+markup hooks the page ships. The prefill namespace (#new?project=)
+must keep coexisting with the view namespace (#view?...).
+
+Bet 5 is server data + markup: the dashboard slot's work_stream rows
+(joined, time-ordered, capped, linked) are cross-checked against
+seeded ledger events, and the page renders the stream feed with the
+old activity table gone.
+"""
+from harness import *  # noqa: F401,F403 — the shared wire helpers
+
+# The open-work status set (window.TASK_OPEN_STATUSES on the page).
+OPEN = ("queued", "assigned", "working", "blocked", "suspended")
+
+
+def view_hash(filters):
+    """window.taskViewHash ported verbatim: only non-default axes
+    serialise ('open' status, all projects, all goals stay clean)."""
+    parts = []
+    for key in ("status", "project", "goal"):
+        value = filters.get(key)
+        if key == "status":
+            value = value or "open"
+            if value == "open":
+                continue
+        elif value in (None, "", 0, "0", "all"):
+            continue
+        from urllib.parse import quote
+        parts.append("%s=%s" % (key, quote(str(value), safe="")))
+    return "#view?" + "&".join(parts) if parts else ""
+
+
+def filters_from_hash(hash_text):
+    """window.taskFiltersFromHash ported verbatim (None = not a view
+    hash: the #new prefill namespace and bare hashes fall through)."""
+    if not hash_text or not hash_text.startswith("#view?"):
+        return None
+    filters = {}
+    for part in hash_text[len("#view?"):].split("&"):
+        if "=" not in part:
+            continue
+        key, value = part.split("=", 1)
+        from urllib.parse import unquote
+        key, value = unquote(key), unquote(value)
+        if key == "status" and value:
+            filters["status"] = value
+        elif key == "project" and value and value != "0":
+            filters["project"] = value
+        elif key == "goal" and value and value != "all":
+            filters["goal"] = value
+    return filters
+
+
+def run(ctx):
+    auth, fake = ctx.auth, ctx.fake
+
+    now = "strftime('%s','now')"
+
+    # ------------------------------------------------------------------
+    print("== bet 4: the hash engine (round-trip as pure logic) ==")
+    cases = [
+        ({}, ""),  # the default view keeps the URL clean
+        ({"status": "open"}, ""),
+        ({"status": "working"}, "#view?status=working"),
+        ({"status": "blocked"}, "#view?status=blocked"),
+        ({"status": "all"}, "#view?status=all"),
+        ({"project": 3}, "#view?project=3"),
+        ({"project": "3"}, "#view?project=3"),
+        ({"project": 0}, ""),  # "all projects" is the default
+        ({"goal": "standalone"}, "#view?goal=standalone"),
+        ({"goal": "all"}, ""),
+        ({"goal": 12}, "#view?goal=12"),
+        ({"status": "working", "project": 3, "goal": "standalone"},
+         "#view?status=working&project=3&goal=standalone"),
+        # A title with spaces/ampersands survives the round-trip
+        # encoded (the goal axis carries ids in practice, but the
+        # engine must not corrupt any value).
+        ({"goal": "a & b=c"}, "#view?goal=a%20%26%20b%3Dc"),
+    ]
+    for filters, expected in cases:
+        got = view_hash(filters)
+        check(got == expected,
+              "serialise %r -> %r" % (filters, expected))
+        if expected:
+            back = filters_from_hash(expected)
+            wanted = {}
+            for key, value in filters.items():
+                text = str(value)
+                if key == "status":
+                    if text not in ("open", ""):
+                        wanted[key] = text
+                elif text not in ("0", "all", ""):
+                    wanted[key] = text
+            check(back == wanted,
+                  "parse %r restores %r (got %r)" % (expected, wanted, back))
+    # The prefill namespace never parses as a view.
+    check(filters_from_hash("#new?project=7") is None,
+          "the #new prefill hash is not a view hash")
+    check(filters_from_hash("") is None and filters_from_hash("#") is None,
+          "bare hashes are not view hashes")
+
+    print("== bet 4: the page ships the engine + the view row ==")
+    tasks_page = http_get_page(auth, "/tasks")
+    for needle, label in (
+            ("window.taskViewHash", "the serialiser"),
+            ("window.taskFiltersFromHash", "the parser"),
+            ("window.applyTaskViewHash", "the load-time seed"),
+            ("window.copyTaskViewLink", "the copy-link affordance"),
+            ("syncTaskHash", "the filter-change hash write"),
+    ):
+        check(needle in tasks_page, "the tasks page ships %s" % label)
+    check("syncTaskHash();" in tasks_page,
+          "every filter change lands in the hash (setTaskFilter)")
+    check("window.history.replaceState" in tasks_page,
+          "hash updates use replaceState (no Back-button spam)")
+    check("hashchange" in tasks_page,
+          "a pasted #view hash re-seeds the filters in-place")
+    check("window.applyTaskViewHash();" in tasks_page,
+          "the hash seeds taskFilters before the first render")
+    check("#view?" in tasks_page,
+          "the view namespace is distinct from the #new prefill")
+    # The view row: presets + copy button.
+    check('data-task-view-row' in tasks_page,
+          "the view row is marked for the bundle/harness")
+    check("Copy view link" in tasks_page
+          and "copyTaskViewLink" in tasks_page,
+          "the one-click copy-link button ships")
+    for preset in ("Open work", "All", "Blocked"):
+        check(preset in tasks_page,
+              "the %r preset chip ships in the view row" % preset)
+    check("navigator.clipboard" in tasks_page,
+          "the copy uses the async clipboard API")
+    # The presets ARE the chips' statuses (no second filter state).
+    check(tasks_page.count("window.taskChipClass('open')") >= 2,
+          "the presets reuse the chip engine's status vocabulary")
+
+    print("== bet 4: prefill coexistence ==")
+    # T9's #new?project= deep-link still parses + drops itself, and
+    # the view engine ignores it (namespace check above; markup).
+    check("indexOf('#new')" in tasks_page,
+          "the #new prefill contract still ships (T9)")
+    check("indexOf('#view?')" in tasks_page,
+          "the view parser gates on its own namespace")
+    # A prefill hash and a view hash in one URL: the prefill wins on
+    # load (it consumes and drops the whole hash); the view engine
+    # then reads nothing - the ported parser mirrors that by gating.
+    check(filters_from_hash("#new?project=7") is None,
+          "a load-time prefill never double-applies as a view")
+
+    # ------------------------------------------------------------------
+    print("== bet 5: the work stream (joined, ordered, capped, linked) ==")
+    # Seed the three row grammars through the ledger the timeline
+    # reads: a task progress line, a goal-scoped narrative event and
+    # a plain task comment (log highlight).
+    conn = db()
+    conn.execute(
+        "insert into tasks (project_id, title, status, priority, created_at)"
+        " values (1, 'T10 stream task', 'working', 5, %s)" % now)
+    stream_task = conn.execute(
+        "select last_insert_rowid()").fetchone()[0]
+    conn.execute(
+        "insert into goals (project_id, title, brief, branch_name, status,"
+        " created_at) values (1, 'T10 stream goal', 'The stream probe.',"
+        " 'goal/t10', 'running', %s)" % now)
+    stream_goal = conn.execute(
+        "select last_insert_rowid()").fetchone()[0]
+    # Goal runs are internal - their task rows never stream.
+    conn.execute(
+        "insert into tasks (project_id, goal_id, kind, title, status,"
+        " priority, created_at) values (1, ?, 'goal_run',"
+        " 'T10 internal run', 'working', 5, %s)" % now, (stream_goal,))
+    run_task = conn.execute(
+        "select last_insert_rowid()").fetchone()[0]
+
+    def event(kind, scope_type, scope_id, payload, offset):
+        conn.execute(
+            "insert into events (kind, actor, scope_type, scope_id,"
+            " payload, created_at) values (?, 'model', ?, ?, ?,"
+            " strftime('%s','now') - ?)",
+            (kind, scope_type, scope_id, payload, offset))
+
+    event("progress", "task", stream_task,
+          '{"text": "T10 progress line"}', 30)
+    event("state_change", "goal", stream_goal,
+          '{"to": "running", "text": "T10 goal narrative"}', 20)
+    event("comment", "task", stream_task,
+          '{"author": "human", "body": "T10 comment highlight"}', 10)
+    event("progress", "task", run_task,
+          '{"text": "T10 internal run progress"}', 5)
+    conn.commit()
+    conn.close()
+
+    dash = slot_public(auth, "/", "dashboard")
+    stream = dash.get("work_stream", [])
+    check(isinstance(stream, list),
+          "the dashboard slot carries the work_stream rows")
+    check(0 < len(stream) <= 15,
+          "the stream is capped at 15 rows (got %d)" % len(stream))
+    epochs = [int(row["at_epoch"]) for row in stream]
+    check(epochs == sorted(epochs, reverse=True),
+          "the stream is newest-first")
+
+    progress_rows = [row for row in stream
+                     if row["kind"] == "task_progress"]
+    goal_rows = [row for row in stream if row["kind"] == "goal_event"]
+    highlight_rows = [row for row in stream
+                      if row["kind"] == "log_highlight"]
+    check(any("T10 progress line" in row["text"]
+              and int(row["task_id"]) == stream_task
+              and row["href"] == "/tasks/%d" % stream_task
+              for row in progress_rows),
+          "task progress rows join with the task link")
+    check(any("T10 goal narrative" in row["text"]
+              and int(row["goal_id"]) == stream_goal
+              and row["href"] == "/goals/%d" % stream_goal
+              for row in goal_rows),
+          "goal narrative rows join with the goal link")
+    check(any("T10 comment highlight" in row["text"]
+              and int(row["task_id"]) == stream_task
+              for row in highlight_rows),
+          "comment rows stream as log highlights")
+    check(all(not ("internal run" in row["text"]
+                   or int(row.get("task_id", 0)) == run_task)
+              for row in stream),
+          "goal-run internals never stream (the goal carries them)")
+
+    # The three grammars agree with the activity projection they
+    # derive from (one window, two projections - same order).
+    activity = dash.get("activity", [])
+    check(len(stream) <= len(activity),
+          "the stream is the window's newest slice (activity keeps the full window)")
+    check(len(activity) > 15 or len(stream) == len(activity),
+          "under the cap the stream covers the whole window")
+
+    # Every row links somewhere or is a bare highlight.
+    for row in stream:
+        if row["href"]:
+            check(row["href"].startswith(("/tasks/", "/goals/")),
+                  "row hrefs stay in-app (%s)" % row["href"])
+            break
+
+    print("== bet 5: the dashboard renders the stream as its feed ==")
+    home_page = http_get_page(auth, "/")
+    check('data-work-stream' in home_page,
+          "the dashboard ships the work-stream feed")
+    check("work-stream" in home_page and "dashboard.work_stream" in home_page,
+          "the feed renders the slot's work_stream rows")
+    check("Work stream" in home_page,
+          "the feed is titled as the work stream")
+    check("badge-stream-" in home_page,
+          "the rows carry the stream's badge vocabulary")
+    # The old three-grammar surfaces it replaces on THIS page: the
+    # activity table is gone from the dashboard.
+    check("dash-activity-table" not in home_page,
+          "the old activity table is gone from the dashboard")
+    check("data-activity-toggle" not in home_page,
+          "the activity Show-all toggle is gone (the server caps)")
+    check("Show all" not in home_page,
+          "no stale Show-all control remains")
+    # The new-since badge keeps working off the stream rows.
+    check("state.work_stream" in home_page,
+          "the new-since badge counts stream rows")
+    # The underlying surfaces live elsewhere (not deleted): the goal
+    # page narrative + the task log stay their pages' own.
+    check("data-activity-new" in home_page,
+          "the ambient expander frame (with the new badge) keeps the feed quiet")
+
+    print("== bet 5: the stream survives the pulse refresh ==")
+    refresh = dash.get("refresh")
+    check(refresh is not None, "the dashboard refresh action is authored")
+    act(auth, refresh, {})
+    dash2 = slot_public(auth, "/", "dashboard")
+    check(len(dash2.get("work_stream", [])) > 0,
+          "the sealed refresh rebuilds the stream too")

+ 144 - 0
test/bundles/t4lists.py

@@ -0,0 +1,144 @@
+"""The T4 lists bundle (goal 41 task 4): the tasks/goals/projects/stacks
+list pages on the Spry conversion — faceted status chips with counts on
+/tasks (default view = open work) and /goals, the create-form expanders
+replaced by native <dialog> modals (openAppDialog helper in app.js),
+the goals Tasks column as readable badges, and the projects list's
+folded repo cell. Markup-level: http_get_page fetches the compiled
+page; the counts are cross-checked against the slot data the chips
+count over."""
+from harness import *  # noqa: F401,F403 — the shared wire helpers
+
+
+def run(ctx):
+    auth, fake, fake_id, fake_token = ctx.auth, ctx.fake, ctx.fake_id, ctx.fake_token
+
+    OPEN = ("queued", "assigned", "working", "blocked", "suspended")
+
+    def status_counts():
+        """Per-status task counts from the /tasks slot (the chip
+        numbers count exactly these rows)."""
+        rows = slot_public(auth, "/tasks", "tasks")["tasks"]
+        counts = {}
+        for row in rows:
+            counts[row["status"]] = counts.get(row["status"], 0) + 1
+        counts["all"] = len(rows)
+        counts["open"] = sum(n for s, n in counts.items()
+                             if s in OPEN)
+        return counts
+
+    def seed_tasks():
+        conn = db()
+        for i, status in enumerate(("queued", "working", "blocked", "done",
+                                    "done", "failed", "draft")):
+            conn.execute(
+                "insert into tasks (project_id, title, status, priority,"
+                " created_at) values (1, ?, ?, 5, strftime('%s','now'))",
+                ("T4 chip probe %d" % i, status))
+        conn.commit()
+        conn.close()
+
+    print("== /tasks: faceted status chips with counts ==")
+    seed_tasks()
+    counts = status_counts()
+    check(counts["open"] == 3 and counts["all"] >= 7,
+          "the fixture shapes the counts (open %d / all %d)"
+          % (counts["open"], counts["all"]))
+
+    tasks_page = http_get_page(auth, "/tasks")
+    check("taskChipClass" in tasks_page and "taskStatusCount" in tasks_page
+          and "TASK_OPEN_STATUSES" in tasks_page,
+          "the tasks page ships the chip engine (class/count/open-set)")
+    check(tasks_page.index("goal-strip") < tasks_page.index("<table"),
+          "the chip strip sits above the queue table")
+    check("window.setTaskStatusFilter('open')" in tasks_page
+          and "window.setTaskStatusFilter('done')" in tasks_page,
+          "every chip drives the shared setTaskFilter engine")
+    # The default view is open work: the status filter initialises to
+    # 'open' and taskRowVisible understands the open set.
+    check("taskFilters.status = 'open'" in tasks_page
+          and "TASK_OPEN_STATUSES.indexOf(row.status) === -1" in tasks_page,
+          "the default filter is the open-work set (queued..suspended)")
+    check("filter-project" in tasks_page and "filter-goal" in tasks_page,
+          "the project and goal selects stay (chips drive the status axis)")
+    check('id="filter-status"' not in tasks_page,
+          "the old status select is gone")
+
+    print("== /tasks: the create form lives in a dialog ==")
+    check('id="task-create-dialog"' in tasks_page
+          and "<dialog" in tasks_page,
+          "the create form is a native dialog")
+    check(tasks_page.index("<dialog") > tasks_page.index("</table>"),
+          "the dialog markup sits after the table, opened by the button")
+    check('onclick="window.openAppDialog(\'task-create-dialog\')"' in tasks_page,
+          "the + New task button opens the dialog through the shared helper")
+    # The form inside the dialog is the SAME form: same action, same
+    # fields (the B6b branch fields, the mode select, auto-enqueue).
+    dialog_html = tasks_page[tasks_page.index('id="task-create-dialog"'):]
+    check('stm-action="tasks.create"' in dialog_html
+          and 'name="task_branch"' in dialog_html
+          and 'name="branch_from"' in dialog_html
+          and 'id="task-mode"' in dialog_html
+          and 'id="task-enqueue-after"' in dialog_html
+          and "No project (standalone)" in dialog_html,
+          "the dialog carries the unchanged create form (action + fields)")
+    check('class="dialog-titlebar"' in dialog_html
+          and 'class="dialog-footer"' in dialog_html,
+          "the dialog uses Spry's dialog classes (titlebar/footer)")
+    # Semantics still work end to end: create through the action the
+    # dialog form posts (new tasks land in draft).
+    before = counts["draft"]
+    act(auth, slot_public(auth, "/tasks", "tasks")["create"], {
+        "project_id": "1", "title": "T4 dialog create task", "priority": "5"})
+    after = status_counts()
+    check(after["draft"] == before + 1,
+          "the dialog form's action still creates (draft %d -> %d)"
+          % (before, after["draft"]))
+
+    print("== app.js: the shared dialog helper ==")
+    status, app_js = http("GET", "/_statum/resource/app.js")
+    check(status == 200 and "window.openAppDialog" in app_js
+          and "window.closeAppDialog" in app_js,
+          "the shared dialog helper ships in app.js")
+    check("onNotifyHandler" in app_js and "dialog.close()" in app_js,
+          "the helper closes the dialog on the action's notify")
+
+    print("== /goals: chips + readable task badges + create dialog ==")
+    goals_page = http_get_page(auth, "/goals")
+    check("goalStatusCount" in goals_page and "goalChipClass" in goals_page
+          and "setGoalStatusFilter" in goals_page,
+          "the goals list ships the same chip engine")
+    check("goalRowVisible" in goals_page,
+          "the goals rows filter client-side like the queue")
+    # The four-count muted string is gone; badges carry the shape.
+    check("draft / " not in goals_page
+          and "running_tasks + " in goals_page,
+          "the Tasks column renders the total badge from the counts")
+    check('badge-warning" stm-if="goal.running_tasks &gt; 0"' in goals_page,
+          "running tasks surface as a warning badge")
+    check('id="goal-create-dialog"' in goals_page
+          and 'stm-action="goals.create"' in goals_page,
+          "the goal create form lives in a dialog")
+    goal_dialog = goals_page[goals_page.index('id="goal-create-dialog"'):]
+    check('id="goal-brief"' in goal_dialog and "textarea" in goal_dialog
+          and "required" in goal_dialog,
+          "the required brief textarea is inside the dialog (visible on open)")
+
+    print("== /projects: the folded repo cell + create dialog ==")
+    projects_page = http_get_page(auth, "/projects")
+    check('id="project-create-dialog"' in projects_page
+          and 'stm-action="projects.create"' in projects_page
+          and 'onclick="window.openAppDialog(\'project-create-dialog\')"' in projects_page,
+          "the project create form lives in a dialog behind the button")
+    check("<th>Repo</th>" in projects_page and "<th>Git URL</th>" not in projects_page,
+          "the Git URL column folded into one Repo cell")
+    check("repo-url" in projects_page and "truncate" in projects_page,
+          "the repo cell truncates the clone URL (branch under it)")
+
+    print("== /stacks: the create dialog ==")
+    stacks_page = http_get_page(auth, "/stacks")
+    check('id="stack-create-dialog"' in stacks_page
+          and 'stm-action="stacks.create"' in stacks_page
+          and 'onclick="window.openAppDialog(\'stack-create-dialog\')"' in stacks_page,
+          "the stack create form lives in a dialog behind the button")
+    check("<details>" not in stacks_page and "<details>" not in projects_page,
+          "no create expander remains on stacks or projects")

+ 185 - 0
test/bundles/t7log.py

@@ -0,0 +1,185 @@
+"""The t7log bundle (goal 41 T7, ux-notes structural bet 3): the task
+page <-> event log unification. The two surfaces are ONE investigation
+flow: the task page mounts the log as its "Event log" tab (the same
+task-log-feed partial the standalone /tasks/{id}/log route renders),
+the entrypoint publishes both slots, and no link to the log opens a
+new tab any more.
+
+Fixture-driven like the pages bundle: a task + session are inserted as
+rows, events land through the push wire (/api/saddle/chunks), and the
+assertions read page HTML, entrypoint slot state and the SSE stream.
+"""
+from harness import *  # noqa: F401,F403 — the shared wire helpers
+
+
+# =============================================================================
+# Bundle: t7log — task page <-> event log unification.
+# =============================================================================
+
+def run(ctx):
+    auth, fake, fake_id, fake_token = ctx.auth, ctx.fake, ctx.fake_id, ctx.fake_token
+
+    print("== B1: the task page embeds the log as its second tab ==")
+    # A working task with one session, then a real event through the
+    # push wire (the same path the daemon writes).
+    conn = db()
+    conn.execute(
+        "insert into tasks (project_id, title, status, priority, created_at)"
+        " values (1, 'T7 unified log task', 'working', 5,"
+        " strftime('%s','now'))")
+    task_id = conn.execute("select max(id) from tasks").fetchone()[0]
+    conn.execute(
+        "insert into sessions (task_id, saddle_id, started_at)"
+        " values (?, ?, strftime('%s','now'))", (task_id, fake_id))
+    conn.commit()
+    session_id = conn.execute("select max(id) from sessions").fetchone()[0]
+    conn.close()
+
+    status, body = saddle_api("POST", "/api/saddle/chunks", fake_token, {
+        "session_id": session_id,
+        "events": [{"seq": 101, "kind": "comment", "actor": "user:admin",
+                    "payload": {"body": "T7 probe comment",
+                                "author": "user:admin"}}]})
+    check(status == 200 and int(body.get("stored") or 0) == 1,
+          "the fixture event stores through the push wire")
+
+    # The tab markup: Spry's CSS tabs (radios first, then .tab-list
+    # labels, then .tab-panels), Overview checked by default.
+    task_page = http_get_page(auth, "/tasks/%d" % task_id)
+    check('class="tabs task-tabs"' in task_page,
+          "the task page carries the tab strip")
+    check(task_page.index('id="task-view-overview"')
+          < task_page.index('class="tab-list"')
+          < task_page.index('class="tab-panels"'),
+          "the radios lead the tab markup (Spry's nth-of-type contract)")
+    check('id="task-view-overview" checked' in task_page,
+          "Overview is the default (checked) tab")
+    check('<label for="task-view-overview">Overview</label>' in task_page
+          and '<label for="task-view-log">Event log</label>' in task_page,
+          "the tab list names Overview and Event log")
+    check('class="task-overview-panel"' in task_page
+          and 'class="task-log-panel"' in task_page,
+          "the panels carry the overview and the log")
+
+    print("== B2: one shared shell, both surfaces ==")
+    # The SAME partial ships both pages: the interactive hooks the
+    # client engine binds appear exactly once on each, and the
+    # compiled shell markup (the partial's) rides both.
+    task_page = http_get_page(auth, "/tasks/%d" % task_id)
+    log_page = http_get_page(auth, "/tasks/%d/log" % task_id)
+    for surface, name in ((task_page, "the task page"),
+                          (log_page, "the log page")):
+        check('class="log-shell"' in surface and "data-log-filter" in surface,
+              "%s renders the shared log shell" % name)
+    # Hook multiplicity: each interactive hook appears exactly as
+    # many times as the ONE shared shell carries it (5 filter
+    # buttons, 1 feed/more/search/jump) — proving neither surface
+    # ships a second, hand-copied shell.
+    for hook, times in (("data-log-feed", 1), ("data-log-more", 1),
+                        ("data-log-filter", 5), ("data-log-search", 1),
+                        ("data-log-jump", 1)):
+        check(task_page.count(hook) == times
+              and log_page.count(hook) == times,
+              "the %s hook appears exactly %d time(s) on both surfaces"
+              % (hook, times))
+
+    print("== B3: no log link opens a new tab any more ==")
+    # The Progress cluster's control is a <label> toggling the radio
+    # in place; no link element on the page navigates to the log
+    # route any more (comments may mention it, markup may not).
+    check('<label class="tab-link" for="task-view-log">Event log</label>'
+          in task_page,
+          "the Progress cluster's Event log control toggles the tab in place")
+    markup = task_page.split("<script>")[0] + "".join(
+        part.split("</script>", 1)[-1] for part in task_page.split("<script>")[1:])
+    check(re.search(r"<a[^>]+(\n[^>]*)*?/log", markup) is None,
+          "the task page markup carries no anchor to the log route")
+    check(re.search(r"/log['\"]", markup) is None,
+          "no log URL appears in any attribute (the tab is the only door)")
+
+    print("== B4: both slots publish from the task entrypoint ==")
+    # The embedded shell binds the page's task_log slot; it must name
+    # the same task the standalone route's slot does.
+    embedded = slot_public(auth, "/tasks/%d" % task_id, "task_log")
+    standalone = slot_public(auth, "/tasks/%d/log" % task_id, "task_log")
+    check(embedded.get("exists") is True
+          and int(embedded["task_id"]) == task_id,
+          "the task page entrypoint publishes the embedded log slot")
+    for field in ("exists", "task_id", "task_title", "task_status", "task_url"):
+        check(standalone.get(field) == embedded.get(field),
+              "the %s field is identical on both surfaces" % field)
+    check(embedded.get("task_title") == "T7 unified log task",
+          "the embedded slot names the task")
+    check(embedded.get("task_status") == "working",
+          "the embedded slot carries the live status the badge reads")
+
+    print("== B5: the standalone page keeps its context strip ==")
+    # The forensic route renders the T5 context header (back chevron +
+    # task title + live badge) and the not-found guard still works.
+    check('class="log-header"' in log_page
+          and 'data-log-title' in log_page
+          and 'data-log-live' in log_page,
+          "the standalone page keeps its one-row context header")
+    check('stm-attribute.href="task_log.task_url"' in log_page,
+          "the back chevron binds the task page URL from the slot")
+    missing = slot_public(auth, "/tasks/99999999/log", "task_log")
+    check(missing.get("exists") is False,
+          "a missing task's log slot says exists=false (both surfaces render their own note)")
+
+    print("== B6: the feed is reachable through both paths ==")
+    # The JSON page + SSE stream are task-scoped, not route-scoped:
+    # the embedded tab reads the same endpoints the standalone page
+    # does (one engine, one wire).
+    status, page_json = http("GET", "/tasks/%d/events" % task_id, timeout=15)
+    rows = json.loads(page_json)
+    check(status == 200
+          and any(e.get("payload", {}).get("body") == "T7 probe comment"
+                  for e in rows.get("events", [])),
+          "the events endpoint serves the fixture event")
+    request = urllib.request.Request(WEB + "/tasks/%d/events/stream" % task_id)
+    with urllib.request.urlopen(request, timeout=15) as stream:
+        probe = None
+        deadline = time.monotonic() + 8
+        while time.monotonic() < deadline and probe is None:
+            line = stream.readline().decode().strip()
+            if not line.startswith("data: ") or line == "data: ping":
+                continue
+            event = json.loads(line[6:])
+            if (event.get("kind") == "comment"
+                    and "T7 probe comment" in
+                    (event.get("payload", {}).get("body") or "")):
+                probe = event
+        check(probe is not None,
+              "the SSE stream carries the fixture event to either surface")
+
+    print("== B7: the pulse refresh rebuilds BOTH slots ==")
+    # The refresh action (the page's sealed pulse) rebuilds the task
+    # slot AND the embedded task_log slot (the badge's status words
+    # stay truthful after a transition without its own poll). Drive
+    # it through the real action wire and read both back.
+    task_slot = task_state(auth, task_id)
+    refresh = task_slot.get("refresh")
+    check(refresh is not None, "the task slot carries its sealed refresh action")
+    if refresh:
+        act(auth, refresh, {})
+    # Flip the round's state through the push wire, refresh again,
+    # and the embedded slot must name the NEW status.
+    status, _ = saddle_api("POST", "/api/saddle/results", fake_token,
+                           {"session_id": session_id, "state": "done",
+                            "summary": "T7 round settled"})
+    check(status == 200, "the round settles through the results push")
+    act(auth, task_state(auth, task_id)["refresh"], {})
+    settled = task_state(auth, task_id)
+    refreshed_log = slot_public(auth, "/tasks/%d" % task_id, "task_log")
+    check(refreshed_log.get("task_status") == settled.get("status"),
+          "the embedded log slot's status tracks the task slot after a pulse refresh")
+
+    print("== B8: the goal surface's run links navigate same-tab ==")
+    # goal_detail's run rows and header link dropped target=_blank
+    # (the goal page is T6's; only the log-link shape is T7's concern).
+    goal_page_shape = open(os.path.join(os.path.dirname(os.path.abspath(__file__)),
+                                        os.pardir, os.pardir,
+                                        "src", "pages", "goal_detail.html"),
+                           encoding="utf-8").read()
+    check('run.terminal_url" target="_blank"' not in goal_page_shape,
+          "the goal page's run links no longer open a new tab")

+ 173 - 0
test/bundles/t8fleet.py

@@ -0,0 +1,173 @@
+"""The T8 fleet bundle (goal 41 task 8): the fleet + admin pages on the
+Spry conversion — the saddle cards' "now" head / "Keys & admin" tail
+split with the ONE consolidated health verdict line (saddle.health_verdict),
+the fleet health summary line above the grid when any saddle is
+offline/stale (saddles.fleet_alert), the capability catalogue as its own
+h2 "Routing tags" section, the register/capability create forms as
+native <dialog> modals behind buttons (the shared openAppDialog helper),
+the users page's create dialog + dense row actions, the model page's
+Spry form cards and the stack detail's in-place Details editor with the
+demoted delete. Markup-level via http_get_page; the slot-level verdict
+and alert cross-checked against the saddles slot data."""
+from harness import *  # noqa: F401,F403 — the shared wire helpers
+
+
+def run(ctx):
+    auth = ctx.auth
+
+    print("== /saddles: the head/tail split markup ==")
+    page = http_get_page(auth, "/saddles")
+    # The admin tail folds under ONE expander after the verdict/work
+    # head (ux-notes quick win 8 A); the fold carries the hairline.
+    check("Keys &amp; admin</summary>" in page,
+          "the card tail folds under one Keys & admin expander")
+    check(page.index("Keys &amp; admin</summary>")
+          > page.index("stm-text=\"saddle.health_verdict\""),
+          "the admin tail sits after the verdict line (head first)")
+    check("saddle-fold" in page,
+          "the head/tail boundary carries the saddle-fold hairline")
+    check("saddle.health_verdict" in page,
+          "the card head renders the composed health verdict line")
+    # The three old fragments no longer lead the head: the badge keeps
+    # the colour, the verdict carries the words.
+    check("'daemon ' + saddle.daemon_state" not in page
+          and "'contacted ' + saddle.last_contact_display" not in page,
+          "the fragmented health line is gone (one verdict instead)")
+    check("stm-text=\"saddle.current_goal_title\"" in page
+          and "stm-text=\"saddle.current_task_title\"" in page,
+          "current work stays in the head")
+
+    print("== /saddles: the fleet summary line + Routing tags section ==")
+    # fleet_alert is server-composed; the page renders it when
+    # non-empty. Mark a saddle offline to shape it, then restore.
+    conn = db()
+    conn.execute("update saddles set status = 'offline' where id = ?",
+                 (ctx.fake_id,))
+    conn.commit()
+    conn.close()
+    state = slot_public(auth, "/saddles", "saddles")
+    check(state["fleet_alert"].startswith("1 of ")
+          and "not healthy" in state["fleet_alert"]
+          and "fake-saddle" in state["fleet_alert"],
+          "an offline saddle shapes the fleet alert line: %s"
+          % state["fleet_alert"])
+    check("dispatcher may be starved" in state["fleet_alert"],
+          "the alert names the dispatcher risk")
+    card = saddle_card(auth, "fake-saddle")
+    check(card["health"] == "offline"
+          and "offline" in card["health_verdict"],
+          "the offline row's verdict agrees with its health")
+    conn = db()
+    conn.execute("update saddles set status = 'idle' where id = ?",
+                 (ctx.fake_id,))
+    conn.commit()
+    conn.close()
+    state = slot_public(auth, "/saddles", "saddles")
+    check(state["fleet_alert"] == "",
+          "a healthy fleet renders no alert (empty string)")
+
+    # The catalogue is its own h2 section (recommendation C), not an
+    # h3 tail after the grid.
+    check("<h2 id=\"routing-tags\">Routing tags</h2>" in page,
+          "the capability catalogue is its own h2 Routing tags section")
+    check("<h3>Capabilities</h3>" not in page,
+          "the old h3 Capabilities tail is gone")
+
+    print("== /saddles: the register + capability dialogs ==")
+    check('id="saddle-register-dialog"' in page
+          and "<dialog" in page,
+          "the register form is a native dialog")
+    check('onclick="window.openAppDialog(\'saddle-register-dialog\')"' in page,
+          "the + Register saddle button opens the dialog via the helper")
+    register_html = page[page.index('id="saddle-register-dialog"'):]
+    check('stm-action="saddles.register"' in register_html
+          and 'name="name"' in register_html
+          and 'name="host"' in register_html,
+          "the dialog carries the unchanged register form")
+    check('class="dialog-titlebar"' in register_html
+          and 'class="dialog-footer"' in register_html,
+          "the register dialog uses Spry's dialog classes")
+    check('id="capability-create-dialog"' in page
+          and 'stm-action="saddles.create_capability"' in page,
+          "the capability create form lives in a dialog too")
+    check("<details>" in page and "<summary><strong>Register saddle</strong></summary>" not in page,
+          "no create expander remains on the saddles page (details are the admin tail)")
+
+    # Semantics: registering through the dialog form's action works,
+    # then clean up (the fake saddle stays the fleet's only daemon).
+    before = len(slot_public(auth, "/saddles", "saddles")["saddles"])
+    act(auth, state["register"], {"name": "t8-probe", "host": "localhost:1"})
+    rows = slot_public(auth, "/saddles", "saddles")["saddles"]
+    check(len(rows) == before + 1, "the register action still creates")
+    probe = [s for s in rows if s["name"] == "t8-probe"][0]
+    # register_saddle creates the row IDLE (the proven-derivation
+    # decision): a never-contacted saddle honestly reads unknown, and
+    # the verdict says so without the alarm of "offline".
+    check(probe["health"] == "unknown"
+          and "no health report yet" in probe["health_verdict"],
+          "a never-contacted saddle reads unknown with the no-report verdict")
+    act(auth, probe["unregister"], {})
+    check(len(slot_public(auth, "/saddles", "saddles")["saddles"]) == before,
+          "the probe saddle unregisters again")
+
+    print("== /users: create dialog + dense row actions ==")
+    users_page = http_get_page(auth, "/users")
+    check('id="user-create-dialog"' in users_page
+          and 'stm-action="users.create"' in users_page
+          and 'onclick="window.openAppDialog(\'user-create-dialog\')"' in users_page,
+          "the user create form lives in a dialog behind the button")
+    check("<summary><strong>New user</strong></summary>" not in users_page,
+          "no create expander remains on the users page")
+    check('name="confirm_password"' in users_page
+          and 'name="date_of_birth"' in users_page,
+          "the dialog carries the unchanged create fields")
+    check("btn-danger" in users_page and "Delete this user?" in users_page,
+          "the destructive delete is the visually secondary danger button")
+
+    print("== /models: Spry form cards ==")
+    models_page = http_get_page(auth, "/models")
+    check('stm-action="model.save"' in models_page
+          and 'id="model-base-url"' in models_page
+          and 'id="advisor-base-url"' in models_page,
+          "the model form keeps its fields with labelled controls")
+    check('class="card-header"' in models_page and "form-rows" in models_page,
+          "the endpoint groups are cards with capped-width fields")
+    check("alert-warning" in models_page or "alert-info" in models_page,
+          "the readiness line renders as an alert")
+    # The saved hint round-trip already covered by modelconfig; the
+    # save action stays the same one.
+
+    print("== /stacks/{id}: in-place Details editor + demoted delete ==")
+    stacks_state = slot_public(auth, "/stacks", "stacks")
+    result = act(auth, stacks_state["create"],
+                 {"name": "T8 probe stack", "summary": "markup probe"})
+    stack_id = None
+    for row in slot_public(auth, "/stacks", "stacks")["stacks"]:
+        if row["name"] == "T8 probe stack":
+            stack_id = row["id"]
+    check(stack_id is not None, "the probe stack exists")
+    stack_page = http_get_page(auth, "/stacks/%s" % stack_id)
+    check("data-long-form" in stack_page and "data-lf-toggle" in stack_page,
+          "the Details card toggles in place (view/edit halves)")
+    check("initLongFormEditors" in stack_page,
+          "the stack page initialises the shared editor engine")
+    check('stm-action="stack.update"' in stack_page
+          and 'stm-action="stack.add_member"' in stack_page,
+          "the update and add-member actions are unchanged")
+    # The old side-by-side Edit card is gone; the delete is a small
+    # danger button inside a flat card at the bottom.
+    check("<h3>Edit</h3>" not in stack_page,
+          "the duplicate side-by-side Edit card is gone")
+    check('class="card card-flat"' in stack_page
+          and "btn-danger" in stack_page,
+          "the delete action sits in a flat danger zone")
+    # Update through the in-place form's action still works.
+    detail = slot_public(auth, "/stacks/%s" % stack_id, "stack")
+    result = act(auth, detail["update"],
+                 {"stack_id": str(stack_id), "name": "T8 probe stack 2",
+                  "summary": "renamed in place"})
+    check("saved" in (action_notify(result) or ""),
+          "the in-place editor's action saves the rename")
+    act(auth, slot_public(auth, "/stacks/%s" % stack_id, "stack")["delete"], {})
+    check(slot_public(auth, "/stacks", "stacks")["stacks"] == [],
+          "the probe stack deletes again")

+ 238 - 0
test/bundles/t9library.py

@@ -0,0 +1,238 @@
+"""The T9 library bundle (goal 41 task 9): the project detail, rules,
+discoveries and chats pages on the Spry conversion — the project page's
+Details/Edit twin cards collapsed into ONE in-place editor
+(data-long-form) with the freed structure carrying the Goals reverse
+link and a create-task deep-link into the /tasks dialog, the rules
+page's Edit expander turned in-place with Delete demoted behind a
+ghost button and the new blast-radius attachments list, the discoveries
+rows' edit expander turned in-place, and breadcrumbs on the project and
+chat detail pages. Markup-level via http_get_page; the new slot members
+(goals vector, rule attachments, crumbs vectors) cross-checked against
+seeded database rows."""
+from harness import *  # noqa: F401,F403 — the shared wire helpers
+
+
+def run(ctx):
+    auth, fake = ctx.auth, ctx.fake
+
+    now = "strftime('%s','now')"
+
+    print("== project detail: the in-place Details editor ==")
+    # A dedicated project so the bundle owns every row it asserts on.
+    state = slot_public(auth, "/projects", "projects")
+    act(auth, state["create"], {"name": "t9-library",
+                                "git_url": "https://example.com/t9.git",
+                                "default_branch": "main",
+                                "summary": "the T9 probe project"})
+    rows = slot_public(auth, "/projects", "projects")["projects"]
+    project_id = int([p["id"] for p in rows if p["name"] == "t9-library"][0])
+
+    detail = slot_public(auth, "/projects/%d" % project_id, "project")
+    check(detail["update"] is not None,
+          "the project slot authors the update action")
+
+    page = http_get_page(auth, "/projects/%d" % project_id)
+    check('data-long-form data-field="project"' in page,
+          "the Details card is one data-long-form card")
+    check(page.count('data-lf-edit') >= 1 and page.count('data-lf-view') >= 1,
+          "the card carries the read/edit halves")
+    check('data-lf-src="project.git_url"' in page
+          and 'data-lf-src="project.summary"' in page,
+          "the edit half prefills from the slot's current values")
+    check("<h3>Edit</h3>" not in page,
+          "the side-by-side Edit card is gone")
+    check('stm-action="project.update"' in page,
+          "the editor form posts the project update action")
+
+    print("== project detail: breadcrumbs replace the back anchor ==")
+    check("<p><a href=\"/projects\">All projects</a></p>" not in page,
+          "the hand-drawn All-projects anchor is gone")
+    check('stm-def-crumbs-as="project.crumbs"' in page,
+          "the breadcrumb partial rides the project page")
+    crumbs = detail["crumbs"]
+    check([c["label"] for c in crumbs]
+          == ["Dashboard", "Projects", "t9-library"]
+          and crumbs[0]["href"] == "/" and crumbs[1]["href"] == "/projects"
+          and crumbs[2].get("href") is None,
+          "the crumbs vector trails Dashboard / Projects / <name> (%s)"
+          % [c["label"] for c in crumbs])
+
+    print("== project detail: the create-task deep-link ==")
+    check("'#new?project=' + project.id" in page
+          or "'/tasks#new?project=' + project.id" in page,
+          "the create affordance deep-links the /tasks dialog prefill")
+    check("New task in this project" in page,
+          "the affordance names the project flow")
+    tasks_page = http_get_page(auth, "/tasks")
+    check("applyNewTaskPrefill" in tasks_page,
+          "the tasks page ships the prefill pass")
+    check("task-create-dialog" in tasks_page
+          and "openAppDialog" in tasks_page,
+          "the prefill opens T4's create dialog through the helper")
+
+    print("== project detail: the goals reverse link ==")
+    # Seed one primary goal and one co-developed link; a reference link
+    # must NOT appear (context, not work happening here).
+    conn = db()
+    now = "strftime('%s','now')"
+    conn.execute(
+        "insert into goals (project_id, title, brief, branch_name, status,"
+        " priority, creator, created_at) values (?, 'T9 primary goal',"
+        " 'built here', 'goal/t9-primary', 'active', 3, 'human', %s)"
+        % now, (project_id,))
+    primary_goal = int(conn.execute("select max(id) from goals").fetchone()[0])
+    conn.execute(
+        "insert into projects (name, git_url, default_branch, created_at)"
+        " values ('t9-codev', 'https://example.com/t9c.git', 'main', %s)"
+        % now)
+    codev_project = int(conn.execute("select max(id) from projects")
+                        .fetchone()[0])
+    conn.execute(
+        "insert into goals (project_id, title, brief, branch_name, status,"
+        " priority, creator, created_at) values (?, 'T9 co-dev goal',"
+        " 'built alongside', 'goal/t9-codedev', 'planning', 3, 'human', %s)"
+        % now, (codev_project,))
+    codev_goal = int(conn.execute("select max(id) from goals").fetchone()[0])
+    conn.execute(
+        "insert into goal_links (goal_id, project_id, relationship)"
+        " values (?, ?, 'co-developed')", (codev_goal, project_id))
+    conn.execute(
+        "insert into goals (project_id, title, brief, branch_name, status,"
+        " priority, creator, created_at) values (?, 'T9 ref goal',"
+        " 'read only', 'goal/t9-ref', 'draft', 3, 'human', %s)"
+        % now, (codev_project,))
+    ref_goal = int(conn.execute("select max(id) from goals").fetchone()[0])
+    conn.execute(
+        "insert into goal_links (goal_id, project_id, relationship)"
+        " values (?, ?, 'reference')", (ref_goal, project_id))
+    conn.commit()
+    conn.close()
+
+    detail = slot_public(auth, "/projects/%d" % project_id, "project")
+    goals = {str(g["id"]): g for g in detail["goals"]}
+    check(set(goals) == {str(primary_goal), str(codev_goal)},
+          "the goals vector carries the primary and co-developed goals only"
+          " (got %s)" % sorted(goals))
+    check(goals[str(primary_goal)]["relationship"] == "primary"
+          and goals[str(codev_goal)]["relationship"] == "co-developed",
+          "each goal row names its relationship")
+    check(goals[str(primary_goal)]["display"] == "active"
+          and goals[str(codev_goal)]["display"] == "planning",
+          "the rows carry the human display status")
+
+    page = http_get_page(auth, "/projects/%d" % project_id)
+    check("Goals in this project" in page
+          and "'/goals/' + goal.id" in page,
+          "the page renders the goals reverse-link table")
+    check("'badge badge-' + goal.status" in page,
+          "goal rows badge their status like the goals list")
+
+    print("== rules: the in-place editor + the demoted delete ==")
+    rules_state = slot_public(auth, "/rules", "rules")
+    act(auth, rules_state["create"], {
+        "name": "T9 blast rule", "body": "The blast radius probe rule."})
+    conn = db()
+    rule_id = int(conn.execute(
+        "select id from rules where name = 'T9 blast rule'").fetchone()[0])
+    # Attach it to the probe project and to one task.
+    conn.execute(
+        "insert into rule_projects (rule_id, project_id, created_at)"
+        " values (?, ?, %s)" % now, (rule_id, project_id))
+    conn.execute(
+        "insert into tasks (project_id, title, status, priority, created_at)"
+        " values (?, 'T9 blast task', 'queued', 5, %s)" % now, (project_id,))
+    blast_task = int(conn.execute("select max(id) from tasks").fetchone()[0])
+    conn.execute(
+        "insert into task_rules (rule_id, task_id, created_at)"
+        " values (?, ?, %s)" % now, (rule_id, blast_task))
+    conn.commit()
+    conn.close()
+
+    rules_state = slot_public(auth, "/rules", "rules")
+    row = [r for r in rules_state["rules"]
+           if r["name"] == "T9 blast rule"][0]
+    check([(a["kind"], a["label"]) for a in row["attachments"]]
+          == [("project", "t9-library"), ("task", "T9 blast task")],
+          "the rule row lists its blast radius in attachment order (%s)"
+          % [(a["kind"], a["label"]) for a in row["attachments"]])
+    check(all(int(a["id"]) > 0 for a in row["attachments"]),
+          "each attachment carries the linked row's id")
+
+    unattached = [r for r in rules_state["rules"]
+                  if len(r.get("attachments") or []) == 0]
+    check(len(unattached) >= 1
+          or len(rules_state["rules"]) == 1,
+          "rules attached nowhere list no attachments (no crash)")
+
+    page = http_get_page(auth, "/rules")
+    check("<summary><strong>New rule</strong></summary>" not in page
+          and "rule-create-dialog" in page
+          and "openAppDialog('rule-create-dialog')" in page,
+          "the create expander became a dialog behind a button")
+    check("<summary>Edit</summary>" not in page,
+          "the per-rule Edit expander is gone")
+    check('data-long-form data-field="rule-row"' in page
+          and 'data-lf-toggle' in page,
+          "each rule edits through the in-place engine")
+    # The compiled page entity-encodes the body text ("…" -> &hellip;);
+    # assert on the escaped form and on the ghost class itself.
+    check("Delete rule&hellip;</button>" in page
+          and 'class="btn-ghost btn-sm"' in page,
+          "delete is demoted to a ghost button out of the row's face")
+    check('stm-text="attachment.label"' in page,
+          "the blast radius renders as links on the rule card")
+
+    print("== discoveries: the in-place editor ==")
+    page = http_get_page(auth, "/discoveries")
+    check("<summary>Edit</summary>" not in page,
+          "the discoveries Edit expander is gone")
+    check('data-long-form data-field="discovery-row"' in page,
+          "each discovery edits through the in-place engine")
+    check('stm-text="discovery.body"' in page
+          and 'name="body"' in page,
+          "the edit textarea prefills from the rendered row value")
+    check('discoveryRowVisible' in page and 'setDiscoveryQuery' in page,
+          "the client-side search semantics are unchanged")
+    state = slot_public(auth, "/discoveries", "discoveries")
+    check("query" in state and "more" in state,
+          "the slot still seeds the search box and the more count")
+
+    print("== chats: breadcrumbs on the detail page ==")
+    # Seed the chat row directly (the slot builder reads any owned row;
+    # this bundle does not restart the app with a scripted LLM - the
+    # chatui bundle owns that surface).
+    conn = db()
+    conn.execute(
+        "insert into advisor_chats (user_id, title, status, created_at,"
+        " updated_at) values ((select id from spry_users where username ="
+        " 'admin'), 'T9 crumbs probe', 'idle', %s, %s)" % (now, now))
+    chat_id = int(conn.execute("select max(id) from advisor_chats")
+                  .fetchone()[0])
+    conn.commit()
+    conn.close()
+
+    chat_uri = "/chats/%d" % chat_id
+    chat_state = slot_public(auth, chat_uri, "chat")
+    check(chat_state["id"] == chat_id, "the probe chat exists (%s)" % chat_uri)
+    crumbs = chat_state["crumbs"]
+    check([c["label"] for c in crumbs]
+          == ["Dashboard", "Chats", "T9 crumbs probe"]
+          and crumbs[0]["href"] == "/" and crumbs[1]["href"] == "/chats"
+          and crumbs[2].get("href") is None,
+          "the chat slot trails Dashboard / Chats / <title> (%s)"
+          % [c["label"] for c in crumbs])
+    page = http_get_page(auth, chat_uri)
+    check('stm-def-crumbs-as="chat.crumbs"' in page,
+          "the breadcrumb partial rides the chat detail page")
+    check("chat-thread" in page and "chat-composer" in page,
+          "the conversation still leads with the composer below")
+    detail = slot_public(auth, "/chats/%d" % chat_id, "chat")
+    check(detail["delete"] is not None,
+          "the seeded chat still authors its delete action")
+
+    print("== chats list: the demoted delete ==")
+    page = http_get_page(auth, "/chats")
+    check('class="btn-ghost btn-sm" stm-action="chat.delete"' in page,
+          "the list's delete is a quiet ghost button")
+    check("alert alert-warning" in page,
+          "the unconfigured-advisor notice uses the Spry alert")

+ 124 - 0
test/bundles/theme.py

@@ -0,0 +1,124 @@
+"""The theme bundle (goal 41 task 2): the account-settings appearance
+picker backed by Spry's built-in themes. The SetThemeAction persists
+the choice per user in user_preferences and writes the DEVICE-scope
+"ui" slot ({theme, mode}) the client applies as data-theme/data-mode;
+login seeds that slot from the stored preferences."""
+
+from harness import *  # noqa: F401,F403
+
+
+def run(ctx):
+    admin = ctx.auth
+
+    def login_frames(username, password):
+        """The login action's FULL directive chain (the harness login()
+        only keeps the auth slot; the ui seed rides the same chain)."""
+        frames = directives("/login")
+        content = json.loads(frames[0]["snapshot_frame"]["content"])
+        action = content["public"]["login"]
+        status, body = http(action.get("method", "POST"), action["uri"],
+                            {"username": username, "password": password})
+        if status != 200:
+            raise SystemExit("login -> HTTP %d: %s" % (status, body[:300]))
+        return json.loads(body)
+
+    print("== profile page: the appearance card ==")
+    profile = slot_public(admin, "/profile", "profile")
+    check(profile.get("set_theme") is not None,
+          "profile carries the set_theme action")
+    check(profile.get("theme", "") == "" and profile.get("mode") == "auto",
+          "no stored preference reads as core theme + auto mode")
+
+    page = http_get_page(admin, "/profile")
+    check("Appearance" in page, "profile page shows the Appearance card")
+    for label in ("Spry (default)", "Airy", "Dwarf", "Formal", "Glassy",
+                  "H2O", "Living Sky", "Modern"):
+        check(label in page, "theme choice offered: %s" % label)
+    for label in ("Auto (follow system)", "Light", "Dark"):
+        check(label in page, "mode choice offered: %s" % label)
+    check("remembered for this account" in page,
+          "the card hints the choice is remembered")
+
+    print("== the seven theme stylesheets are served ==")
+    for slug in ("airy", "dwarf", "formal", "glassy", "h2o",
+                 "living-sky", "modern"):
+        status, _ = http("GET", "/_statum/resource/spry-theme-%s.css" % slug)
+        check(status == 200, "spry-theme-%s.css served (HTTP %d)" % (slug, status))
+    status, _ = http("GET", "/_statum/resource/spry.css")
+    check(status == 200, "spry.css served (HTTP %d)" % status)
+
+    print("== saving a theme persists + answers the ui slot ==")
+    result = act(admin, profile["set_theme"], {"theme": "dwarf", "mode": "dark"})
+    check("saved" in (action_notify(result) or ""),
+          "set_theme accepts dwarf/dark")
+
+    ui = [d for d in result
+          if d["type"] == "set"
+          and json.loads(d["snapshot_frame"]["content"])["slot"]["type"] == "ui"]
+    check(len(ui) == 1, "the action answers a ui slot")
+    ui_public = json.loads(ui[0]["snapshot_frame"]["content"])["public"]
+    check(ui_public.get("theme") == "dwarf" and ui_public.get("mode") == "dark",
+          "ui slot public data carries {theme, mode}")
+    ui_scope = json.loads(ui[0]["snapshot_frame"]["content"])["slot"]
+    check(ui_scope.get("scope") == "device",
+          "the ui slot is DEVICE scope (cached across sessions)")
+
+    conn = db()
+    try:
+        rows = conn.execute(
+            "select key, value from user_preferences").fetchall()
+    finally:
+        conn.close()
+    check(dict(rows).get("theme") == "dwarf" and dict(rows).get("mode") == "dark",
+          "user_preferences stores theme=dwarf mode=dark: %s" % rows)
+
+    print("== the saved choice reflects back on the profile ==")
+    profile = slot_public(admin, "/profile", "profile")
+    check(profile.get("theme") == "dwarf" and profile.get("mode") == "dark",
+          "profile reads the stored choice back")
+
+    print("== invalid values are refused, nothing stored ==")
+    result = act(admin, profile["set_theme"], {"theme": "gruvbox", "mode": "auto"})
+    check("Unknown theme" in (action_notify(result) or ""),
+          "an unknown theme slug is refused with a friendly error")
+    result = act(admin, profile["set_theme"], {"theme": "airy", "mode": "sepia"})
+    check("Unknown colour mode" in (action_notify(result) or ""),
+          "an unknown colour mode is refused")
+
+    conn = db()
+    try:
+        rows = conn.execute(
+            "select key, value from user_preferences").fetchall()
+    finally:
+        conn.close()
+    check(dict(rows).get("theme") == "dwarf",
+          "the refused slugs wrote nothing: %s" % rows)
+
+    print("== core default + upsert overwrite ==")
+    result = act(admin, profile["set_theme"], {"theme": "", "mode": "light"})
+    check("saved" in (action_notify(result) or ""), "core default + light accepted")
+    conn = db()
+    try:
+        rows = dict(conn.execute(
+            "select key, value from user_preferences").fetchall())
+    finally:
+        conn.close()
+    check(rows.get("theme") == "" and rows.get("mode") == "light",
+          "the upsert replaced both rows in place: %s" % rows)
+
+    profile = slot_public(admin, "/profile", "profile")
+    check(profile.get("theme") == "" and profile.get("mode") == "light",
+          "profile reads the core default back")
+
+    print("== login seeds the ui slot from stored prefs ==")
+    # The seed rides the LOGIN response's directive chain (the same
+    # builder Spry's login action returns, extended by our subclass) -
+    # so assert it there, not on a later entrypoint.
+    frames = login_frames("admin", "adminpass123")
+    seeded = [d for d in frames
+              if d["type"] == "set"
+              and json.loads(d["snapshot_frame"]["content"])["slot"]["type"] == "ui"]
+    check(len(seeded) == 1, "the login response answers a ui slot")
+    seeded_public = json.loads(seeded[0]["snapshot_frame"]["content"])["public"]
+    check(seeded_public.get("theme") == "" and seeded_public.get("mode") == "light",
+          "login seeds the ui slot from the stored preferences")

+ 3 - 3
test/harness.py

@@ -1362,12 +1362,12 @@ def start_bundle(name):
 
 
 
-BUNDLES_ORDER = ("pages", "dispatch", "subtasks", "lineage",
+BUNDLES_ORDER = ("pages", "t7log", "dispatch", "subtasks", "lineage",
                  "allocation", "lifecycle", "answers", "answerwake", "ghostworking",
                  "turncappark", "modelconfig",
                  "push",
-                 "goals", "replay", "goalui", "forcewake", "users", "stacks",
-                 "notes", "resurrect", "origins", "parity", "understanding",
+                 "goals", "replay", "goalui", "forcewake", "users", "theme", "stacks",
+                 "notes", "resurrect", "t4lists", "t8fleet", "t9library", "t10views", "origins", "parity", "understanding",
                  "discoveries", "knowledge_cycle", "advisor", "chatui",
                  "advisor_arc", "advisor_fix", "advisor_refine", "advisor_utf8",
                  "canon",

+ 12 - 3
test/test_task_log_js.py

@@ -34,6 +34,11 @@ PAGE = os.path.join(os.path.dirname(os.path.abspath(__file__)),
                     os.pardir, "src", "pages", "task_log.html")
 SCRIPT = os.path.join(os.path.dirname(os.path.abspath(__file__)),
                       os.pardir, "src", "pages", "task_log.js")
+# Goal 41 T7: the interactive shell (toolbar + feed) lives in the
+# task-log-feed partial - shared by this page and the task page's
+# Log tab - so markup invariants are asserted against it.
+SHELL = os.path.join(os.path.dirname(os.path.abspath(__file__)),
+                     os.pardir, "src", "pages", "task_log_feed.html")
 
 
 def script_body():
@@ -97,10 +102,14 @@ class ScriptSourceTests(unittest.TestCase):
     def test_more_button_rides_inside_the_feed(self):
         # The load-older control is the feed's FIRST row - it
         # scrolls away with the history instead of sitting above
-        # the pane. Search the page markup only (the script's
+        # the pane. Search the shell markup only (the script's
         # comments also mention [data-log-feed]).
-        with open(PAGE, encoding="utf-8") as handle:
-            markup = handle.read().split("</script>", 1)[1]
+        # Goal 41 T7: the shell moved into the task-log-feed
+        # partial (task_log_feed.html) - the SAME markup the task
+        # page's Log tab embeds - so the invariant is asserted
+        # there.
+        with open(SHELL, encoding="utf-8") as handle:
+            markup = handle.read().split("</script>", 1)[-1]
         feed = markup.index("data-log-feed")
         more = markup.index("data-log-more", feed)
         close = markup.index("</div>", feed)

Some files were not shown because too many files changed in this diff