#16 CSS Upgrade

Обединени
Tilo15 обедини 21 ревизии от clanker/goal/ui-ux във clanker/web-stack преди 23 часа
променени са 69 файла, в които са добавени 6166 реда и са изтрити 3809 реда
  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
 # 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,
 This is an opinions document about **structure**, not looks: the theme,
 colours and typography stay as they are. "Structure" here means page
 colours and typography stay as they are. "Structure" here means page
 hierarchy (what leads a page, what folds away), information order,
 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_branches.html',
     'src/pages/goal_compare.html',
     'src/pages/goal_compare.html',
     'src/pages/progress_feed.html',
     'src/pages/progress_feed.html',
+    'src/pages/task_log_feed.html',
     'src/pages/block_answer.html',
     'src/pages/block_answer.html',
     'src/pages/block_reply.html',
     'src/pages/block_reply.html',
+    'src/pages/breadcrumb.html',
 )
 )
 
 
 # spry:pages-begin
 # spry:pages-begin
@@ -225,6 +227,7 @@ app_sources = files(
     'src/Data/Migrations/M0008_AnswerWake.vala',
     'src/Data/Migrations/M0008_AnswerWake.vala',
     'src/Data/Migrations/M0009_TurnCapPark.vala',
     'src/Data/Migrations/M0009_TurnCapPark.vala',
     'src/Data/Migrations/M0010_ModelConfig.vala',
     'src/Data/Migrations/M0010_ModelConfig.vala',
+    'src/Data/Migrations/M0011_UserPreferences.vala',
     'src/Services/ProjectService.vala',
     'src/Services/ProjectService.vala',
     'src/Services/TaskService.vala',
     'src/Services/TaskService.vala',
     'src/Services/GoalService.vala',
     'src/Services/GoalService.vala',
@@ -246,6 +249,7 @@ app_sources = files(
     'src/Services/PushService.vala',
     'src/Services/PushService.vala',
     'src/Services/ChatService.vala',
     'src/Services/ChatService.vala',
     'src/Services/ModelConfigService.vala',
     'src/Services/ModelConfigService.vala',
+    'src/Services/UserPreferenceService.vala',
     'src/Services/CanonService.vala',
     'src/Services/CanonService.vala',
     'src/Services/AdvisorService.vala',
     'src/Services/AdvisorService.vala',
     'src/Services/AdvisorCallback.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.

Файловите разлики са ограничени, защото са твърде много
+ 0 - 311
mocks/dashboard-control.html


Файловите разлики са ограничени, защото са твърде много
+ 0 - 282
mocks/dashboard-current.html


Файловите разлики са ограничени, защото са твърде много
+ 0 - 321
mocks/dashboard-primer.html


Файловите разлики са ограничени, защото са твърде много
+ 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 deployment's model configuration (the Admin → Model page):
      * the OpenAI-compatible endpoint every saddle assignment is sent
      * 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 {
 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 —
      * Helpers for reading the JSON objects that travel the wire —
      * pushed results/chunks entries, block payloads and request
      * pushed results/chunks entries, block payloads and request

+ 24 - 0
src/Services/RuleService.vala

@@ -216,6 +216,30 @@ namespace ClankerSaddle.Services {
                 .materialise_async();
                 .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. */
         /** The rules attached to `task_id`, attachment order. */
         public async ImmutableLot<Rule> list_rules_for_task(int64 task_id) throws Error {
         public async ImmutableLot<Rule> list_rules_for_task(int64 task_id) throws Error {
             var rows = new Vector<Rule>();
             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 Statum;
+using ClankerSaddle.Services;
 
 
 namespace ClankerSaddle {
 namespace ClankerSaddle {
 
 
     /**
     /**
      * The shipped {@link Spry.Actions.LoginAction} with this app's
      * 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 {
     public class LoginAction : Spry.Actions.LoginAction {
 
 
         protected override string landing_uri { get { return "/"; } }
         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 Statum;
+using ClankerSaddle.Services;
 
 
 namespace ClankerSaddle {
 namespace ClankerSaddle {
 
 
     /**
     /**
      * The shipped Spry change-own-password action with this app's
      * 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.
      * verification (current/new/confirm) are Spry's.
      */
      */
     public class ChangeMyPasswordAction : Spry.Actions.ChangePasswordAction {
     public class ChangeMyPasswordAction : Spry.Actions.ChangePasswordAction {
 
 
         protected override string login_uri { get { return "/login"; } }
         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,
      * current data so the three-second client poll shows live comments,
      * blocks, output-driven status changes and completions. Navigates
      * blocks, output-driven status changes and completions. Navigates
      * nowhere; a failed reload leaves the client's state untouched.
      * 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 {
     public class RefreshTaskDataAction : TaskDetailRefreshingAction {
 
 
@@ -660,7 +665,8 @@ namespace ClankerSaddle {
                 return directives();
                 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());
                     messages.to_immutable_buffer());
                 props["suggestions"] = StateJson.list<ChatSuggestionView>(
                 props["suggestions"] = StateJson.list<ChatSuggestionView>(
                     suggestions.to_immutable_buffer());
                     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 {
             } else {
                 props = (PropertyDictionary) GObjectMapping.to_properties(pub);
                 props = (PropertyDictionary) GObjectMapping.to_properties(pub);
                 props["messages"] = StateJson.list<ChatMessageView>(
                 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<GoalCommentView> comments { get; set; default = new Vector<GoalCommentView>(); }
         public Vector<GoalRunView> runs { get; set; default = new Vector<GoalRunView>(); }
         public Vector<GoalRunView> runs { get; set; default = new Vector<GoalRunView>(); }
         public Vector<GoalPushBranchView> push_branches { get; set; default = new Vector<GoalPushBranchView>(); }
         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>(); }
         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);
             var goal = yield ctx.goals.get_goal(goal_id);
             if (goal != null) {
             if (goal != null) {
                 view = yield full_view((!)goal, ctx);
                 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) {
                 foreach (var block in view.blocks) {
                     if (block.is_plan_review && plan_pending == null) {
                     if (block.is_plan_review && plan_pending == null) {
                         plan_pending = block;
                         plan_pending = block;
@@ -400,6 +413,8 @@ namespace ClankerSaddle {
                 view.progress.to_immutable_buffer());
                 view.progress.to_immutable_buffer());
             pub_root["push_branches"] = StateJson.list<GoalPushBranchView>(
             pub_root["push_branches"] = StateJson.list<GoalPushBranchView>(
                 view.push_branches.to_immutable_buffer());
                 view.push_branches.to_immutable_buffer());
+            pub_root["crumbs"] = StateJson.list<CrumbView>(
+                view.crumbs.to_immutable_buffer());
             pub_root["plan_revisions"] = StateJson.list<GoalBlockView>(
             pub_root["plan_revisions"] = StateJson.list<GoalBlockView>(
                 plan_revisions.to_immutable_buffer());
                 plan_revisions.to_immutable_buffer());
             pub_root["rules"] = StateJson.list<GoalRuleView>(
             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 = ""; }
         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;
      * A pinned OPEN blocking item (plan 10 phase 3; plan 12 unified;
      * plan 15 C1 single-row): an unanswered question, RFC, review or
      * plan 15 C1 single-row): an unanswered question, RFC, review or
@@ -175,6 +205,13 @@ namespace ClankerSaddle {
      */
      */
     public class DashboardState : Object {
     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
          * Loads the dashboard aggregate and shapes its PAGE slot. The
          * `db` session comes from the caller (entrypoint or action scope).
          * `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);
             var props = (PropertyDictionary) GObjectMapping.to_properties(pub);
             props["saddles"] = StateJson.list<DashboardSaddleView>(saddle_rows.to_immutable_buffer());
             props["saddles"] = StateJson.list<DashboardSaddleView>(saddle_rows.to_immutable_buffer());
             props["push_endpoints"] = StateJson.strings(push_fingerprints);
             props["push_endpoints"] = StateJson.strings(push_fingerprints);
             props["activity"] = StateJson.list<DashboardActivityView>(activity.to_immutable_buffer());
             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["active_tasks"] = StateJson.list<DashboardActiveView>(active.to_immutable_buffer());
             props["pinned"] = StateJson.list<DashboardBlockView>(pinned_sorted.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;
 using Spry.Authentication;
 using Spry.Authentication;
 using Statum;
 using Statum;
+using ClankerSaddle.Services;
 
 
 namespace ClankerSaddle {
 namespace ClankerSaddle {
 
 
@@ -14,17 +15,40 @@ namespace ClankerSaddle {
         public bool is_admin { get; set; }
         public bool is_admin { get; set; }
         public string created_display { get; set; default = ""; }
         public string created_display { get; set; default = ""; }
         public Statum.Model.ActionDto change_password { get; set; }
         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,
      * 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
      * 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 {
     public class ProfileEntrypoint : StatumEntrypoint {
 
 
         private UserService users_service = Inversion.inject<UserService>();
         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 {
         public override async DirectiveBuilder handle() throws GLib.Error {
             var guard = yield SpryAuth.require_login(directives(), request, users_service);
             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");
                 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() {
             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),
                     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);
             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",
                 type_name = "profile",
                 public_data = props,
                 public_data = props,
                 private_data = new PropertyDictionary()
                 private_data = new PropertyDictionary()
             });
             });
-            return directives().set(slot);
         }
         }
     }
     }
 }
 }

+ 71 - 0
src/entrypoints/ProjectsEntrypoint.vala

@@ -1,5 +1,6 @@
 using Invercargill;
 using Invercargill;
 using Invercargill.DataStructures;
 using Invercargill.DataStructures;
+using Invercargill.Expressions;
 using Spry;
 using Spry;
 using Spry.Authentication;
 using Spry.Authentication;
 using Statum;
 using Statum;
@@ -103,6 +104,23 @@ namespace ClankerSaddle {
         public int64 priority { get; set; }
         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. */
     /** A rule attached to the project, with its detach action. */
     public class ProjectRuleRowView : Object {
     public class ProjectRuleRowView : Object {
         public int64 link_id { get; set; }
         public int64 link_id { get; set; }
@@ -191,6 +209,8 @@ namespace ClankerSaddle {
         private UnderstandingService understanding_service = Inversion.inject<UnderstandingService>();
         private UnderstandingService understanding_service = Inversion.inject<UnderstandingService>();
         private RuleService rule_service = Inversion.inject<RuleService>();
         private RuleService rule_service = Inversion.inject<RuleService>();
         private StackService stack_service = Inversion.inject<StackService>();
         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>();
         private UserService users = Inversion.inject<UserService>();
 
 
         public override async DirectiveBuilder handle() throws GLib.Error {
         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>();
             var note_rows = new Vector<ProjectNoteRowView>();
             foreach (var note in yield project_service.list_notes(project_id)) {
             foreach (var note in yield project_service.list_notes(project_id)) {
                 var note_private = new ProjectNoteActionPrivate() {
                 var note_private = new ProjectNoteActionPrivate() {
@@ -376,6 +438,7 @@ namespace ClankerSaddle {
             props["links"] = StateJson.list<ProjectLinkView>(links.to_immutable_buffer());
             props["links"] = StateJson.list<ProjectLinkView>(links.to_immutable_buffer());
             props["linkable"] = StateJson.list<ProjectRowView>(linkable.to_immutable_buffer());
             props["linkable"] = StateJson.list<ProjectRowView>(linkable.to_immutable_buffer());
             props["tasks"] = StateJson.list<ProjectTaskRowView>(task_rows.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["rules"] = StateJson.list<ProjectRuleRowView>(rule_rows.to_immutable_buffer());
             props["rule_options"] = StateJson.list<RuleOptionView>(rule_options.to_immutable_buffer());
             props["rule_options"] = StateJson.list<RuleOptionView>(rule_options.to_immutable_buffer());
             props["understanding_current"] = StateJson.element(current_understanding_row);
             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["stack_options"] = StateJson.list<StackOptionView>(stack_options.to_immutable_buffer());
             props["notes"] = StateJson.list<ProjectNoteRowView>(note_rows.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() {
             var slot = state_service.new_slot(Scope.PAGE, new State() {
                 type_name = "project",
                 type_name = "project",

+ 65 - 2
src/entrypoints/RulesEntrypoint.vala

@@ -8,6 +8,20 @@ using ClankerSaddle.Services;
 
 
 namespace ClankerSaddle {
 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. */
     /** A rule row on the rules page, with its row actions. */
     public class RuleRowView : Object {
     public class RuleRowView : Object {
         public int64 id { get; set; }
         public int64 id { get; set; }
@@ -16,6 +30,14 @@ namespace ClankerSaddle {
         public string created_display { get; set; default = ""; }
         public string created_display { get; set; default = ""; }
         public Statum.Model.ActionDto? update { get; set; }
         public Statum.Model.ActionDto? update { get; set; }
         public Statum.Model.ActionDto? delete { 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. */
     /** Public data of the `rules` slot. */
@@ -32,6 +54,8 @@ namespace ClankerSaddle {
     public class RulesEntrypoint : StatumEntrypoint {
     public class RulesEntrypoint : StatumEntrypoint {
 
 
         private RuleService rule_service = Inversion.inject<RuleService>();
         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>();
         private UserService users = Inversion.inject<UserService>();
 
 
         public override async DirectiveBuilder handle() throws GLib.Error {
         public override async DirectiveBuilder handle() throws GLib.Error {
@@ -49,7 +73,8 @@ namespace ClankerSaddle {
                     body = rule.body,
                     body = rule.body,
                     created_display = format_stamp(rule.created_at),
                     created_display = format_stamp(rule.created_at),
                     update = action_registry.author_private<UpdateRuleAction, RuleActionPrivate>(sealed),
                     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>();
             pub.create = action_registry.author<CreateRuleAction>();
 
 
             var props = (PropertyDictionary) GObjectMapping.to_properties(pub);
             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() {
             var slot = state_service.new_slot(Scope.PAGE, new State() {
                 type_name = "rules",
                 type_name = "rules",
@@ -66,5 +96,38 @@ namespace ClankerSaddle {
             });
             });
             return directives().set(slot);
             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; }
         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. */
     /** A saddle card on the management page, with its current task and API token. */
     public class SaddleRowView : Object {
     public class SaddleRowView : Object {
         public int64 id { get; set; }
         public int64 id { get; set; }
@@ -38,6 +68,8 @@ namespace ClankerSaddle {
         public string daemon_state { get; set; default = ""; }
         public string daemon_state { get; set; default = ""; }
         public string uptime_display { get; set; default = ""; }
         public string uptime_display { get; set; default = ""; }
         public string last_contact_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_name { get; set; default = ""; }
         public string git_email { get; set; default = ""; }
         public string git_email { get; set; default = ""; }
         public string ssh_public_key { 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
         /** True when the viewer may see the admin controls (the actions
          * re-check the permission; this only shapes the page). */
          * re-check the permission; this only shapes the page). */
         public bool is_admin { get; set; }
         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 register { get; set; }
         public Statum.Model.ActionDto create_capability { get; set; }
         public Statum.Model.ActionDto create_capability { get; set; }
     }
     }
@@ -183,6 +223,8 @@ namespace ClankerSaddle {
                     daemon_state = daemon_state,
                     daemon_state = daemon_state,
                     uptime_display = uptime > 0 ? relative_time(uptime * 1000) : "",
                     uptime_display = uptime > 0 ? relative_time(uptime * 1000) : "",
                     last_contact_display = last_contact,
                     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_name = saddle.git_name ?? "",
                     git_email = saddle.git_email ?? "",
                     git_email = saddle.git_email ?? "",
                     ssh_public_key = saddle.ssh_public_key ?? "",
                     ssh_public_key = saddle.ssh_public_key ?? "",
@@ -234,6 +276,29 @@ namespace ClankerSaddle {
 
 
             var pub = new SaddlesPublic();
             var pub = new SaddlesPublic();
             pub.is_admin = is_admin;
             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.register = action_registry.author<RegisterSaddleAction>();
             pub.create_capability = action_registry.author<CreateCapabilityAction>();
             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["capabilities"] = StateJson.list<TaskCapabilityRowView>(task_capabilities.to_immutable_buffer());
             props["capability_options"] = StateJson.list<CapabilityOptionView>(capability_options.to_immutable_buffer());
             props["capability_options"] = StateJson.list<CapabilityOptionView>(capability_options.to_immutable_buffer());
             props["sessions"] = StateJson.list<SessionView>(sessions.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);
             return slot_for(state_service, pub, props);
         }
         }
 
 
@@ -537,6 +554,13 @@ namespace ClankerSaddle {
                 project_service, saddle_service, rule_service, capability_service,
                 project_service, saddle_service, rule_service, capability_service,
                 action_registry, poll_state, push_service, event_service, db,
                 action_registry, poll_state, push_service, event_service, db,
                 me != null ? ((!)me).user_id : 0, task_id);
                 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
             // Plan 14 B1: the goal page fully replaces the goal-run
             // task's page — deep links (dashboard, notifications,
             // task's page — deep links (dashboard, notifications,
             // timeline) redirect to the goal. The task state still
             // timeline) redirect to the goal. The task state still
@@ -545,11 +569,12 @@ namespace ClankerSaddle {
             var detail = yield task_service.get_task(task_id);
             var detail = yield task_service.get_task(task_id);
             if (detail != null && ((!)detail).task.kind == TASK_KIND_GOAL_RUN
             if (detail != null && ((!)detail).task.kind == TASK_KIND_GOAL_RUN
                     && ((!)detail).task.goal_id > 0) {
                     && ((!)detail).task.goal_id > 0) {
-                return directives().set(state)
+                return directives().set(state).set(log_state)
                     .navigate(@"/goals/$(((!)detail).task.goal_id)")
                     .navigate(@"/goals/$(((!)detail).task.goal_id)")
                     .subscribe_global(StatePulse.SLOT_TYPE);
                     .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
      * control pages backwards through GET /tasks/&lt;id&gt;/events in
      * batches of 30. Unauthenticated like the terminal page before
      * batches of 30. Unauthenticated like the terminal page before
      * it (the task id is the capability).
      * 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 {
     public class TaskLogEntrypoint : StatumEntrypoint {
 
 
@@ -35,6 +40,17 @@ namespace ClankerSaddle {
 
 
         public override async DirectiveBuilder handle() throws GLib.Error {
         public override async DirectiveBuilder handle() throws GLib.Error {
             int64 task_id = int64.parse(request.route("task_id") ?? "0");
             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>()
             var task = yield db.query<TaskEntity>()
                 .where(expr("id == $0", elem<int64?>(task_id)))
                 .where(expr("id == $0", elem<int64?>(task_id)))
                 .first_async();
                 .first_async();
@@ -48,10 +64,10 @@ namespace ClankerSaddle {
                 pub.task_title = ((!)task).title;
                 pub.task_title = ((!)task).title;
                 pub.task_status = ((!)task).status;
                 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",
                 type_name = "task_log",
                 public_data = GObjectMapping.to_properties(pub)
                 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.M0008_AnswerWake>();
         db.add_migration<ClankerSaddle.Data.Migrations.M0009_TurnCapPark>();
         db.add_migration<ClankerSaddle.Data.Migrations.M0009_TurnCapPark>();
         db.add_migration<ClankerSaddle.Data.Migrations.M0010_ModelConfig>();
         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<Project>(Project.entity_mapping);
         db.add_entity<ProjectLink>(ProjectLink.entity_mapping);
         db.add_entity<ProjectLink>(ProjectLink.entity_mapping);
         db.add_entity<TaskEntity>(TaskEntity.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<StackProject>(StackProject.entity_mapping);
         db.add_entity<Note>(Note.entity_mapping);
         db.add_entity<Note>(Note.entity_mapping);
         db.add_entity<ModelConfig>(ModelConfig.entity_mapping);
         db.add_entity<ModelConfig>(ModelConfig.entity_mapping);
+        db.add_entity<UserPreference>(UserPreference.entity_mapping);
         db.migrate_on_startup();
         db.migrate_on_startup();
 
 
         application.add_scoped<ProjectService>();
         application.add_scoped<ProjectService>();
@@ -97,6 +99,7 @@ int main(string[] args) {
         application.add_scoped<AllocationService>();
         application.add_scoped<AllocationService>();
         application.add_scoped<TimelineService>();
         application.add_scoped<TimelineService>();
         application.add_scoped<ChatService>();
         application.add_scoped<ChatService>();
+        application.add_scoped<UserPreferenceService>();
         application.add_singleton<SaddlePollState>();
         application.add_singleton<SaddlePollState>();
         application.add_singleton<AnswerDeliveryHub>();
         application.add_singleton<AnswerDeliveryHub>();
         application.add_singleton<StatePulse>();
         application.add_singleton<StatePulse>();
@@ -226,6 +229,7 @@ int main(string[] args) {
         statum.action<DeleteManagedUserAction>();
         statum.action<DeleteManagedUserAction>();
         statum.action<ChangeMyPasswordAction>();
         statum.action<ChangeMyPasswordAction>();
         statum.action<SaveModelConfigAction>();
         statum.action<SaveModelConfigAction>();
+        statum.action<SetThemeAction>();
         // spry:actions-end
         // spry:actions-end
 
 
         // spry:resources-begin
         // spry:resources-begin

Файловите разлики са ограничени, защото са твърде много
+ 390 - 742
src/pages/app.css


+ 444 - 2
src/pages/app.js

@@ -120,7 +120,7 @@
             actions.className = 'actions';
             actions.className = 'actions';
             var add = document.createElement('button');
             var add = document.createElement('button');
             add.type = 'button';
             add.type = 'button';
-            add.className = 'primary';
+            add.className = 'btn-primary btn-sm';
             add.textContent = 'Add remark';
             add.textContent = 'Add remark';
             add.addEventListener('click', function () {
             add.addEventListener('click', function () {
                 if (body.value.trim().length === 0) return;
                 if (body.value.trim().length === 0) return;
@@ -208,7 +208,7 @@
                     entry.appendChild(body);
                     entry.appendChild(body);
                     var remove = document.createElement('button');
                     var remove = document.createElement('button');
                     remove.type = 'button';
                     remove.type = 'button';
-                    remove.className = 'inline';
+                    remove.className = 'btn-ghost btn-sm';
                     remove.setAttribute('data-plan-remove', '');
                     remove.setAttribute('data-plan-remove', '');
                     remove.textContent = 'Remove';
                     remove.textContent = 'Remove';
                     entry.appendChild(remove);
                     entry.appendChild(remove);
@@ -326,6 +326,149 @@
         statum.afterEvaluate(applyPrefills);
         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) {
     if (window.statum) {
             (function () {
             (function () {
                 'use strict';
                 'use strict';
@@ -351,6 +494,227 @@
                     showToast(message || kind, kind);
                     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 quoteHandlers = [];
                 var quoteButton = null;
                 var quoteButton = null;
                 var quotePopover = 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>
                 <textarea name="other" rows="2"></textarea>
             </label>
             </label>
         </div>
         </div>
-        <button type="submit" class="primary">Answer</button>
+        <button type="submit" class="btn-primary">Answer</button>
     </form>
     </form>
 </body>
 </body>
 </html>
 </html>

+ 2 - 2
src/pages/block_reply.html

@@ -9,9 +9,9 @@
 </head>
 </head>
 <body>
 <body>
     <form stm-if="!block.answered && block.block_type != 'question' && block.block_type != 'review'"
     <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>
         <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>
     </form>
 </body>
 </body>
 </html>
 </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">
         <div stm-if="chat != null && chat.id == 0">
             <h2>Chat not found</h2>
             <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>
                 chats are private. <a href="/chats">Back to your chats.</a></p>
         </div>
         </div>
 
 
         <div stm-if="chat != null && chat.id > 0" class="chat-page">
         <div stm-if="chat != null && chat.id > 0" class="chat-page">
             <div class="chat-head">
             <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>
                 <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>
                          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>
                                 stm-confirm="Delete this chat? Its messages and suggestions go with it.">Delete chat</button>
                     </form></p>
                     </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
                     configured - set the model endpoint on the <a href="/models">Admin
                     → Model</a> page to enable it. Your messages wait
                     → Model</a> page to enable it. Your messages wait
                     until then.</p>
                     until then.</p>
@@ -157,15 +164,15 @@
                         <details>
                         <details>
                             <summary><span class="badge badge-tool">tool</span>
                             <summary><span class="badge badge-tool">tool</span>
                                 <code stm-text="message.tool_name">name</code></summary>
                                 <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>
                             <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>
                             <pre stm-text="message.result">result</pre>
                         </details>
                         </details>
                     </div>
                     </div>
                     <div stm-if="message.kind == 'user'" class="chat-bubble chat-user">
                     <div stm-if="message.kind == 'user'" class="chat-bubble chat-user">
                         <div class="md" stm-markdown="message.content">message</div>
                         <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>
                     <!-- A tool-calling assistant row renders NO bubble
                     <!-- A tool-calling assistant row renders NO bubble
                          of its own when it said nothing worth reading
                          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"
                     <div stm-if="message.kind == 'assistant' && !message.is_question && !message.bubble_hidden"
                          class="chat-bubble chat-assistant">
                          class="chat-bubble chat-assistant">
                         <div class="md" stm-markdown="message.content">message</div>
                         <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>
                     <div stm-if="message.kind == 'assistant' && message.is_question"
                     <div stm-if="message.kind == 'assistant' && message.is_question"
                          class="chat-question">
                          class="chat-question">
@@ -186,35 +193,35 @@
                         <div stm-if="!message.answered && chat.status == 'awaiting_answer'"
                         <div stm-if="!message.answered && chat.status == 'awaiting_answer'"
                              class="card chat-question-card"
                              class="card chat-question-card"
                              stm-attribute.data-message-id="message.id">
                              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>
                                 answer, or type your own below.</p>
                             <div class="chat-options" stm-if="message.options.length > 0">
                             <div class="chat-options" stm-if="message.options.length > 0">
                                 <button type="button" stm-for-option-in="message.options"
                                 <button type="button" stm-for-option-in="message.options"
                                         stm-key="option" stm-text="option">option</button>
                                         stm-key="option" stm-text="option">option</button>
                             </div>
                             </div>
-                            <form class="inline"
+                            <form class="cluster"
                                   onsubmit="window.submitAdvisorOther(this); return false;">
                                   onsubmit="window.submitAdvisorOther(this); return false;">
                                 <input type="text" name="other"
                                 <input type="text" name="other"
                                        placeholder="Or type your own answer">
                                        placeholder="Or type your own answer">
                                 <button type="submit">Answer</button>
                                 <button type="submit">Answer</button>
                             </form>
                             </form>
                         </div>
                         </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>
                             <strong stm-text="message.answer">answer</strong></p>
                     </div>
                     </div>
                     <div stm-if="message.kind == 'error'" class="chat-error">
                     <div stm-if="message.kind == 'error'" class="chat-error">
                         <p stm-text="message.content">error</p>
                         <p stm-text="message.content">error</p>
                     </div>
                     </div>
                     <div stm-if="message.kind == 'system'" class="chat-system">
                     <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>
                 </div>
 
 
                 <div stm-if="chat.status == 'running'" class="chat-thinking">
                 <div stm-if="chat.status == 'running'" class="chat-thinking">
-                    <p class="muted">thinking…</p>
+                    <p class="text-muted">thinking…</p>
                 </div>
                 </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>
                     something below.</p>
             </div>
             </div>
 
 
@@ -227,22 +234,22 @@
                              stm-text="suggestion.kind">task</span>
                              stm-text="suggestion.kind">task</span>
                         <span stm-attribute.class="'badge badge-suggestion-' + suggestion.status"
                         <span stm-attribute.class="'badge badge-suggestion-' + suggestion.status"
                               stm-text="suggestion.status">pending</span>
                               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>
                     <p><strong stm-text="suggestion.title">title</strong></p>
                     <div class="md" stm-markdown="suggestion.body">body</div>
                     <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>
                         mode <span stm-text="suggestion.mode.length > 0 ? suggestion.mode : 'implement'">mode</span>
                         - priority <span stm-text="suggestion.priority">5</span>
                         - priority <span stm-text="suggestion.priority">5</span>
                         <span stm-if="suggestion.project_id > 0"> - project
                         <span stm-if="suggestion.project_id > 0"> - project
                             <span stm-text="suggestion.project_id">id</span></span></p>
                             <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
                         <span stm-if="suggestion.branch_name.length > 0">branch
                             <code stm-text="suggestion.branch_name">branch</code> - </span>
                             <code stm-text="suggestion.branch_name">branch</code> - </span>
                         <span stm-if="suggestion.project_id > 0">project
                         <span stm-if="suggestion.project_id > 0">project
                             <span stm-text="suggestion.project_id">id</span> - </span>
                             <span stm-text="suggestion.project_id">id</span> - </span>
                         created as an unqueued draft</p>
                         created as an unqueued draft</p>
                     <div stm-if="suggestion.status == 'pending'" class="chat-suggestion-actions">
                     <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="approve_enqueue">Approve + enqueue</button>
                         <button type="button" data-decision="decline">Decline</button>
                         <button type="button" data-decision="decline">Decline</button>
                     </div>
                     </div>
@@ -259,11 +266,11 @@
                 <textarea id="chat-composer" rows="3"
                 <textarea id="chat-composer" rows="3"
                           stm-attribute.disabled="chat.status == 'running'"
                           stm-attribute.disabled="chat.status == 'running'"
                           placeholder="Reply to the advisor..."></textarea>
                           placeholder="Reply to the advisor..."></textarea>
-                <button type="button" class="primary"
+                <button type="button" class="btn-primary"
                         stm-attribute.disabled="chat.status == 'running'"
                         stm-attribute.disabled="chat.status == 'running'"
                         onclick="window.sendAdvisorMessage()">Send</button>
                         onclick="window.sendAdvisorMessage()">Send</button>
             </div>
             </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>
                 waiting for your answer above.</p>
         </div>
         </div>
     </section>
     </section>

+ 14 - 7
src/pages/chats.html

@@ -7,11 +7,11 @@
 <body>
 <body>
     <section>
     <section>
         <h2>Chats</h2>
         <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
             <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
             state, ask you questions and suggest tasks or goals, but never runs
             commands itself.</p>
             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
             advisor is not configured - set the model endpoint on the <a href="/models">Admin
             → Model</a> page to enable it. Chats you start now
             → Model</a> page to enable it. Chats you start now
             will wait until then.</p>
             will wait until then.</p>
@@ -19,7 +19,9 @@
         <p stm-if="chats == null">Loading…</p>
         <p stm-if="chats == null">Loading…</p>
 
 
         <div stm-if="chats != null">
         <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>
                 <thead><tr><th>Chat</th><th>Status</th><th>Created</th><th>Updated</th><th></th></tr></thead>
                 <tbody>
                 <tbody>
                     <tr stm-for-chat-in="chats.chats" stm-key="chat.id">
                     <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.created_display">when</td>
                         <td stm-text="chat.updated_display">when</td>
                         <td stm-text="chat.updated_display">when</td>
                         <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>
                                         stm-confirm="Delete this chat? Its messages and suggestions go with it.">Delete</button>
                             </form>
                             </form>
                         </td>
                         </td>
                     </tr>
                     </tr>
                 </tbody>
                 </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
                 question into the "Ask the advisor" box at the top of the
                 <a href="/">dashboard</a> to start one.</p>
                 <a href="/">dashboard</a> to start one.</p>
         </div>
         </div>

+ 41 - 21
src/pages/discoveries.html

@@ -37,15 +37,15 @@
 <body>
 <body>
     <section>
     <section>
         <h2>Discoveries</h2>
         <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
             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>
             before working with a technology and refine notes in place; you can do the same.</p>
 
 
         <p stm-if="discoveries == null">Loading…</p>
         <p stm-if="discoveries == null">Loading…</p>
 
 
         <div stm-if="discoveries != null">
         <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"
                     <input type="search" id="discovery-q"
                            stm-attribute.value="discoveries.query"
                            stm-attribute.value="discoveries.query"
                            placeholder="meson, valac, sqlite…">
                            placeholder="meson, valac, sqlite…">
@@ -54,28 +54,48 @@
 
 
             <div stm-for-discovery-in="discoveries.discoveries" stm-key="discovery.id"
             <div stm-for-discovery-in="discoveries.discoveries" stm-key="discovery.id"
                  stm-if="window.discoveryRowVisible(discovery)" class="card">
                  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>
                             stm-confirm="Delete this discovery?">Delete</button>
-                </form>
+                </div>
             </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
                 No discoveries yet — the knowledge base starts empty and grows as agents
                 record what they learn.</p>
                 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>
                     stm-text="discoveries.more">n</span> more (narrow the search).</p>
         </div>
         </div>
     </section>
     </section>

+ 1 - 1
src/pages/goal_branches.html

@@ -17,6 +17,6 @@
         <span stm-attribute.class="branch.pushed ? 'badge badge-done' : 'badge badge-failed'"
         <span stm-attribute.class="branch.pushed ? 'badge badge-done' : 'badge badge-failed'"
               stm-text="branch.pushed ? 'pushed' : 'unpushed'">state</span>
               stm-text="branch.pushed ? 'pushed' : 'unpushed'">state</span>
     </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>
 </body>
 </html>
 </html>

+ 317 - 193
src/pages/goal_detail.html

@@ -4,13 +4,18 @@
     <pstm-uri>/goals/{goal_id}</pstm-uri>
     <pstm-uri>/goals/{goal_id}</pstm-uri>
     <pstm-template>main</pstm-template>
     <pstm-template>main</pstm-template>
 <script>
 <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
     // rules, the approved plan (revision history behind the
     // expander), the supervision narrative (events at full weight,
     // expander), the supervision narrative (events at full weight,
     // comment events as footnotes), the comment thread with
     // 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
     // R3/arch 15): a per-block queue that renders at the bottom of
     // the plan, persists across pulse refreshes (the maps live
     // the plan, persists across pulse refreshes (the maps live
     // outside statum re-renders), rides the rejection numbered, and
     // 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) {
     if (window.statum) {
         (function () {
         (function () {
             'use strict';
             'use strict';
@@ -63,6 +67,17 @@
                 if (document.visibilityState === 'visible') refreshGoal('focus');
                 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) {
             if (window.initQuoteComments) {
                 window.initQuoteComments({
                 window.initQuoteComments({
                     matches: function (node) {
                     matches: function (node) {
@@ -85,11 +100,11 @@
             // now lives once); only the block resolution is page
             // now lives once); only the block resolution is page
             // state.
             // state.
             // Plan 16 S2: the block for a review container resolves
             // 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) {
             function resolveReviewBlock(id) {
                 var goal = statum.read('goal');
                 var goal = statum.read('goal');
                 if (!goal) return null;
                 if (!goal) return null;
@@ -97,6 +112,10 @@
                 (goal.open_blocks || []).forEach(function (candidate) {
                 (goal.open_blocks || []).forEach(function (candidate) {
                     if (String(candidate.id) === String(id)) block = 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
                 if (!block && goal.plan_pending_block
                         && String(goal.plan_pending_block.id) === String(id)) {
                         && String(goal.plan_pending_block.id) === String(id)) {
                     block = goal.plan_pending_block;
                     block = goal.plan_pending_block;
@@ -121,36 +140,177 @@
 </head>
 </head>
 <body>
 <body>
     <div class="page goal-page">
     <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> —
         <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>
             <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">
         <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">
                 <div class="card-head">
                     <h3>Status</h3>
                     <h3>Status</h3>
                 </div>
                 </div>
                 <p><span stm-attribute.class="'badge badge-' + goal.status" stm-text="goal.display">status</span>
                 <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>
                     · 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"
                     <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>
                     · priority <strong stm-text="goal.priority">3</strong>
                     <span stm-if="goal.agent_saddle.length > 0"> · agent saddle
                     <span stm-if="goal.agent_saddle.length > 0"> · agent saddle
                         <strong stm-text="goal.agent_saddle">saddle</strong></span></p>
                         <strong stm-text="goal.agent_saddle">saddle</strong></span></p>
                 <pstm-fragment name="goal-branches"
                 <pstm-fragment name="goal-branches"
                                note="&quot;(the daemon's /status report)&quot;"/>
                                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>
                     <a stm-attribute.href="'/projects/' + goal.project_id" stm-text="goal.project_name">project</a>
                     · Created <span stm-text="goal.created_display">when</span>
                     · Created <span stm-text="goal.created_display">when</span>
                     <span stm-if="goal.started_display.length > 0"> · Started
                     <span stm-if="goal.started_display.length > 0"> · Started
@@ -158,13 +318,13 @@
                     <span stm-if="goal.completed_display.length > 0"> · Finished
                     <span stm-if="goal.completed_display.length > 0"> · Finished
                         <span stm-text="goal.completed_display">when</span></span></p>
                         <span stm-text="goal.completed_display">when</span></span></p>
                 <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>
                             stm-action="goal.start">Start goal</button>
                     <button type="button" stm-if="goal.pause != null"
                     <button type="button" stm-if="goal.pause != null"
                             stm-action="goal.pause">Pause</button>
                             stm-action="goal.pause">Pause</button>
                     <button type="button" stm-if="goal.resume != null"
                     <button type="button" stm-if="goal.resume != null"
                             stm-action="goal.resume">Resume</button>
                             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>
                             stm-action="goal.retry_run">Retry supervision</button>
                     <button type="button" stm-if="goal.requeue_run != null"
                     <button type="button" stm-if="goal.requeue_run != null"
                             stm-action="goal.requeue_run"
                             stm-action="goal.requeue_run"
@@ -178,20 +338,20 @@
                             stm-action="goal.cancel"
                             stm-action="goal.cancel"
                             stm-confirm="Cancel this goal? Its tasks are cancelled with it.">Cancel goal</button>
                             stm-confirm="Cancel this goal? Its tasks are cancelled with it.">Cancel goal</button>
                 </p>
                 </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>
                     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>
                     task events queue and coalesce into the resume.</p>
                 <!-- Plan 15 C4: the ONE muted activity line (the
                 <!-- Plan 15 C4: the ONE muted activity line (the
                      Current-activity card is gone) — what the run is
                      Current-activity card is gone) — what the run is
                      doing right now, folded into the status head. -->
                      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>
                    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>
                    stm-text="goal.saddle_health">health</p>
                 <!-- S5: the goal's aggregate LLM usage across its tasks
                 <!-- S5: the goal's aggregate LLM usage across its tasks
                      (the run plus the workers) — empty renders nothing. -->
                      (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>
                    stm-text="goal.usage_line">LLM: 1,234 calls · 45.2M in / 1.1M out tok · 3h 12m</p>
             </section>
             </section>
 
 
@@ -203,7 +363,7 @@
             <section class="card">
             <section class="card">
                 <h3>Agent progress</h3>
                 <h3>Agent progress</h3>
                 <pstm-fragment name="progress-feed" entries="goal.progress"/>
                 <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>
                     agent reports via <code>saddle progress-update</code>.</p>
             </section>
             </section>
 
 
@@ -213,43 +373,18 @@
                  the historic answered ones fold behind the
                  the historic answered ones fold behind the
                  answered-blocks expander (the task page's pattern);
                  answered-blocks expander (the task page's pattern);
                  the completion review follows when one awaits. -->
                  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>
                 <h3>Goal agent blocks</h3>
                 <div stm-for-block-in="goal.open_blocks" class="goal-block">
                 <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>
                              stm-text="block.block_type">kind</span>
                         <strong stm-text="block.question">question</strong>
                         <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.is_completion_review">
                         <div stm-if="block.show_body && block.body.length > 0"
                         <div stm-if="block.show_body && block.body.length > 0"
                              class="md rfc-body goal-body" stm-markdown="block.body">body</div>
                              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"
                         <p><span stm-attribute.class="'badge badge-block-' + block.block_type"
                                  stm-text="block.block_type">kind</span>
                                  stm-text="block.block_type">kind</span>
                             <strong stm-text="block.question">question</strong>
                             <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"
                         <div stm-if="block.show_body && block.body.length > 0"
                              class="md rfc-body goal-body" stm-markdown="block.body">body</div>
                              class="md rfc-body goal-body" stm-markdown="block.body">body</div>
                         <blockquote stm-if="block.answered" class="term-comment-quote">
                         <blockquote stm-if="block.answered" class="term-comment-quote">
@@ -277,57 +412,32 @@
                  once the goal is complete/failed, with the report and
                  once the goal is complete/failed, with the report and
                  the compare link. While PENDING, the review lives in
                  the compare link. While PENDING, the review lives in
                  the attention blocks above (the rich review form). -->
                  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>
                 <h3>Completion review</h3>
                 <pstm-fragment name="goal-branches"/>
                 <pstm-fragment name="goal-branches"/>
                 <pstm-fragment name="goal-compare"/>
                 <pstm-fragment name="goal-compare"/>
                 <div class="md rfc-body goal-body" stm-markdown="goal.completion_report">report</div>
                 <div class="md rfc-body goal-body" stm-markdown="goal.completion_report">report</div>
             </section>
             </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>
                 <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
                     <summary>Approved plan &amp; revisions (<span
                             stm-text="goal.plan_revisions.length">0</span>)</summary>
                             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
                     <div stm-if="goal.plan.length > 0" class="md rfc-body goal-body" data-plan-body
                          stm-markdown="goal.plan">plan</div>
                          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">
                     <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'))"
                         <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>
                                  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>
                         <details>
                             <summary>Submitted markdown</summary>
                             <summary>Submitted markdown</summary>
                             <div class="md rfc-body" stm-markdown="revision.body">body</div>
                             <div class="md rfc-body" stm-markdown="revision.body">body</div>
@@ -339,10 +449,76 @@
                 </details>
                 </details>
             </section>
             </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:
             <!-- Task status strip + the goal's task list (plan 14 B5:
                  tasks above the brief; capability badges per row —
                  tasks above the brief; capability badges per row —
                  no editing here; yeehaw on rows only). -->
                  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>
                 <h3>Tasks</h3>
                 <p class="goal-strip">
                 <p class="goal-strip">
                     <button type="button" stm-attribute.class="window.goalFilterClass('draft')"
                     <button type="button" stm-attribute.class="window.goalFilterClass('draft')"
@@ -360,7 +536,7 @@
                     <button type="button" stm-attribute.class="window.goalFilterClass('failed')"
                     <button type="button" stm-attribute.class="window.goalFilterClass('failed')"
                             onclick="window.goalSetFilter('failed')"
                             onclick="window.goalSetFilter('failed')"
                             stm-text="'failed ' + goal.failed_tasks">failed</button>
                             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>
                 </p>
                 <table>
                 <table>
                     <thead><tr><th>#</th><th>Title</th><th>Status</th><th>Mode</th><th>Requires</th><th>Created by</th></tr></thead>
                     <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">
                             <td><span stm-for-capability-in="goal_task.capabilities">
                                 <span class="badge badge-capability"
                                 <span class="badge badge-capability"
                                       stm-text="capability">tag</span> </span></td>
                                       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>
                         <tr stm-if="goal.goal_tasks.length == 0">
                         <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>
                                 them out once the plan is approved.</td></tr>
                     </tbody>
                     </tbody>
                 </table>
                 </table>
@@ -395,23 +571,22 @@
                  nothing else could change). Starting locks the brief:
                  nothing else could change). Starting locks the brief:
                  the editor's stm-if wrapper disappears with the
                  the editor's stm-if wrapper disappears with the
                  update action and the card stays the plain read view. -->
                  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
                 <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>
                             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 data-lf-view>
                     <div class="md rfc-body goal-body" data-brief-body stm-markdown="goal.brief">brief</div>
                     <div class="md rfc-body goal-body" data-brief-body stm-markdown="goal.brief">brief</div>
                 </div>
                 </div>
                 <div stm-if="goal.update != null">
                 <div stm-if="goal.update != null">
                     <form data-lf-edit hidden stm-action="goal.update">
                     <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>
                             <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"
                             <textarea name="brief" rows="8"
                                       data-lf-src="goal.brief">brief</textarea></div>
                                       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"
                             <input type="number" name="priority" min="1" max="9"
                                    data-lf-src="goal.priority"></div>
                                    data-lf-src="goal.priority"></div>
                         <button type="submit">Save</button>
                         <button type="submit">Save</button>
@@ -426,14 +601,14 @@
                 <p stm-if="goal.links.length > 0"><span stm-for-link-in="goal.links">
                 <p stm-if="goal.links.length > 0"><span stm-for-link-in="goal.links">
                     <span stm-attribute.class="'badge badge-' + link.relationship"
                     <span stm-attribute.class="'badge badge-' + link.relationship"
                           stm-text="link.name + ' (' + link.relationship + ')'">link</span>
                           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>
                             stm-confirm="Remove this link?">&times;</button>
                     <input type="hidden" name="link_id" stm-attribute.value="link.id">
                     <input type="hidden" name="link_id" stm-attribute.value="link.id">
                 </span></p>
                 </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">
                 <details stm-if="!goal.terminal">
                     <summary>Link a project</summary>
                     <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>
                         <select name="linked_project_id" required>
                             <option stm-for-project-in="goal.project_options" stm-attribute.value="project.id"
                             <option stm-for-project-in="goal.project_options" stm-attribute.value="project.id"
                                      stm-text="project.name">project</option>
                                      stm-text="project.name">project</option>
@@ -444,7 +619,7 @@
                         </select>
                         </select>
                         <button type="submit">Link</button>
                         <button type="submit">Link</button>
                     </form>
                     </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;">
                           stm-if="goal.stack_options.length > 0" style="margin-top: 0.5em;">
                         <label for="goal-stack">Apply stack</label>
                         <label for="goal-stack">Apply stack</label>
                         <select id="goal-stack" name="stack_id" required>
                         <select id="goal-stack" name="stack_id" required>
@@ -460,24 +635,24 @@
                  the agent and every worker it fans out. -->
                  the agent and every worker it fans out. -->
             <section class="card">
             <section class="card">
                 <h3>Rules</h3>
                 <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>
                     the primary project's, then co-developed projects' in link order.</p>
                 <div stm-for-rule-in="goal.rules" class="rule-card">
                 <div stm-for-rule-in="goal.rules" class="rule-card">
                     <h4 stm-text="rule.name">name</h4>
                     <h4 stm-text="rule.name">name</h4>
                     <div class="md" stm-markdown="rule.body">body</div>
                     <div class="md" stm-markdown="rule.body">body</div>
                 </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>
             </section>
 
 
             <!-- The supervision narrative (plan 14 B6): state events
             <!-- The supervision narrative (plan 14 B6): state events
                  at full weight, comment events as .footnote — the
                  at full weight, comment events as .footnote — the
                  feed reads as events-with-occasional-footnotes. -->
                  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>
                 <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"
                 <div stm-for-event-in="goal.events"
                      stm-attribute.class="'goal-event' + (event.kind == 'comment' ? ' footnote' : '')">
                      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"
                     <span stm-attribute.class="'badge badge-goal-' + event.kind"
                           stm-text="event.kind.replace('-', ' ')">kind</span>
                           stm-text="event.kind.replace('-', ' ')">kind</span>
                     <!-- Comment events render as footnotes (plan 13 B4
                     <!-- Comment events render as footnotes (plan 13 B4
@@ -489,84 +664,33 @@
                 </div>
                 </div>
             </section>
             </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">
             <section class="card" stm-if="goal.runs.length > 0">
                 <h3>Goal agent runs</h3>
                 <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>
                         #<span stm-text="goal.run_task_id">id</span></span></p>
                 <table>
                 <table>
                     <thead><tr><th>Run</th><th>Saddle</th><th>Started</th><th>Ended</th><th>Outcome</th></tr></thead>
                     <thead><tr><th>Run</th><th>Saddle</th><th>Started</th><th>Ended</th><th>Outcome</th></tr></thead>
                     <tbody>
                     <tbody>
                         <tr stm-for-run-in="goal.runs">
                         <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>
                                    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>
                             <td><span stm-if="run.open" class="badge badge-working">open</span>
                                 <span stm-if="!run.open" stm-text="run.outcome">outcome</span></td>
                                 <span stm-if="!run.open" stm-text="run.outcome">outcome</span></td>
                         </tr>
                         </tr>
                     </tbody>
                     </tbody>
                 </table>
                 </table>
             </section>
             </section>
+
+                </div><!-- /data-goal-consult -->
+            </div><!-- /act-consult columns -->
         </div>
         </div>
     </div>
     </div>
 </body>
 </body>

+ 147 - 34
src/pages/goals.html

@@ -9,40 +9,167 @@
     // brief is a heredoc-style textarea (markdown, rendered on the
     // brief is a heredoc-style textarea (markdown, rendered on the
     // goal page). Branch name is required and uniqueness-validated
     // goal page). Branch name is required and uniqueness-validated
     // server-side.
     // 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) {
     if (window.statum) {
         (function () {
         (function () {
             'use strict';
             '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>
 </script>
 </head>
 </head>
 <body>
 <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>
             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>
                     <select id="goal-project" name="project_id" required>
                         <option stm-for-project-in="goals.projects" stm-attribute.value="project.id"
                         <option stm-for-project-in="goals.projects" stm-attribute.value="project.id"
                                  stm-text="project.name">project</option>
                                  stm-text="project.name">project</option>
                     </select></div>
                     </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
                     <input type="text" id="goal-title" name="title" required
                            placeholder="What should the goal achieve?"></div>
                            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
                     <textarea id="goal-brief" name="brief" rows="6" required
                               placeholder="Markdown brief — locks once the goal starts"></textarea></div>
                               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
                     <input type="text" id="goal-branch" name="branch_name" required
                            placeholder="goal/my-feature"></div>
                            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"
                     <input type="text" id="goal-base" name="base_branch"
                            placeholder="main"></div>
                            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">
                     <select id="goal-priority" name="priority">
                         <option value="1">1 — highest</option>
                         <option value="1">1 — highest</option>
                         <option value="2">2</option>
                         <option value="2">2</option>
@@ -54,27 +181,13 @@
                         <option value="8">8</option>
                         <option value="8">8</option>
                         <option value="9">9 — lowest</option>
                         <option value="9">9 — lowest</option>
                     </select></div>
                     </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>
 </body>
 </html>
 </html>

+ 313 - 409
src/pages/home.html

@@ -8,8 +8,9 @@
     // re-runs the sealed refresh action whenever task or saddle state
     // re-runs the sealed refresh action whenever task or saddle state
     // changes, so progress, blocks and review requests stream in.
     // changes, so progress, blocks and review requests stream in.
     // The notification bell drives the Web Push subscribe flow (plan
     // 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) {
     if (window.statum) {
         (function () {
         (function () {
             'use strict';
             '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() {
             function dashboard() {
                 return statum.read('dashboard') || {};
                 return statum.read('dashboard') || {};
             }
             }
@@ -59,419 +40,342 @@
                 statum.act(state.start_advisor, { message: text }).catch(function () { });
                 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>
 </script>
 </head>
 </head>
 <body>
 <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>
         <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>
                 </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>
             </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>
             </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>
         </div>
-    </section>
+    </div>
 </body>
 </body>
 </html>
 </html>

+ 23 - 11
src/pages/login.html

@@ -6,6 +6,14 @@
     <pstm-uri>/login</pstm-uri>
     <pstm-uri>/login</pstm-uri>
     <pstm-title>ClankerSaddle — Log in</pstm-title>
     <pstm-title>ClankerSaddle — Log in</pstm-title>
     <script pstm-res-src="statum.js"></script>
     <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="stylesheet" pstm-res-href="app.css">
     <link rel="icon" type="image/png" pstm-res-href="clanker-saddle.png">
     <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">
     <link rel="apple-touch-icon" type="image/png" pstm-res-href="clanker-saddle.png">
@@ -27,21 +35,25 @@
     </script>
     </script>
 </head>
 </head>
 <body>
 <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">
             <img class="login-emblem" alt="" pstm-res-src="clanker-saddle-emblem.png">
             <h1>ClankerSaddle</h1>
             <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>
             <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>
             </form>
-            <p class="error" id="login-error" hidden></p>
+            <p class="text-danger" id="login-error" hidden></p>
         </div>
         </div>
-    </section>
+    </main>
 </body>
 </body>
 </html>
 </html>

+ 58 - 46
src/pages/main.html

@@ -1,74 +1,86 @@
 <!DOCTYPE html>
 <!DOCTYPE html>
-<html lang="en">
+<html lang="en" data-mode="auto">
 <head>
 <head>
     <meta charset="utf-8">
     <meta charset="utf-8">
     <meta name="viewport" content="width=device-width, initial-scale=1.0">
     <meta name="viewport" content="width=device-width, initial-scale=1.0">
     <pstm-name>main</pstm-name>
     <pstm-name>main</pstm-name>
     <pstm-title>ClankerSaddle</pstm-title>
     <pstm-title>ClankerSaddle</pstm-title>
     <script pstm-res-src="statum.js"></script>
     <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="stylesheet" pstm-res-href="app.css">
     <link rel="icon" type="image/png" pstm-res-href="clanker-saddle.png">
     <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">
     <link rel="stm-guard" require="auth != null || location.pathname == '/login'" href="/login">
     <script pstm-res-src="app.js"></script>
     <script pstm-res-src="app.js"></script>
 </head>
 </head>
 <body>
 <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 -->
             <!-- 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 -->
             <!-- spry:nav-end -->
             <span class="nav-auth">
             <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-begin -->
                 <!-- spry:nav-auth-end -->
                 <!-- spry:nav-auth-end -->
             </span>
             </span>
         </nav>
         </nav>
     </header>
     </header>
-    <main>
+    <main class="container">
         <div stm-preloader>Loading…</div>
         <div stm-preloader>Loading…</div>
         <div stm-content hidden>
         <div stm-content hidden>
             <pstm-content></pstm-content>
             <pstm-content></pstm-content>
         </div>
         </div>
     </main>
     </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>
 </body>
 </html>
 </html>

+ 58 - 40
src/pages/model_settings.html

@@ -5,56 +5,74 @@
     <pstm-template>main</pstm-template>
     <pstm-template>main</pstm-template>
 </head>
 </head>
 <body>
 <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>
         <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
             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
             in the database — environment variables no longer configure it (a pre-page
             deployment's SADDLE_LLM_* environment is imported once, on first read).</p>
             deployment's SADDLE_LLM_* environment is imported once, on first read).</p>
         <p stm-if="model == null">Loading…</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>
             </form>
         </div>
         </div>
-    </section>
+    </div>
 </body>
 </body>
 </html>
 </html>

+ 103 - 18
src/pages/profile.html

@@ -3,35 +3,120 @@
 <head>
 <head>
     <pstm-uri>/profile</pstm-uri>
     <pstm-uri>/profile</pstm-uri>
     <pstm-template>main</pstm-template>
     <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>
 </head>
 <body>
 <body>
-    <section>
+    <section class="stack">
         <h2>Your profile</h2>
         <h2>Your profile</h2>
         <p stm-if="profile == null">Loading…</p>
         <p stm-if="profile == null">Loading…</p>
 
 
-        <div stm-if="profile != null">
+        <div stm-if="profile != null" class="stack">
             <div class="card">
             <div class="card">
                 <h3 stm-text="profile.name_display">name</h3>
                 <h3 stm-text="profile.name_display">name</h3>
                 <p><span stm-attribute.class="profile.is_admin ? 'badge badge-admin' : 'badge'"
                 <p><span stm-attribute.class="profile.is_admin ? 'badge badge-admin' : 'badge'"
                          stm-text="profile.is_admin ? 'admin' : 'user'">role</span></p>
                          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>
             </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>
         </div>
     </section>
     </section>
 </body>
 </body>

+ 2 - 2
src/pages/progress_feed.html

@@ -13,12 +13,12 @@
     <pstm-input name="entries"/>
     <pstm-input name="entries"/>
     <div stm-if="entries.length > 0">
     <div stm-if="entries.length > 0">
         <div class="progress-latest md" stm-markdown="entries[0].summary">latest</div>
         <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">
         <details class="history-expander" stm-if="entries.length > 1">
             <summary>Show history (<span stm-text="entries.length">0</span> updates)</summary>
             <summary>Show history (<span stm-text="entries.length">0</span> updates)</summary>
             <ul class="progress-history">
             <ul class="progress-history">
                 <li stm-for-item-in="entries">
                 <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>
                     <span stm-text="item.summary">summary</span>
                 </li>
                 </li>
             </ul>
             </ul>

+ 97 - 49
src/pages/project_detail.html

@@ -7,35 +7,54 @@
 <body>
 <body>
     <section>
     <section>
         <h2 stm-if="project != null && project.exists" stm-text="project.name">Project</h2>
         <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">
         <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>
                     <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
                     <p><strong stm-text="project.git_url">git_url</strong> · branch
                         <em stm-text="project.default_branch">main</em></p>
                         <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">
                         <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>
                     </form>
-                </section>
-            </div>
+                </div>
+            </section>
 
 
             <h3>Linked projects</h3>
             <h3>Linked projects</h3>
             <table>
             <table>
@@ -47,10 +66,10 @@
                         <td><span stm-attribute.class="'badge badge-' + link.relationship" stm-text="link.relationship">relationship</span></td>
                         <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>
                         <td><button type="button" stm-action="link.remove" stm-confirm="Remove this link?">Remove</button></td>
                     </tr>
                     </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>
                 </tbody>
             </table>
             </table>
-            <form class="inline" stm-action="project.add_link">
+            <form class="cluster" stm-action="project.add_link">
                 <label for="link-target">Link project</label>
                 <label for="link-target">Link project</label>
                 <select id="link-target" name="linked_project_id" required>
                 <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>
                     <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>
                 <button type="submit">Add link</button>
             </form>
             </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>
                 <label for="stack-target">Apply stack</label>
                 <select id="stack-target" name="stack_id" required>
                 <select id="stack-target" name="stack_id" required>
                     <option stm-for-stack-in="project.stack_options" stm-attribute.value="stack.id"
                     <option stm-for-stack-in="project.stack_options" stm-attribute.value="stack.id"
@@ -70,20 +89,20 @@
                 </select>
                 </select>
                 <button type="submit">Apply</button>
                 <button type="submit">Apply</button>
             </form>
             </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>
                 collections on the <a href="/stacks">stacks page</a> and apply them here.</p>
 
 
             <h3>Rules</h3>
             <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">
             <div stm-for-rule-in="project.rules" stm-key="rule.link_id" class="card">
                 <h3 stm-text="rule.name">name</h3>
                 <h3 stm-text="rule.name">name</h3>
                 <div class="md" stm-markdown="rule.body">body</div>
                 <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>
                     <button type="button" stm-action="rule.detach" stm-confirm="Detach this rule?">Detach</button>
                 </form>
                 </form>
             </div>
             </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>
                 <label for="rule-target">Attach rule</label>
                 <select id="rule-target" name="rule_id" required>
                 <select id="rule-target" name="rule_id" required>
                     <option stm-for-option-in="project.rule_options" stm-attribute.value="option.id"
                     <option stm-for-option-in="project.rule_options" stm-attribute.value="option.id"
@@ -91,13 +110,13 @@
                 </select>
                 </select>
                 <button type="submit">Attach</button>
                 <button type="submit">Attach</button>
             </form>
             </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>
             <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>
                 unusual, written by an agent against a commit and refreshed on demand.</p>
             <div class="card" stm-if="project.understanding_current.body.length > 0">
             <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
                         <code stm-text="project.understanding_current.commit_hash">hash</code> · </span>refreshed
                     <span stm-text="project.understanding_current.created_display">when</span><span
                     <span stm-text="project.understanding_current.created_display">when</span><span
                         stm-if="project.understanding_current.source_task_id > 0"> · from task
                         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>
                            stm-text="'#' + project.understanding_current.source_task_id">task</a></span></p>
                 <div class="md" stm-markdown="project.understanding_current.body">body</div>
                 <div class="md" stm-markdown="project.understanding_current.body">body</div>
             </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>
                 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>
                 <button type="submit" stm-confirm="Spin up an agent task to refresh the understanding?">Refresh</button>
             </form>
             </form>
             <div stm-for-revision-in="project.understanding_history" stm-key="revision.id" class="card">
             <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
                         <code stm-text="revision.commit_hash">hash</code> · </span>refreshed
                     <span stm-text="revision.created_display">when</span><span
                     <span stm-text="revision.created_display">when</span><span
                         stm-if="revision.source_task_id > 0"> · from task
                         stm-if="revision.source_task_id > 0"> · from task
@@ -121,22 +140,30 @@
             </div>
             </div>
 
 
             <h3>Project notes</h3>
             <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>
                 stops being useful. There is no manual create.</p>
             <div stm-for-note-in="project.notes" stm-key="note.id" class="card">
             <div stm-for-note-in="project.notes" stm-key="note.id" class="card">
                 <p><span class="badge" stm-text="note.project_name">project</span>
                 <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>
                         <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>
                 <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>
                     <button type="button" stm-action="note.delete" stm-confirm="Delete this note?">Delete</button>
                 </form>
                 </form>
             </div>
             </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>
                 record them with <code>saddle note</code>.</p>
 
 
             <h3>Tasks in this project</h3>
             <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>
             <table>
                 <thead><tr><th>#</th><th>Title</th><th>Status</th><th>Priority</th></tr></thead>
                 <thead><tr><th>#</th><th>Title</th><th>Status</th><th>Priority</th></tr></thead>
                 <tbody>
                 <tbody>
@@ -147,9 +174,30 @@
                         <td><span stm-attribute.class="'badge badge-' + task.status" stm-text="task.display">status</span></td>
                         <td><span stm-attribute.class="'badge badge-' + task.status" stm-text="task.display">status</span></td>
                         <td stm-text="task.priority">priority</td>
                         <td stm-text="task.priority">priority</td>
                     </tr>
                     </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>
                 </tbody>
             </table>
             </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
             <!-- Admin-only deletion (ownership cascade). The dialog asks
                  for the project's EXACT name — the server re-checks both
                  for the project's EXACT name — the server re-checks both
@@ -157,17 +205,17 @@
                  delete a renamed project or one with running work. -->
                  delete a renamed project or one with running work. -->
             <div stm-if="project.is_admin" class="card">
             <div stm-if="project.is_admin" class="card">
                 <h3>Delete project</h3>
                 <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>
                     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
                     (tasks include the goal agents' own run rows), with their comments, progress
                     and logs.</p>
                     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
                     <span stm-text="project.active_tasks">0</span> task(s) are still queued, working
                     or awaiting review — cancel or let them finish first.</p>
                     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
                     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>
                     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
                         onclick="document.getElementById('project-delete-dialog').showModal()">Delete
                     project…</button>
                     project…</button>
             </div>
             </div>
@@ -179,7 +227,7 @@
                 <p><strong>Linked projects' work is untouched.</strong> Other projects' tasks,
                 <p><strong>Linked projects' work is untouched.</strong> Other projects' tasks,
                     goals and rows that merely reference this project survive — only their link
                     goals and rows that merely reference this project survive — only their link
                     entry pointing here goes.</p>
                     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
                     <span stm-text="project.active_tasks">0</span> live task(s) — the deletion
                     will be refused until they are cancelled or finished.</p>
                     will be refused until they are cancelled or finished.</p>
                 <p>Type the project name <strong stm-text="project.name">name</strong> to
                 <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"
                     <input type="text" name="confirm_name" autocomplete="off"
                            stm-attribute.placeholder="'Type ' + project.name + ' to confirm'"
                            stm-attribute.placeholder="'Type ' + project.name + ' to confirm'"
                            style="width:100%; margin:0.5em 0;">
                            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>
                             onclick="document.getElementById('project-delete-dialog').close()">Cancel</button>
                 </form>
                 </form>
             </dialog>
             </dialog>

+ 56 - 25
src/pages/projects.html

@@ -5,41 +5,72 @@
     <pstm-template>main</pstm-template>
     <pstm-template>main</pstm-template>
 </head>
 </head>
 <body>
 <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>
         <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>
                 <tbody>
                     <tr stm-for-project-in="projects.projects">
                     <tr stm-for-project-in="projects.projects">
                         <td><a stm-attribute.href="'/projects/' + project.id" stm-text="project.name">name</a>
                         <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 ·
                         <td><span stm-text="project.open_tasks">0</span> open ·
                             <span stm-text="project.done_tasks">0</span> closed</td>
                             <span stm-text="project.done_tasks">0</span> closed</td>
                     </tr>
                     </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>
                 </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>
         </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>
 </body>
 </html>
 </html>

+ 99 - 31
src/pages/rules.html

@@ -6,45 +6,113 @@
 </head>
 </head>
 <body>
 <body>
     <section>
     <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>
             attach rules on the project and task pages.</p>
         <p stm-if="rules == null">Loading…</p>
         <p stm-if="rules == null">Loading…</p>
 
 
         <div stm-if="rules != null">
         <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">
             <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>
             </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>
         </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>
     </section>
 </body>
 </body>
 </html>
 </html>

+ 194 - 110
src/pages/saddles.html

@@ -5,138 +5,222 @@
     <pstm-template>main</pstm-template>
     <pstm-template>main</pstm-template>
 </head>
 </head>
 <body>
 <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>
         <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">
             <div class="cards">
                 <section class="card" stm-for-saddle-in="saddles.saddles">
                 <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>
                 <section class="card" stm-if="saddles.saddles.length == 0">
                 <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>
                 </section>
             </div>
             </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>
                 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>
                 <thead><tr><th>Tag</th><th>Saddles carrying it</th><th>Tasks requiring it</th><th></th></tr></thead>
                 <tbody>
                 <tbody>
                     <tr stm-for-capability-in="saddles.capabilities">
                     <tr stm-for-capability-in="saddles.capabilities">
                         <td><span class="badge" stm-text="capability.name">tag</span></td>
                         <td><span class="badge" stm-text="capability.name">tag</span></td>
                         <td stm-text="capability.saddle_count">0</td>
                         <td stm-text="capability.saddle_count">0</td>
                         <td stm-text="capability.task_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>
                                     stm-confirm="Delete this capability everywhere?">Delete</button></td>
                     </tr>
                     </tr>
                     <tr stm-if="saddles.capabilities.length == 0">
                     <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>
                     </tr>
                 </tbody>
                 </tbody>
-            </table>
+
+                </table>
+            </div>
         </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>
 </body>
 </html>
 </html>

+ 82 - 49
src/pages/stack_detail.html

@@ -3,65 +3,98 @@
 <head>
 <head>
     <pstm-uri>/stacks/{stack_id}</pstm-uri>
     <pstm-uri>/stacks/{stack_id}</pstm-uri>
     <pstm-template>main</pstm-template>
     <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>
 </head>
 <body>
 <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>
         <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>
             <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">
                         <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>
                     </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>
         </div>
-    </section>
+    </div>
 </body>
 </body>
 </html>
 </html>

+ 41 - 20
src/pages/stacks.html

@@ -5,38 +5,59 @@
     <pstm-template>main</pstm-template>
     <pstm-template>main</pstm-template>
 </head>
 </head>
 <body>
 <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>
             goal to attach its whole collection of links in a single click.</p>
         <p stm-if="stacks == null">Loading…</p>
         <p stm-if="stacks == null">Loading…</p>
 
 
         <div stm-if="stacks != null">
         <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>
                 <thead><tr><th>#</th><th>Name</th><th>Projects</th><th>Summary</th><th>Created</th></tr></thead>
                 <tbody>
                 <tbody>
                     <tr stm-for-stack-in="stacks.stacks">
                     <tr stm-for-stack-in="stacks.stacks">
                         <td stm-text="stack.id">id</td>
                         <td stm-text="stack.id">id</td>
                         <td><a stm-attribute.href="'/stacks/' + stack.id" stm-text="stack.name">name</a></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 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>
                     </tr>
                 </tbody>
                 </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>
         </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>
 </body>
 </html>
 </html>

+ 471 - 330
src/pages/task_detail.html

@@ -3,16 +3,25 @@
 <head>
 <head>
     <pstm-uri>/tasks/{task_id}</pstm-uri>
     <pstm-uri>/tasks/{task_id}</pstm-uri>
     <pstm-template>main</pstm-template>
     <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>
 <script>
     // Live updates: the page subscribes to the global pulse (see the
     // Live updates: the page subscribes to the global pulse (see the
     // entrypoint); whenever task or saddle state changes anywhere in
     // entrypoint); whenever task or saddle state changes anywhere in
     // the app, the sealed refresh action rehydrates the task slot -
     // the app, the sealed refresh action rehydrates the task slot -
     // no fixed-interval polling. RFC bodies get the shared
     // no fixed-interval polling. RFC bodies get the shared
     // text-highlight -> comment popover (see main.html).
     // 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) {
             function refreshTask(source) {
                 var task = statum.read('task');
                 var task = statum.read('task');
                 if (!task || !task.refresh) return;
                 if (!task || !task.refresh) return;
@@ -52,6 +61,11 @@
             if (window.initLongFormEditors) {
             if (window.initLongFormEditors) {
                 window.initLongFormEditors();
                 window.initLongFormEditors();
             }
             }
+
+            // The shared anchor sub-nav engine (app.js).
+            if (window.initSubnav) {
+                window.initSubnav();
+            }
         })();
         })();
     }
     }
 </script>
 </script>
@@ -60,353 +74,480 @@
     <section>
     <section>
         <h2 stm-if="task != null && task.exists">#<span stm-text="task.id">id</span> —
         <h2 stm-if="task != null && task.exists">#<span stm-text="task.id">id</span> —
             <span stm-text="task.title">title</span></h2>
             <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">
         <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>
                         </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>
                 </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>
             </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>
         </div>
     </section>
     </section>
 </body>
 </body>

+ 26 - 31
src/pages/task_log.html

@@ -7,37 +7,32 @@
 </head>
 </head>
 <body>
 <body>
     <div class="page log-page">
     <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>
     </div>
 </body>
 </body>
 </html>
 </html>

+ 48 - 5
src/pages/task_log.js

@@ -88,6 +88,7 @@
             };
             };
             var feed = null;
             var feed = null;
             var moreButton = null;
             var moreButton = null;
+            var jumpButton = null;
             var stream = null;
             var stream = null;
             var liveBadge = null;
             var liveBadge = null;
             var liveLabel = null;
             var liveLabel = null;
@@ -182,9 +183,23 @@
             }
             }
 
 
             // Pin the feed to its bottom edge (the newest row).
             // Pin the feed to its bottom edge (the newest row).
+            // Reaching the tail retires the jump button (goal 41 T5).
             function scrollToBottom() {
             function scrollToBottom() {
                 if (!feed) return;
                 if (!feed) return;
                 feed.scrollTop = feed.scrollHeight;
                 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
             // The daemon JSON-encodes tool arguments and tool
@@ -410,7 +425,7 @@
                                 ? compact : JSON.stringify(extras, null, 2)));
                                 ? compact : JSON.stringify(extras, null, 2)));
                         } else {
                         } else {
                             var empty = document.createElement('p');
                             var empty = document.createElement('p');
-                            empty.className = 'muted';
+                            empty.className = 'text-muted';
                             empty.textContent = '(no output)';
                             empty.textContent = '(no output)';
                             div.appendChild(empty);
                             div.appendChild(empty);
                         }
                         }
@@ -595,7 +610,7 @@
                 pre.className = 'event-pre event-command';
                 pre.className = 'event-pre event-command';
                 if (bits && bits.length > 0) {
                 if (bits && bits.length > 0) {
                     var tail = document.createElement('span');
                     var tail = document.createElement('span');
-                    tail.className = 'muted';
+                    tail.className = 'text-muted';
                     tail.textContent = '  \u00b7 ' + bits.join(' \u00b7 ');
                     tail.textContent = '  \u00b7 ' + bits.join(' \u00b7 ');
                     pre.appendChild(tail);
                     pre.appendChild(tail);
                 }
                 }
@@ -957,7 +972,7 @@
                 badge.textContent = String(evt.kind || '').replace(/_/g, ' ');
                 badge.textContent = String(evt.kind || '').replace(/_/g, ' ');
                 head.appendChild(badge);
                 head.appendChild(badge);
                 var actor = document.createElement('span');
                 var actor = document.createElement('span');
-                actor.className = 'muted';
+                actor.className = 'text-muted';
                 actor.textContent = ' ' + (evt.actor || '') + ' \u00b7 '
                 actor.textContent = ' ' + (evt.actor || '') + ' \u00b7 '
                     + (evt.created_display || '');
                     + (evt.created_display || '');
                 head.appendChild(actor);
                 head.appendChild(actor);
@@ -1007,7 +1022,7 @@
                     var quote = document.createElement('blockquote');
                     var quote = document.createElement('blockquote');
                     if (p.quote) {
                     if (p.quote) {
                         var q = document.createElement('p');
                         var q = document.createElement('p');
-                        q.className = 'muted';
+                        q.className = 'text-muted';
                         q.textContent = '> ' + p.quote;
                         q.textContent = '> ' + p.quote;
                         quote.appendChild(q);
                         quote.appendChild(q);
                     }
                     }
@@ -1227,7 +1242,7 @@
                 var visibleRows = state.events.filter(visible);
                 var visibleRows = state.events.filter(visible);
                 if (visibleRows.length === 0) {
                 if (visibleRows.length === 0) {
                     var empty = document.createElement('p');
                     var empty = document.createElement('p');
-                    empty.className = 'muted log-empty';
+                    empty.className = 'text-muted log-empty';
                     empty.textContent = state.events.length === 0
                     empty.textContent = state.events.length === 0
                         ? 'Waiting for the first events\u2026'
                         ? 'Waiting for the first events\u2026'
                         : 'No events match the filters.';
                         : '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 () {
             statum.afterEvaluate(function () {
                 var page = statum.read('task_log');
                 var page = statum.read('task_log');
                 if (!page || !page.exists) return;
                 if (!page || !page.exists) return;
+                syncTopbarOffset();
                 feed = document.querySelector('[data-log-feed]');
                 feed = document.querySelector('[data-log-feed]');
                 moreButton = document.querySelector('[data-log-more]');
                 moreButton = document.querySelector('[data-log-more]');
+                jumpButton = document.querySelector('[data-log-jump]');
                 liveBadge = document.querySelector('[data-log-live]');
                 liveBadge = document.querySelector('[data-log-live]');
                 liveLabel = document.querySelector('[data-live-label]');
                 liveLabel = document.querySelector('[data-live-label]');
                 taskStatus = strText(page.task_status);
                 taskStatus = strText(page.task_status);
@@ -1416,6 +1444,21 @@
                     header.textContent = '#' + page.task_id + ' \u2014 '
                     header.textContent = '#' + page.task_id + ' \u2014 '
                         + page.task_title;
                         + 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();
                 if (!stream && feed) connect();
                 render();
                 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>
 <script>
     // The task queue. Plan 12: the filters (status / project / goal)
     // The task queue. Plan 12: the filters (status / project / goal)
     // run CLIENT-SIDE - the Statum entrypoint fetch never carries the
     // 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) {
     if (window.statum) {
         (function () {
         (function () {
             'use strict';
             'use strict';
 
 
+            window.TASK_OPEN_STATUSES = ['queued', 'assigned', 'working',
+                'blocked', 'suspended'];
+
             window.taskFilters = window.taskFilters || {};
             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) {
             window.taskRowVisible = function (row) {
                 var filters = window.taskFilters || {};
                 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
                 if (filters.project && filters.project > 0
                         && String(row.project_id) !== String(filters.project)) {
                         && String(row.project_id) !== String(filters.project)) {
@@ -42,6 +77,10 @@
 
 
             window.setTaskFilter = function (name, value) {
             window.setTaskFilter = function (name, value) {
                 window.taskFilters[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');
                 var tasks = statum.read('tasks');
                 if (tasks && tasks.refresh) {
                 if (tasks && tasks.refresh) {
                     statum.act(tasks.refresh).catch(function () { });
                     statum.act(tasks.refresh).catch(function () { });
@@ -51,27 +90,281 @@
                     document.body.setAttribute('data-filter-bump', String(Date.now()));
                     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>
 </script>
 </head>
 </head>
 <body>
 <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>
             <label for="filter-project">Project</label>
             <select id="filter-project" onchange="window.setTaskFilter('project', this.value)">
             <select id="filter-project" onchange="window.setTaskFilter('project', this.value)">
                 <option value="0">all</option>
                 <option value="0">all</option>
@@ -85,13 +378,80 @@
                 <option stm-for-goal-in="tasks.goal_options" stm-attribute.value="goal.id"
                 <option stm-for-goal-in="tasks.goal_options" stm-attribute.value="goal.id"
                          stm-text="goal.title">goal</option>
                          stm-text="goal.title">goal</option>
             </select>
             </select>
-            <span class="muted">— filters apply instantly</span>
         </form>
         </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 —
                     <!-- Goal 8 task 4: "No project" posts project_id 0 —
                          a STANDALONE task with its own ephemeral
                          a STANDALONE task with its own ephemeral
                          workspace; the completion report is the
                          workspace; the completion report is the
@@ -101,27 +461,27 @@
                         <option stm-for-project-in="tasks.projects" stm-attribute.value="project.id"
                         <option stm-for-project-in="tasks.projects" stm-attribute.value="project.id"
                                  stm-text="project.name">project</option>
                                  stm-text="project.name">project</option>
                     </select></div>
                     </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>
                     <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">
                     <select id="task-mode" name="mode">
                         <option value="implement" selected>implement — build the change on a task branch</option>
                         <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="investigate">investigate — explore and report, no code changes</option>
                         <option value="review">review — read and critique existing work</option>
                         <option value="review">review — read and critique existing work</option>
                         <option value="test">test — write and run tests</option>
                         <option value="test">test — write and run tests</option>
                     </select></div>
                     </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"
                     <textarea id="task-description" name="description" rows="4"
                               placeholder="What the agent must accomplish (the mandate)"></textarea></div>
                               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"
                     <input type="text" id="task-branch" name="task_branch"
                            placeholder="task/&lt;id&gt;-&lt;slug&gt;"></div>
                            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"
                     <input type="text" id="task-branch-from" name="branch_from"
                            placeholder="main"></div>
                            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">
                     <select id="task-priority" name="priority">
                         <option value="1">1 — highest</option>
                         <option value="1">1 — highest</option>
                         <option value="2">2</option>
                         <option value="2">2</option>
@@ -133,54 +493,29 @@
                         <option value="8">8</option>
                         <option value="8">8</option>
                         <option value="9">9 — lowest</option>
                         <option value="9">9 — lowest</option>
                     </select></div>
                     </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">
                     <select id="task-enqueue-after" name="enqueue_after_task_id">
                         <option value="">none</option>
                         <option value="">none</option>
                         <option stm-for-option-in="tasks.enqueue_after_options"
                         <option stm-for-option-in="tasks.enqueue_after_options"
                                  stm-attribute.value="option.id" stm-text="option.name">task</option>
                                  stm-attribute.value="option.id" stm-text="option.name">task</option>
                     </select></div>
                     </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
                     <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
                     <select id="task-capabilities" name="required_capabilities" multiple
                             size="3">
                             size="3">
                         <option stm-for-capability-in="tasks.capability_options"
                         <option stm-for-capability-in="tasks.capability_options"
                                  stm-attribute.value="capability.id"
                                  stm-attribute.value="capability.id"
                                  stm-text="capability.name">cap</option>
                                  stm-text="capability.name">cap</option>
                     </select></div>
                     </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>
 </body>
 </html>
 </html>

+ 88 - 59
src/pages/users.html

@@ -5,37 +5,26 @@
     <pstm-template>main</pstm-template>
     <pstm-template>main</pstm-template>
 </head>
 </head>
 <body>
 <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>
             goals and rules; admins additionally manage users, saddles and capabilities.</p>
         <p stm-if="users == null">Loading…</p>
         <p stm-if="users == null">Loading…</p>
 
 
         <div stm-if="users != null">
         <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>
                 <thead><tr>
                     <th>Username</th><th>Email</th><th>Name</th><th>Role</th>
                     <th>Username</th><th>Email</th><th>Name</th><th>Role</th>
                     <th>Enabled</th><th>Created</th><th></th>
                     <th>Enabled</th><th>Created</th><th></th>
@@ -49,45 +38,85 @@
                                   stm-text="user.is_admin ? 'admin' : 'user'">role</span></td>
                                   stm-text="user.is_admin ? 'admin' : 'user'">role</span></td>
                         <td><span stm-attribute.class="user.enabled ? 'badge badge-done' : 'badge badge-offline'"
                         <td><span stm-attribute.class="user.enabled ? 'badge badge-done' : 'badge badge-offline'"
                                   stm-text="user.enabled ? 'enabled' : 'disabled'">enabled</span></td>
                                   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>
                         <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>
                                         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>
                                         stm-confirm="Delete this user? Their sessions outlive the account.">Delete</button>
-                            </form>
+                            </div>
                         </td>
                         </td>
                     </tr>
                     </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>
                 </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>
         </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>
 </body>
 </html>
 </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,
 from . import (allocation, answers, dispatch, goalui, goals, lineage,
                lifecycle, notes, origins, pages, push, replay, resurrect,
                lifecycle, notes, origins, pages, push, replay, resurrect,
-               stacks, subtasks, users)
+               stacks, subtasks, t4lists, t7log, t8fleet, t9library, users)
 
 
 BUNDLES = {
 BUNDLES = {
     "pages": pages,
     "pages": pages,
+    "t7log": t7log,
     "dispatch": dispatch,
     "dispatch": dispatch,
     "subtasks": subtasks,
     "subtasks": subtasks,
     "lineage": lineage,
     "lineage": lineage,
@@ -24,4 +25,7 @@ BUNDLES = {
     "origins": origins,
     "origins": origins,
     "notes": notes,
     "notes": notes,
     "resurrect": resurrect,
     "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
     check(any(p["item_type"] == "goal" for p in
               slot_public(auth, "/", "dashboard")["pinned"]),
               slot_public(auth, "/", "dashboard")["pinned"]),
           "the goal's pending block pins onto the dashboard pile")
           "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")
           "dashboard goal cards carry the distinct goal styling")
     goals_html = http_get_page(auth, "/goals")
     goals_html = http_get_page(auth, "/goals")
     check("&#127919;" in goals_html and "goal-icon" in goals_html,
     check("&#127919;" in goals_html and "goal-icon" in goals_html,
           "the goals list page carries the goal icon")
           "the goals list page carries the goal icon")
     goal_page_html = http_get_page(auth, "/goals/%d" % goal1)
     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")
           "the goal page carries the goal-accented identity chrome")
     # Reject with feedback: the agent revises.
     # Reject with feedback: the agent revises.
     fake.goal_script = [("plan", "# Plan v2\n\n1. Add tests\n2. Add docs\n3. Ship")]
     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="event.text"' in goal_page
           and 'stm-markdown="block.body"' in goal_page,
           and 'stm-markdown="block.body"' in goal_page,
           "every long-form goal body renders through stm-markdown")
           "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,
     check("progress-latest" in goal_page and "Agent progress" in goal_page,
           "the green progress feed renders on the goal page")
           "the green progress feed renders on the goal page")
     # Plan revision history keeps its verdicts (approved + rejected).
     # 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",
     check(goal_state(goal)["current_activity"] == "parked awaiting the plan review",
           "the head line reads 'parked awaiting the plan review' while one is open")
           "the head line reads 'parked awaiting the plan review' while one is open")
     goal_page = http_get_page(auth, "/goals/%d" % goal)
     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,
     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")
           "an open review renders expanded beside the collapsed approved-plan expander")
 
 
@@ -415,28 +418,79 @@ def run(ctx):
               for e in state["events"]),
               for e in state["events"]),
           "the narrative records the supervision retry (a system row)")
           "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)
     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"),
         ('class="card goal-head"', "status head"),
         ("Agent progress", "progress"),
         ("Agent progress", "progress"),
         ("Goal agent blocks", "attention blocks"),
         ("Goal agent blocks", "attention blocks"),
         ("<h3>Plan</h3>", "plan"),
         ("<h3>Plan</h3>", "plan"),
+        ("<h3>Comments</h3>", "comments"),
+    ]
+    consult_markers = [
         ("<h3>Tasks</h3>", "tasks"),
         ("<h3>Tasks</h3>", "tasks"),
         ("Initial brief", "brief"),
         ("Initial brief", "brief"),
         ("Involved projects", "projects"),
         ("Involved projects", "projects"),
         ("<h3>Rules</h3>", "rules"),
         ("<h3>Rules</h3>", "rules"),
         ("Supervision narrative", "narrative"),
         ("Supervision narrative", "narrative"),
-        ("<h3>Comments</h3>", "comments"),
         ("Goal agent runs", "runs"),
         ("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,
     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)")
           "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(),
     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;
     # Plan 11 task D: the progress feed sits ABOVE the status card;
     # status + description share a 50/50 grid; the plan detail hides
     # status + description share a 50/50 grid; the plan detail hides
     # behind its expander below it.
     # 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"),
           and page.index("<h3>Progress</h3>") < page.index("status-head"),
           "the progress feed renders above the status card")
           "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,
     check("Mandate" in page and "plan-expander" not in page,
           "the task page carries no plan expander (plans live on the goal)")
           "the task page carries no plan expander (plans live on the goal)")
     # Answered blocks fold behind the history expander (this fixture's
     # Answered blocks fold behind the history expander (this fixture's
@@ -448,9 +447,15 @@ def run(ctx):
     check(status == 200
     check(status == 200
           and history_page.index("progress-latest") < history_page.index("progress-history"),
           and history_page.index("progress-latest") < history_page.index("progress-history"),
           "the progress timeline renders newest-first (latest above the 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 ==")
     print("== heredoc completion reports: markdown card + timeline snippet ==")
     # The daemon's `cat << 'EOF' | saddle done` stores a markdown
     # 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)
     status, page = http("GET", "/tasks/%d" % report_done, timeout=15)
     check(status == 200 and "completion-report" in page
     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
     check('stm-markdown="task.summary"' in card
           and 'stm-text="task.report_title"' in card,
           and 'stm-text="task.report_title"' in card,
           "the card renders the summary as markdown under its state-driven heading")
           "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")
           "the lifecycle buttons ride the status card inside the grid")
     status, queue_page = http("GET", "/tasks", timeout=15)
     status, queue_page = http("GET", "/tasks", timeout=15)
     check(status == 200
     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,
           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
     check("New task" in queue_page and "task-mode" in queue_page
           and "task-enqueue-after" in queue_page
           and "task-enqueue-after" in queue_page
           and 'name="plan_auto_approve"' not 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)")
           " (no subtask checkboxes)")
 
 
     print("== plan 12: the web surfaces — pile, timeline, queue, task page ==")
     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)
     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,
     check("Mandate" in task_page_html and 'stm-markdown="task.description"' in task_page_html,
           "the task page frames the description as the mandate")
           "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,
                     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")
     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,
     check(log_state.get("exists") is True and int(log_state["task_id"]) == dash_run,
           "the event log page state names the run task")
           "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")
           "the task comment card shows the body before the quote")
     status, css = http("GET", "/_statum/resource/app.css", timeout=15)
     status, css = http("GET", "/_statum/resource/app.css", timeout=15)
     check(status == 200 and ".footnote" in css
     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),
           and re.search(r"\.comment\s*\{[^}]*font-size:\s*0\.88rem", css),
           "the stylesheet ships the footnote utility and muted comment cards")
           "the stylesheet ships the footnote utility and muted comment cards")
     home_html = http_get_page(auth, "/")
     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 ==")
     print("== list ordering: newest first everywhere ==")
     # The ordering sweep: every table leads with the most recent row.
     # 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)
     status, queue_page = http("GET", "/tasks", timeout=15)
     check(status == 200
     check(status == 200
           and queue_page.index("New task") < queue_page.index("<table"),
           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
     # Comments/blocks threads: newest first since the sweep. The
     # comment list leads with the most recently added one.
     # comment list leads with the most recently added one.
     conn = db()
     conn = db()
@@ -848,13 +870,17 @@ def run(ctx):
           "the mandate card carries the in-place Edit button and editor")
           "the mandate card carries the in-place Edit button and editor")
     check('name="description" rows="8"' in task_page,
     check('name="description" rows="8"' in task_page,
           "the editor's textarea is the wide in-place one")
           "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
     # The PREFILL contract: the editor reads task.description from
     # state at open (JS-applied — stm-attribute.value is a no-op on
     # 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
     # hides them at render), while the rendered mandate view carries
     # no gate at all.
     # no gate at all.
     active_page = http_get_page(auth, "/tasks/%d" % active_task)
     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_end = active_page.find('</section>', card_at)
     card_html = active_page[card_at:card_end]
     card_html = active_page[card_at:card_end]
     toggle_at = card_html.find('data-lf-toggle')
     toggle_at = card_html.find('data-lf-toggle')
@@ -920,13 +948,14 @@ def run(ctx):
         "select name from sqlite_master"
         "select name from sqlite_master"
         " where type = 'index' and tbl_name = 'events'").fetchall()}
         " where type = 'index' and tbl_name = 'events'").fetchall()}
     conn.close()
     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,"
           " (a fresh boot ran M0001 then the M0002 no-op then M0003,"
           " M0004 understandings, M0005 discoveries, M0006 advisor,"
           " M0004 understandings, M0005 discoveries, M0006 advisor,"
           " M0007 the parking guard, M0008 answer-wake, M0009 the"
           " 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)")
           "no duplicate migration rows (each guarded up() applied once)")
     check({"idx_events_scope", "idx_events_terminal"} <= event_indexes,
     check({"idx_events_scope", "idx_events_terminal"} <= event_indexes,
           "M0003's event reader indexes exist (fresh boot)")
           "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
     check(status == 200 and "data-push-test" in home_page
           and "data-bell-on" in home_page and "data-bell-off" 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")
           "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 ==")
     print("== cleanup: unsubscribe the probe fixtures ==")
     act(auth, dash["push_unsubscribe"], {"endpoint": endpoint_a})
     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,
     check(detail["status"] == "draft" and int(detail["project_id"]) == 0,
           "a project_id 0 task creates in draft (project_id %s)" % detail["project_id"])
           "a project_id 0 task creates in draft (project_id %s)" % detail["project_id"])
     page = http_get_page(auth, "/tasks/%d" % standalone)
     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"),
     check("No project (standalone)" in http_get_page(auth, "/tasks"),
           "the create form offers the No project (standalone) option")
           "the create form offers the No project (standalone) option")
     enqueue_task(auth, standalone)
     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",
                  "allocation", "lifecycle", "answers", "answerwake", "ghostworking",
                  "turncappark", "modelconfig",
                  "turncappark", "modelconfig",
                  "push",
                  "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",
                  "discoveries", "knowledge_cycle", "advisor", "chatui",
                  "advisor_arc", "advisor_fix", "advisor_refine", "advisor_utf8",
                  "advisor_arc", "advisor_fix", "advisor_refine", "advisor_utf8",
                  "canon",
                  "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")
                     os.pardir, "src", "pages", "task_log.html")
 SCRIPT = os.path.join(os.path.dirname(os.path.abspath(__file__)),
 SCRIPT = os.path.join(os.path.dirname(os.path.abspath(__file__)),
                       os.pardir, "src", "pages", "task_log.js")
                       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():
 def script_body():
@@ -97,10 +102,14 @@ class ScriptSourceTests(unittest.TestCase):
     def test_more_button_rides_inside_the_feed(self):
     def test_more_button_rides_inside_the_feed(self):
         # The load-older control is the feed's FIRST row - it
         # The load-older control is the feed's FIRST row - it
         # scrolls away with the history instead of sitting above
         # 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]).
         # 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")
         feed = markup.index("data-log-feed")
         more = markup.index("data-log-more", feed)
         more = markup.index("data-log-more", feed)
         close = markup.index("</div>", feed)
         close = markup.index("</div>", feed)

Някои файлове не бяха показани, защото твърде много файлове са промени