Prechádzať zdrojové kódy

Merge branch 'goal/ux-and-bugfixes' of clanker/clanker-saddle into web-stack

Billy Barrow 1 mesiac pred
rodič
commit
5d0be40a9c

+ 631 - 0
docs/ux-notes.md

@@ -0,0 +1,631 @@
+# ClankerSaddle web UI — structural layout critique
+
+This is an opinions document about **structure**, not looks: the theme,
+colours and typography stay as they are. "Structure" here means page
+hierarchy (what leads a page, what folds away), information order,
+navigation between pages, where forms live, and density. Every claim
+below was read out of the templates in `src/pages/` on the goal branch
+(after the SSE and HTML-escaping fixes landed), so the wireframes
+describe the app as it ships today.
+
+## How the app is built (why the recommendations look the way they do)
+
+- Every page is a Statum template compiled by `statum-mkpstm`:
+  `stm-text` / `stm-if` / `stm-for` / `stm-attribute` / `stm-markdown`
+  bind server state, `stm-action` submits sealed actions, and the page
+  refreshes in place. The shared chrome lives in `main.html`: a header
+  nav (Dashboard, Projects, Tasks, Goals, Saddles, Rules, Users, then
+  the user cluster), one `<main>`, a footer.
+- The whole app renders inside a **single 64rem column** (`body {
+  max-width: 64rem }` in `app.css`). There are exactly two layout
+  primitives beyond that: `.cards` (an auto-fill card grid) and
+  `.grid-2` (the task page's 50/50 status/mandate grid, stacking below
+  40rem). No page uses a persistent two-column layout, a sticky
+  sub-nav, or a sidebar.
+- `task_log.html` is the only page whose body is built by client
+  JavaScript (`createElement`/`textContent`, filters, search, paging,
+  SSE). Everything else is server-rendered bindings.
+- `details`/`summary` is the app's only progressive-disclosure device,
+  and it is doing a lot of work it was not meant for (see the
+  "expander fatigue" section).
+
+Two capabilities notes used throughout:
+
+- `stm-attribute` can set **any** attribute (`stm-attribute.href`,
+  `.class`, `.value`, `.data-*` already appear), so a section can be
+  collapsed by binding `hidden` or a `data-collapsed` class to state,
+  without client JS.
+- Client JS is not exotic: `task_log.html`, `tasks.html`
+  (`window.taskRowVisible` + `window.setTaskFilter`) and
+  `goal_detail.html` (`window.goalSetFilter`) already run page scripts
+  that cooperate with statum's render cycle. A "sticky sub-nav that
+  highlights the section in view" is a `scroll` listener plus one
+  class toggle — the pattern already exists on this codebase.
+
+## The five structural themes
+
+1. **Attention is buried under equal-weight sections.** The detail
+   pages are one long scroll of cards that all look equally
+   important. The one thing a human must do (answer a block, review a
+   plan) sits in card 2 or 3 of 11, between the status card and the
+   plan history. Nothing in the page's *shape* says "act here".
+2. **Reference material competes with action.** Rules, capabilities,
+   sessions, briefs, plan revision history — genuinely useful, read
+   maybe once — render at the same weight and in the same column as
+   the action surface, so a page visit for "approve the plan" scrolls
+   past everything else.
+3. **Expander fatigue.** Every create and edit surface hides behind a
+   `<details>` expander: New task, New goal, New project, New rule,
+   New user, New capability, Register saddle, Edit task, Link a
+   project, SSH key, Git identity, Edit rule, answered blocks, plan
+   revisions, progress history. Disclosure is the right instinct for
+   *history*; it is the wrong instinct for *primary actions*, where it
+   inverts affordance (the most important button on the page is the
+   least visible control).
+4. **No "where am I".** No breadcrumbs anywhere. "Back" is a manual
+   anchor each page draws by hand ("← Goals", "All tasks", "All
+   projects"), inconsistently placed (goal page: above the `h2`; task
+   page: a context line under the `h2` carrying project + enqueue
+   chain). Cross-links exist (task→project, task→goal, goal→project)
+   but the reverse links (project→its goals, task→its sessions' logs)
+   are partial, so navigation is a one-way tree.
+5. **Density mismatch.** A five-word status line and a 400-word
+   completion report both get a full-width card in the same column;
+   the tasks table carries eight columns of which three are reference
+   (goal, project, capabilities) that most visits do not need.
+
+---
+
+## Page-by-page
+
+### 1. Home / dashboard (`home.html`)
+
+**What it's for.** The "is anything on fire / does anything need me"
+page: push bell, queue and saddle metrics, pinned needs-a-human
+blocks, active tasks with latest progress, saddle cards, recent
+activity.
+
+**Structural problem.** The page opens with a full-width card whose
+entire content is one button pair ("Enable notifications — be told
+when an agent asks a question…"). Then two metric cards (Queue,
+Saddles). Only *then* comes the section the page exists for: "Needs a
+human". On a fresh install the attention content is below the fold;
+on a busy day the pinned cards — the actionable items — are squeezed
+between metrics above and two more full sections below (Saddles,
+Activity), so comparing a block against the task it came from means
+scrolling past a saddle grid.
+
+**Recommendations.**
+
+- **A. Move "Needs a human" to the top** (S, low risk). Keep the bell
+  but demote it: it is a settings action, not news. It can become a
+  compact strip beside the metric cards, or move to the header's user
+  cluster entirely (arguably where a notification preference belongs).
+- **B. Put the metrics in a right-hand rail next to the attention
+  column** (M, low risk). One `.grid-2`-style row: left 2/3 pinned
+  blocks + active tasks, right 1/3 Queue/Saddles metrics, the bell
+  strip, and "Saddles" as a compact status list rather than cards.
+- **C. Fold Activity under a collapsed expander with a "new since your
+  last visit" count** (S, low risk), or cap it at ~8 rows with "view
+  all" — it is the page's only pure-read section.
+
+```
+BEFORE (linear)                 AFTER (attention-first + rail)
++----------------------------+  +----------------------------+
+| [Enable notifications ...] |  | NEEDS A HUMAN (3)          |
++----------------------------+  |  [block] [block] [block]   |
+| [Queue]      [Saddles]     |  | ACTIVE TASKS               |
++----------------------------+  |  ...table...               |
+| NEEDS A HUMAN (3)          |  +------------+---------------+
+|  [block] [block] [block]   |  | Queue      | bell (compact)|
++----------------------------+  | Saddles    | saddle list   |
+| ACTIVE TASKS               |  +------------+---------------+
+|  ...table...               |  | ACTIVITY (collapsed, 3 new)|
++----------------------------+  +----------------------------+
+| SADDLES (cards)            |
++----------------------------+
+| ACTIVITY (full table)      |
++----------------------------+
+```
+
+**statum notes.** A and C need nothing new. B needs one CSS grid row
+plus the existing card markup; `stm-if` already gates the pinned
+section on `dashboard.pinned.length`.
+
+### 2. Tasks list (`tasks.html`)
+
+**What it's for.** The queue: find a task, judge the queue's shape,
+create a task.
+
+**Structural problems.**
+
+- The **create form** is the page's only primary action and it is a
+  collapsed expander above the table. New users' first act is to hunt
+  for the form.
+- **Eight columns** (`#`, Title, Status, Mode, Goal, Priority,
+  Project, Capabilities), three of which (Goal, Project,
+  Capabilities) are reference attributes. On the 64rem column this is
+  dense, and Priority is usually "5" noise.
+- The three filters (Status, Project, Goal) are bare `<select>`s with
+  an inline hint ("— filters apply instantly") — functional but
+  undiscoverable as *filters*: they read as form fields. They also
+  carry no counts, so you cannot see the queue's shape without
+  filtering.
+- The list has **no server-side limit** (`TaskService.list_tasks`
+  returns every task newest-first) and terminal tasks accumulate
+  forever. Done tasks from months ago share the page with the thing
+  you came for.
+- It duplicates the create form that project detail doesn't have
+  (project detail links back here instead — the reverse direction is
+  a dead end).
+
+**Recommendations.**
+
+- **A. Default the list to "open work"** (S, low risk): a
+  `draft/queued/assigned/working/blocked` default with an explicit
+  "show done/failed (N)" toggle. The filters exist already; this is a
+  default-value change plus a chip.
+- **B. Replace the three selects with faceted status chips + one
+  "Project / Goal" select** (M, low risk, client JS exists): the
+  goal-detail page already has exactly this pattern (a
+  `goal-strip` of status buttons with counts). Port it here and the
+  two pages stop feeling like different apps.
+- **C. Drop Mode / Priority / Capabilities to a per-row hover/expand
+  or merge into one "meta" cell** (S, low risk); keep the row scannable
+  as `# title — status — project`.
+- **D. Give "New task" a real button** (S, low risk) — see the
+  expander-fatigue section: an always-visible `+ New task` button that
+  opens the same form inline.
+
+```
+BEFORE                              AFTER
++------------------------------+    +------------------------------+
+| [New task >]  (collapsed)    |    | Tasks        [+ New task]    |
++------------------------------+    +------------------------------+
+| Status[v] Project[v] Goal[v] |    | (draft 2)(queued 5)(working 3)|
+|  — filters apply instantly   |    | (blocked 1)(done 214)        |
++------------------------------+    | Project/Goal [all v]  search |
+| # | Title | St | Mode | Goal |    +------------------------------+
+| Pri | Proj | Caps             |    | #  Title           Status    |
+| ...8 columns...               |    | 42 Fix the SSE feed working  |
++------------------------------+    | 41 Escape entities  blocked  |
+                                    | ...project folded into title |
+                                    +------------------------------+
+```
+
+**statum notes.** B is the client-side `taskRowVisible` filter with
+counts derived from `tasks.tasks` (already shipped to the client) —
+no server change. A could also be server-side (default status filter
+in the entrypoint) if the "all" count needs to stay cheap.
+
+### 3. Task detail (`task_detail.html`)
+
+**What it's for.** The human's control surface for one task: watch
+progress, answer blocks, read the completion report, comment, tweak
+rules/capabilities, inspect sessions.
+
+**Structural problem.** Thirteen `h3` sections in one 64rem column,
+half of them full-width cards, in this order: Progress → Status
+card / Goal card / Mandate card (the one `.grid-2` on the page) →
+Completion report → *Edit task expander* → Blocks → Comments →
+Rules → Required capabilities → Sessions. The page's actual verb —
+answer the block, approve the review — is the **sixth** thing you
+scroll to, and it sits between an edit expander and the comments
+thread. Meanwhile the Status card (five muted metadata lines plus
+buttons) takes half a screen width while the Mandate — the single
+most useful read — gets the other half at the same visual weight as
+"Created 3 days ago".
+
+**Recommendations.**
+
+- **A. Two zones: "Act" then "Understand"** (M, medium risk). Zone 1
+  (top, full width): Progress line, **Blocks** (open ones first,
+  full weight), Completion report. Zone 2: the status/mandate grid,
+  comments, then a reference cluster.
+- **B. An anchored sub-nav strip under the page title**
+  (`Progress · Blocks · Report · Comments · Rules · Sessions`) that
+  scroll-jumps and highlights — a dozen lines of client JS over
+  `data-section` ids. This is the cheap version of "restructure the
+  page": it keeps the DOM order but gives the reader a map and a
+  one-click path.
+- **C. Cluster the low-traffic reference sections** (Rules, Required
+  capabilities, Sessions) into one "Configuration & history" region —
+  either a single card with internal `h4`s or one collapsed expander
+  with a summary line ("2 rules · 1 capability · 3 sessions").
+- **D. The Sessions table's "Event log" link is the page's only
+  per-session affordance and it is a link in a table row** — surface
+  it once at the top level ("View event log" beside Progress) since
+  it is per-task, not per-session, anyway.
+
+```
+BEFORE (13 sections, one column)   AFTER (Act / Understand / Reference)
++------------------------------+   +------------------------------+
+| Progress                     |   | Progress        [Event log]  |
++------------------------------+   +------------------------------+
+| Status | Goal | Mandate      |   | BLOCKS (1 open)              |
++------------------------------+   |  [question card + answer]    |
+| Completion report            |   +------------------------------+
++------------------------------+   | COMPLETION REPORT            |
+| [Edit task >]                |   +------------------------------+
++------------------------------+   | Status | Mandate    | Goal   |
+| BLOCKS (1 open)   <- the verb|   +------------------------------+
++------------------------------+   | Comments                     |
+| Comments                     |   +------------------------------+
++------------------------------+   | Configuration & history  (2) |
+| Rules                        |   |  rules - caps - sessions     |
++------------------------------+   +------------------------------+
+| Required capabilities        |
++------------------------------+   (sub-nav strip pinned under
+| Sessions                     |    the title: Progress / Blocks /
++------------------------------+    Report / Comments / Config)
+```
+
+**statum notes.** B and C need no new patterns (`stm-if`, anchors,
+existing client-JS style). A is a re-ordering of existing cards; the
+risk is only that regulars re-learn where things are, so ship it with
+the sub-nav.
+
+### 4. Task event log (`task_log.html`)
+
+**What it's for.** The live transcript of one task: turn cards,
+commands, results, comments, transitions, usage — the forensic view
+when something went wrong.
+
+**Structural problems.**
+
+- It is structurally unlike every other page (client-rendered feed in
+  a 72vh scroller), which is *fine for a transcript* — but its
+  toolbar (5 filter buttons + search) sits inside the card above the
+  feed, so it scrolls away. For the page where you spend the most
+  time scrolled deep, the controls should be pinned.
+- "Load older events" is a button *above* the feed — correct, since
+  older history grows upward, but nothing indicates how much history
+  remains or that the feed is a window onto a longer ledger.
+- There is no way back to the task's *blocks/comments* surface except
+  the "Task page" text link at the top — the two pages are one
+  investigation flow split across a page boundary, and the log opens
+  in a **new tab** from the task page, which severs even the back
+  button.
+- The feed and the task page's Progress/Sessions duplicate a job:
+  "what has the agent been doing".
+
+**Recommendations.**
+
+- **A. Sticky toolbar** (S, low risk): `position: sticky; top: 0`
+  on `.log-toolbar` inside the scroller, so filters/search survive
+  deep scrolling. Add a "paused / live" affordance to the
+  bottom-stick behaviour (the feed already knows whether it is
+  pinned; expose it as a "jump to latest" button when not).
+- **B. Context header, not a link farm** (S): the page opens with
+  "← Tasks" then "Event log" then "Task page · the unified event
+  ledger…". Replace with a single header row: task id + title +
+  status badge (the data is already in `task_log`), a back-chevron to
+  the task, and the window note ("showing most recent 30") folded
+  into the load-older control ("Load older events — 412 more").
+- **C. Consider the log a *tab of the task page* rather than a
+  separate URL** (L, medium risk — a decision more than a build):
+  either mount the feed component under a `?view=log` tab on
+  `/tasks/{id}` or keep the URL but add a persistent task-context
+  strip so both pages share their sub-nav. This is the single
+  highest-leverage navigation fix in the app: today the natural
+  investigation loop (task → log → task) costs a tab and a reload.
+
+```
+BEFORE                            AFTER
++----------------------------+    +----------------------------+
+| <- Tasks                   |    | <- Task #42 "Fix SSE" [wg] |
+| Event log                  |    +----------------------------+
+| Task page - unified ledger |    | [cmds][res][cmts][tr][use] |
++----------------------------+    | [search____________] live  |
+| [Show: cmds res cmts ...]  |    +----------------------------+
+| [search]                   |    | ^ Load older (412 more)    |
++----------------------------+    +----------------------------+
+| [Load older events]        |    |  14:02 turn 51             |
++----------------------------+    |  ...feed (sticky filters)  |
+| 14:02 turn 51              |    |                            |
+| ...feed scrolls...         |    |              [jump to end] |
++----------------------------+    +----------------------------+
+```
+
+**statum notes.** A and B are pure client CSS/markup inside the page
+that already owns its rendering. C needs either the feed lifted into
+a fragment included by the task page (partials already exist — see
+`progress_feed.html`, `goal-branches`) or a shared context strip;
+both are proven patterns.
+
+### 5. Goal detail (`goal_detail.html`)
+
+**What it's for.** The supervision surface for one goal: status and
+lifecycle actions, agent progress, **plan review**, **block
+answering**, the task fan-out table, the brief, rules, narrative,
+comments.
+
+**Structural problem.** Eleven sequential cards — Status, Agent
+progress, Goal agent blocks, Completion review, Plan, Tasks, Initial
+brief, Involved projects, Rules, Supervision narrative, Comments,
+(Goal agent runs) — in one column, all at the same weight. The good
+news is the *order* is already attention-first (blocks and the
+pending plan review sit above tasks and brief — the comments in the
+template say so). The problem is *depth and differentiation*: to
+check "has the plan been approved yet?" you scroll past status,
+progress, blocks, and completion review, then find the Plan card
+whose *content* is a collapsed expander ("Approved plan & revisions
+(N)"). The Tasks card embeds a 6-column table with its own
+client-side status-filter strip — a second, different filtering UI on
+the same site (see tasks list). Reference cards (Rules, Involved
+projects) sit between the action surface and Comments, so a
+conversation with the agent is a long scroll from the question you
+are answering.
+
+**Recommendations.**
+
+- **A. Make the page's state legible in one line** (S, low risk):
+  a compact "goal strip" at the top — status badge, the one open
+  item ("1 question waiting · plan revision 2 pending"), and the
+  primary lifecycle button (Start / Pause / Retry). The Status card
+  keeps the detail (branches, compare, saddle, timestamps) but the
+  reader gets the verdict without parsing it.
+- **B. Two-column attention-first layout on wide screens** (M,
+  medium risk): left column = Status, Agent progress, Blocks, Plan
+  review, Completion review, Comments (everything a human acts on);
+  right column = Tasks, Initial brief, Involved projects, Rules,
+  Supervision narrative, Runs (everything a human consults). The
+  single 64rem column is the core reason this page reads as "12
+  cards in a trenchcoat"; splitting the reading column from the
+  acting column fixes it without touching the theme.
+- **C. Sub-nav strip** as per task detail (`Status · Blocks · Plan ·
+  Tasks · Brief · Narrative · Comments`) — pairs well with B, and
+  makes B optional if you want the smaller change first.
+- **D. Unify the tasks-filtering pattern** with the tasks list (the
+  goal page's `goal-strip` is the better UI — port it *up* to
+  /tasks, not the other way).
+
+```
+BEFORE (11 cards, 1 column)        AFTER (act | consult)
++----------------------------+    +--------------+---------------+
+| Status                     |    | ACT          | CONSULT       |
++----------------------------+    | Status (cmpct)| Tasks        |
+| Agent progress             |    | Progress      |  (filter     |
++----------------------------+    | BLOCKS (1)    |   strip)     |
+| Goal agent blocks          |    | Plan review   | Initial brief|
++----------------------------+    | Completion    | Projects     |
+| Completion review          |    | Comments      | Rules        |
++----------------------------+    |               | Narrative    |
+| Plan (usually collapsed)   |    |               | Runs         |
++----------------------------+    +--------------+---------------+
+| Tasks (own filter strip)   |
++----------------------------+    sub-nav: Status / Blocks / Plan /
+| Initial brief              |    Tasks / Brief / Narrative / Comments
++----------------------------+
+| Involved projects          |
++----------------------------+
+| Rules / Narrative /        |
+| Comments / Runs            |
++----------------------------+
+```
+
+**statum notes.** A is `stm-if` + expression text over data the
+entrypoint already publishes (`open_blocks`, `plan_pending_block`).
+B is one CSS grid + card re-parenting; the risk is mobile (stack
+Act-then-Consult, which the existing 40rem breakpoint pattern
+already handles). C is the same client JS as task detail.
+
+### 6. Saddles (`saddles.html`)
+
+**What it's for.** Fleet health and configuration: per-saddle status,
+current work, SSH keys, git identity, capabilities; the capability
+tag catalogue at the bottom.
+
+**Structural problems.**
+
+- The card grid is right for this page, but each card is a long
+  stack of paragraphs (status, work, health line, token, SSH key
+  expander, git identity expander, capability chips, add-capability
+  form, Refresh/Remove buttons). "What is this saddle doing?" — the
+  question you ask daily — is the second of nine blocks, and the
+  admin machinery (keys, identity) crowds it.
+- The **Capabilities section at the bottom is a different modality**
+  (table) bolted under a card grid; it reads like a second page
+  appended to the first.
+- Health is expressed as three separate text fragments (health badge,
+  `daemon state`, `up X`, `contacted Y ago`) instead of one verdict.
+
+**Recommendations.**
+
+- **A. Split each card into a "now" head and an "admin" tail** (S,
+  low risk): head = name, status badge, health verdict (one line),
+  current work; tail (already-expanders, keep them) = key, identity,
+  capabilities, token, actions. Visual anchor: the head is the card's
+  first ~3 lines; everything else folds.
+- **B. Surface "unhealthy" at the top of the page** (S): if any
+  saddle is offline/stale/fenced, one summary line above the grid
+  ("1 of 3 saddles stale — dispatcher may be starved"), linking to
+  the card. The dashboard's idle-count metric does not currently
+  tell you this.
+- **C. Keep the capabilities table but visually separate it** (S) —
+  its own `h2`-level section ("Routing tags") rather than an `h3`
+  after the grid, so the page reads as two deliberate sections.
+
+```
+BEFORE (one 9-block card)          AFTER (now head / admin tail)
++--------------------------+      +--------------------------+
+| saddle-1                 |      | saddle-1        [busy]   |
+| [busy] localhost:7100    |      | healthy - up 3d - 12s ago|
+| Supervising goal: X      |      | Supervising goal X (2h)  |
+|  since 2h                |      +--------------------------+
+| [healthy] daemon idle ...|      | [Keys & admin v]         |
+| API token: ...           |      |   key / identity / caps /|
+| [SSH public key v]       |      |   token / refresh/remove |
+| [Git identity v]         |      +--------------------------+
+| Capabilities: [gpu] [x]  |
+| [Add capability v]       |
+| [Refresh] [Remove]       |
++--------------------------+
+```
+
+**statum notes.** All three are markup re-orders inside existing
+patterns (`stm-if`, `details`, badges); B is an expression over
+`saddles.saddles` the page already iterates.
+
+---
+
+## Shorter notes
+
+### Projects list (`projects.html`) and project detail
+
+- List: table is fine; "New project" is the expander problem again
+  (give it a button). The Git URL column is long noise — truncate
+  with the branch column into one "repo" cell.
+- Detail: "Details" and "Edit" as two side-by-side cards is odd —
+  the Edit card is a form for exactly what Details displays. Make
+  Details the read view with an in-place **Edit** toggle (the
+  `data-long-form` in-place editor pattern from task/goal briefs
+  already exists in `main.html` and is a better fit here), freeing
+  the second column for "Tasks in this project".
+- Project detail has no create-task surface and links out to
+  `/tasks`; with a `?project=` pre-fill that becomes a flow instead
+  of a detour. It also lists tasks but not the project's goals —
+  the reverse cross-link gap.
+
+### Goals list (`goals.html`)
+
+- Same expander-pattern note for "New goal". The brief is a required
+  6-row textarea — the goal's most important field — hiding behind a
+  collapsed summary; fine once expanded, invisible until then.
+- The Tasks column packs four counts into one muted string
+  ("2 draft / 5 queued / 3 running / 14 done") — unreadable; four
+  tiny badges (the `goal-strip` chip pattern again) or just
+  "19 tasks, 3 running" would carry more signal per pixel.
+- No status filter on the list at all, unlike tasks — the same chip
+  strip would fix both consistency and scanning.
+
+### Rules (`rules.html`)
+
+- Each rule is a full-width card with body, then an Edit expander and
+  a bare Delete button *outside* the expander — a destructive
+  action more prominent than the edit one. Invert: Edit as the
+  in-place editor pattern, Delete inside a "danger" expander or
+  behind the existing confirm alone but visually secondary.
+- No list of where a rule is attached (projects/tasks using it) —
+  the page says rules attach on project/task pages but you cannot
+  see the blast radius of a delete from here. A reference list, not
+  a theme change.
+
+### Users (`users.html`) and profile
+
+- Users: admin-only, table plus expander create form — acceptable,
+  lowest traffic in the app. Same "New user" button note applies.
+- Profile: fine structurally; the change-password form could sit in
+  a card for consistency, and this is the natural home for the
+  notification preference currently occupying the dashboard's first
+  card.
+
+### Login
+
+- No structural complaints: inline error handling, focused field,
+  narrow form. Good.
+
+---
+
+## Expander fatigue — the cross-cutting fix
+
+Counted across the app, `<details>` currently serves three distinct
+jobs with one widget:
+
+1. **History folding** (progress history, answered blocks, plan
+   revisions, submitted markdown) — this is the right tool; keep it.
+2. **Primary create forms** (new task, goal, project, rule, user,
+   capability, register saddle) — wrong tool: it hides the page's
+   main action. Replace with a persistent button (`+ New task`)
+   that opens the form inline (a `data-*` toggle + `hidden`, exactly
+   the `data-long-form` / `data-lf-toggle` pattern in `main.html`),
+   or render the form directly on low-traffic pages where there is
+   room (rules, projects).
+3. **Admin/config editing** (SSH key, git identity, edit task, edit
+   rule) — defensible, but prefer the in-place editor (read view +
+   Edit toggle) where the form is short, because it shows the
+   current values in place instead of a cold empty form.
+
+Rule of thumb proposed: **history folds, actions surface.**
+
+## Navigation — the cross-cutting fix
+
+- **Breadcrumbs on detail pages** (S): `Dashboard / Tasks / #42` (or
+  `Goals / #7 🎯`), replacing the hand-drawn "← Goals" / "All tasks"
+  anchors that each page currently invents. One shared partial
+  (`pstm-fragment` already powers `progress-feed`, `goal-branches`)
+  makes it consistent for free.
+- **Reverse links**: project detail lists its tasks but not its
+  goals; task detail's sessions link out to the log but the log links
+  back only to the task. The dashboard's cross-links are the model to
+  copy: pinned blocks deep-link to the exact task or goal, and saddle
+  cards link to the saddle's current work.
+- **The log-in-a-new-tab decision** (see event log, C) is the one
+  navigation break that costs the most; everything else is polish.
+
+## Quick wins (a day or less each)
+
+1. Dashboard re-order: Needs-a-human first, bell compacted (S).
+2. Faceted status chips with counts on /tasks, defaulting to open
+   work (M but a port of existing `goal-strip` code).
+3. Breadcrumb partial across detail pages (S).
+4. `+ New …` buttons replacing create-form expanders (S, per page).
+5. Sticky toolbar + jump-to-latest on the event log (S).
+6. Task detail: cluster Rules + Capabilities + Sessions into one
+   collapsed "Configuration & history" region (S).
+7. Goal page: one-line state strip above the Status card (S).
+8. Saddles: card head/tail split + fleet health summary line (S).
+9. Goals list: readable task-count badges + status chips (S).
+
+## Structural bets (multi-day)
+
+1. **Act / Consult two-column layout** for goal detail (and
+   optionally task detail) — the biggest single improvement to the
+   app's most-used deep pages; needs a responsive fallback.
+2. **Anchor sub-nav** on the two detail pages (Progress · Blocks ·
+   Plan · …) with in-view highlighting — a new (small) client-side
+   pattern, ~a day including polish.
+3. **Task page ↔ event log unification**: shared context strip or a
+   tabbed task page (`Overview | Log`), turning two URLs and a new
+   tab into one investigation flow.
+4. **Saved views / URL state for the tasks list** (filters in the
+   query string, shareable "my blocked tasks" views) — needs the
+   entrypoint to read the query string or a small client state
+   sync; the current client-side filtering deliberately exists
+   because statum's fetch doesn't carry the page query string, so
+   this is the one item that brushes against that limitation.
+5. **Cross-entity "work" stream**: one feed joining task progress,
+   goal narrative and event-log highlights, replacing three
+   different feeds with different row grammars (activity table,
+   narrative card, event log). Large; only worth it if the dashboard
+   stays the daily driver.
+
+## Capability appendix — what needs what
+
+Already possible with today's statum.js patterns (`stm-for`,
+`stm-if`, `stm-attribute`, `stm-markdown`, `details`, `pstm-fragment`
+partials, per-page client JS as in `task_log` / `tasks` /
+`goal_detail`):
+
+- Every quick win above (1–9): chips, breadcrumbs, buttons,
+  sticky toolbar, clustering, strips, head/tail cards.
+- The Act/Consult two-column layouts: pure CSS grid + re-parenting
+  existing cards; `stm-if` gates each card's visibility already.
+- In-place editors replacing expanders: the `data-long-form`
+  machinery in `main.html` is generic — reuse it for project edit
+  and rule edit.
+
+Needs a new (small) pattern or a server change:
+
+- Sub-nav with in-view highlight: a scroll listener + class toggle;
+  no statum change, but it is a new client-side idiom for
+  server-rendered pages (only `task_log` carries this much JS
+  today).
+- URL-state filters / saved views on /tasks: either the entrypoint
+  learns the query string (server change; the template comment in
+  `tasks.html` says the statum entrypoint fetch does not carry it)
+  or the client owns filter state in `location.hash`.
+- Task↔log unification: fragment-include of a client-rendered
+  component inside a server-rendered page, or a shared context
+  strip; both are compositions of existing pieces but need real
+  design care around the SSE lifecycle on tab switches.

+ 11 - 0
meson.build

@@ -263,3 +263,14 @@ test('web-stack', python_test,
     args: [files('test/web_stack_test.py'), clanker_saddle_exe],
     timeout: 3600,
 )
+
+# The event-log page's client JS: static source checks plus a python
+# port of the ingest/ordering/scroll-stick logic. Standalone by
+# design - it must run where the app binary cannot be built (the
+# statum toolchain is a build-time dependency that not every
+# container carries), so it reads src/pages/task_log.html directly
+# instead of driving a live server.
+test('task-log-js', python_test,
+    args: [files('test/test_task_log_js.py')],
+    timeout: 120,
+)

+ 4 - 0
src/pages/app.css

@@ -880,6 +880,10 @@ dialog#quote-comment-dialog blockquote {
 .event-pre.event-stderr {
     color: var(--danger, #cf222e);
 }
+.event-pre.event-command {
+    background: #ddf4ff;
+    font-weight: 600;
+}
 .event-result-body {
     margin-top: 0.3rem;
 }

+ 6 - 6
src/pages/goal_detail.html

@@ -4,7 +4,7 @@
     <pstm-uri>/goals/{goal_id}</pstm-uri>
     <pstm-template>main</pstm-template>
 <script>
-    // The goal page: supervision surface of one goal — status head
+    // 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
@@ -67,7 +67,7 @@
                 window.initQuoteComments({
                     matches: function (node) {
                         // The pending plan body feeds the REMARKS
-                        // popover instead (below) — everything else
+                        // popover instead (below) - everything else
                         // long-form on the goal quote-comments.
                         return node.closest && node.closest('.goal-body') != null
                             && node.closest('[data-plan-body]') == null;
@@ -81,11 +81,11 @@
 
             // The goal's plan-review remarks ride the SHARED
             // engine (main.html's initPlanReviews, plan 16 R3/arch
-            // 15 — the duplicated ~180-line collect-remarks pattern
+            // 15 - the duplicated ~180-line collect-remarks pattern
             // now lives once); only the block resolution is page
             // state.
             // Plan 16 S2: the block for a review container resolves
-            // from the ACTUAL [data-plan-review] element — the pending
+            // 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
@@ -173,7 +173,7 @@
                          recovery for the no-pending-wake stall. -->
                     <button type="button" 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>
+                            stm-confirm="Force-woke this goal's parked agent? It resumes immediately on its saddle - one wake per press.">Force wake</button>
                     <button type="button" class="danger" stm-if="goal.cancel != null"
                             stm-action="goal.cancel"
                             stm-confirm="Cancel this goal? Its tasks are cancelled with it.">Cancel goal</button>
@@ -321,7 +321,7 @@
                     <div stm-for-revision-in="goal.plan_revisions" class="plan-revision">
                         <p><span stm-attribute.class="'badge ' + (revision.approved ? 'badge-done' : (revision.answered ? 'badge-failed' : 'badge-plan'))"
                                  stm-text="revision.approved ? 'approved' : (revision.answered ? 'rejected' : 'pending')">verdict</span>
-                            <span class="muted" stm-text="'Revision #' + revision.id + ' — ' + revision.created_display">when</span></p>
+                            <span class="muted">Revision #<span stm-text="revision.id">n</span> &mdash; <span stm-text="revision.created_display">when</span></span></p>
                         <details>
                             <summary>Submitted markdown</summary>
                             <div class="md rfc-body" stm-markdown="revision.body">body</div>

+ 8 - 8
src/pages/home.html

@@ -114,14 +114,14 @@
             // 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
+            // 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
+                // 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) {
@@ -177,14 +177,14 @@
                     if (help) {
                         if (here) {
                             help.textContent = others > 0
-                                ? '— this browser receives question, RFC, review and waiting-on-drafts alerts; '
+                                ? '\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'
-                                : '— this browser receives question, RFC, review and waiting-on-drafts alerts; "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 = '— you are subscribed on ' + list.length
+                            help.textContent = '\u2014 you are subscribed on ' + list.length
                                 + ' other browser(s); enable here too to be notified in this browser';
                         } else {
-                            help.textContent = '— be told when an agent asks a question, raises an RFC or review';
+                            help.textContent = '\u2014 be told when an agent asks a question, raises an RFC or review';
                         }
                     }
                 });
@@ -215,7 +215,7 @@
                     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
+                    // row) shows Enable - and enabling re-registers the
                     // existing subscription, healing it.
                     if (hereNow) disablePush();
                     else enablePush();
@@ -347,7 +347,7 @@
                             <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"
-                               stm-text="'→ task #' + item.task_id">task</a></td>
+                               >&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

+ 9 - 9
src/pages/main.html

@@ -13,19 +13,19 @@
     <script>
         // Shared page plumbing. showToast: slide-in notifications (used in
         // place of alert). initQuoteComments: registers a container matcher
-        // for the text-highlight -> comment popover — selecting text inside
+        // for the text-highlight -> comment popover - selecting text inside
         // a matched container shows a floating button, then a popover that
         // posts the comment with the quoted text via the action the page
         // supplies.
         // Shared UI engines (parameterised per page):
     //
-    // showToast — every page's toast corner.
+    // showToast - every page's toast corner.
     //
-    // initQuoteComments — the text-selection quote popover + dialog
+    // initQuoteComments - the text-selection quote popover + dialog
     // posting a comment with the quoted text; pages pass a `matches`
     // node predicate and the action accessor.
     //
-    // initPlanReviews (plan 16 R3, arch 15 — the ~180-line pattern
+    // initPlanReviews (plan 16 R3, arch 15 - the ~180-line pattern
     // that lived duplicated between the task and goal pages, now
     // goal-only after the task-page plan review died with the poll
     // era): the plan/completion REVIEW remark queue. "Add remark"
@@ -36,8 +36,8 @@
     // clears on accept. Remark-less rejection is allowed when the
     // feedback textarea carries the substance (plan 16 S2). The
     // rejection body rides `feedback`; the SERVER wraps the per-kind
-    // stable prefix (R4 — templates carry no verdict constants). The
-    // page supplies `resolveBlock(id)` — how a [data-plan-review]
+    // stable prefix (R4 - templates carry no verdict constants). The
+    // page supplies `resolveBlock(id)` - how a [data-plan-review]
     // container's block row resolves from page state.
     window.initPlanReviews = function (config) {
         if (!window.statum || !statum.afterEvaluate) return;
@@ -170,7 +170,7 @@
             var extra = review.querySelector('[data-plan-feedback] textarea[name="feedback"]');
             var extraText = extra && extra.value.trim().length > 0 ? extra.value.trim() : '';
             // Remark-less rejection is allowed when the feedback
-            // textarea carries the review's substance — one of the
+            // textarea carries the review's substance - one of the
             // two must be non-empty (plan 16 S2).
             if (list.length === 0 && extraText.length === 0) {
                 if (window.showToast) window.showToast('Highlight some text and add a remark, or write feedback first', 'error');
@@ -239,7 +239,7 @@
     // plan-remarks pattern) toggles which side shows. Why JS-applied
     // values and not stm-attribute.value: statum's attribute binding
     // sets the CONTENT attribute, which the browser ignores on
-    // <textarea> (its value lives in the text node) — so the textarea
+    // <textarea> (its value lives in the text node) - so the textarea
     // is re-prefilled from the card's data-value payload after every
     // render cycle, while the page state carries the source of truth.
     // The card contract:
@@ -285,7 +285,7 @@
             }
         }
 
-        // Prefill bookkeeping: applied ONCE per open — a statum
+        // Prefill bookkeeping: applied ONCE per open - a statum
         // re-render (pulse refresh, action response) rebinds
         // attributes but never textarea VALUES, so re-applying on
         // every cycle would clobber the user's in-progress typing.

+ 2 - 1
src/pages/saddles.html

@@ -38,7 +38,8 @@
                     <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 + (saddle.uptime_display.length > 0 ? ' · up ' + saddle.uptime_display : '')">daemon</span>
+                              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>

+ 3 - 3
src/pages/task_detail.html

@@ -6,7 +6,7 @@
 <script>
     // Live updates: the page subscribes to the global pulse (see the
     // entrypoint); whenever task or saddle state changes anywhere in
-    // the app, the sealed refresh action rehydrates the task slot —
+    // the app, the sealed refresh action rehydrates the task slot -
     // no fixed-interval polling. RFC bodies get the shared
     // text-highlight -> comment popover (see main.html).
     if (window.statum) {
@@ -29,7 +29,7 @@
             });
             // Belt and braces for SSE starvation: browsers cap ~6
             // connections per origin over HTTP/1.1, and every open
-            // terminal tab holds a /terminal-stream EventSource — when
+            // terminal tab holds a /terminal-stream EventSource - when
             // the budget is exhausted the pulse channel is silently
             // starved. Refreshing on focus recovers without a manual
             // reload.
@@ -118,7 +118,7 @@
                              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>
+                                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>

+ 372 - 59
src/pages/task_log.html

@@ -4,28 +4,85 @@
     <pstm-uri>/tasks/{task_id}/log</pstm-uri>
     <pstm-template>main</pstm-template>
     <script>
-    // The task's event log (plan 17 §3.2): the unified ledger's human
-    // view. The feed is client-side over the plan-17 endpoints — the
-    // SSE stream seeds the most-recent-30 window and streams live
-    // appends, load-more pages backwards through the JSON endpoint in
-    // batches of 30 (the button pins at the top and appears when the
-    // window is scrolled to it while older events remain). Turn cards
-    // group a run's turn_text + tool_call + tool_result events by
-    // payload.turn (per session); results collapse behind a summary
-    // carrying head+tail truncation display and byte counts.
-    // Filters (commands/results/comments/transitions/usage) and the
-    // full-text search run client-side over the loaded window. All
-    // rendering goes through textContent/createElement — event
-    // payloads never touch innerHTML.
+    // The task's event log (plan 17 section 3.2): the unified
+    // ledger's human view. The feed is client-side over the
+    // plan-17 endpoints, and its contract is:
+    //
+    // CHRONOLOGY - rows render OLDEST at the top and NEWEST at
+    // the bottom, like a terminal transcript. The SSE stream
+    // replays the most-recent-30 window oldest-first and then
+    // appends every new row, so live traffic just extends the
+    // tail of the feed.
+    //
+    // SCROLL - the feed ([data-log-feed], max-height 72vh,
+    // overflow-y auto) is scrolled to the bottom on the first
+    // render that carries rows, and afterwards sticks to the
+    // bottom ONLY when the reader was already at (or within
+    // 48px of) the bottom before the render. A reader who
+    // scrolled up into the history is never yanked down.
+    //
+    // PAGING - the load-older control sits above the feed
+    // (older history lives above it) and pages backwards via
+    // GET /tasks/<id>/events?before=<oldest loaded id>&limit=30.
+    // The server serves each page newest-first; the client
+    // reverses it to oldest-first and prepends, restoring the
+    // viewport by the scrollHeight delta so the rows under the
+    // reader's cursor do not move. A page whose `more` flag is
+    // false marks the ledger exhausted and hides the control.
+    //
+    // STREAM - the server's SSE frames carry only id: and
+    // data: lines (no `event:` name), so every frame lands on
+    // `onmessage` and no named listener can ever fire. The
+    // heartbeat frame's data is the literal string "ping";
+    // every other frame is one JSON ledger row, ingested with
+    // the id-dedupe (which also absorbs EventSource's replay
+    // after a reconnect, resent from the last seen id).
+    //
+    // Turn cards group a run's turn_text + tool_call +
+    // tool_result events by payload.turn (per session).
+    //
+    // PAYLOADS - the daemon serialises a tool call's arguments
+    // and a tool result's envelope to a JSON STRING before it
+    // stores them: args is a string like '{"command":"ls"}'
+    // (never an object) and result is a string holding the
+    // {"ok":..,"exit_code":..,"stdout":..,"stderr":..,
+    // "duration_ms":..,"bytes_out":..,"bytes_err":..,
+    // "truncated":..} envelope. Every render path parses these
+    // members through parseMaybeJson first and falls back to
+    // the raw string when it is not JSON - data is never
+    // hidden. The command the agent ran must always be
+    // visible: tool_call renders it as its own line,
+    // command_output prefixes '$ <command>'. Results collapse
+    // behind a summary carrying exit code, byte counts (from
+    // the envelope when present), the truncated flag and
+    // duration. Filters
+    // (commands/results/comments/transitions/usage) and the
+    // full-text search run client-side over the loaded window.
+    // All rendering goes through textContent/createElement -
+    // event payloads never touch innerHTML.
+    //
+    // This whole script body is deliberately ASCII-only
+    // (comments included): the page compiler HTML-escapes
+    // script bodies, so any non-ASCII literal would come back
+    // out of the browser as literal entity text - the entity
+    // name spelled out - instead of the character it names.
+    // Non-ASCII characters are written as JS unicode escapes
+    // (\u00b7 etc).
     if (window.statum) {
         (function () {
             'use strict';
 
+            // How close to the bottom (px) still counts as
+            // "reading the tail" for the stick rule.
+            var STICK_THRESHOLD = 48;
+            var PAGE_SIZE = 30;
+
             var state = {
                 events: [],
                 oldest: 0,
                 exhausted: false,
                 loading: false,
+                seeded: false,
                 filters: { commands: true, results: true, comments: true,
                            transitions: true, usage: true },
                 search: ''
@@ -50,7 +107,9 @@
                 if (!state.filters[filterOf(evt.kind)]) return false;
                 if (state.search.length > 0) {
                     var haystack = evt.kind + ' ' + evt.actor + ' '
-                        + JSON.stringify(evt.payload || {});
+                        + JSON.stringify(evt.payload || {})
+                        + ' ' + strText((evt.payload || {}).args)
+                        + ' ' + strText((evt.payload || {}).result);
                     if (haystack.toLowerCase().indexOf(state.search.toLowerCase()) === -1) {
                         return false;
                     }
@@ -58,10 +117,14 @@
                 return true;
             }
 
+            // state.events is kept oldest-first (ascending id):
+            // insert before the first newer row, skip ids we
+            // already hold (SSE replay after a reconnect sends
+            // the last frame again).
             function ingest(evt) {
-                for (var i = 0; i < state.events.length; i++) {
-                    if (state.events[i].id === evt.id) return;
-                    if (state.events[i].id < evt.id) break;
+                var i = 0;
+                while (i < state.events.length && state.events[i].id < evt.id) {
+                    i++;
                 }
                 if (i < state.events.length && state.events[i].id === evt.id) return;
                 state.events.splice(i, 0, evt);
@@ -70,11 +133,57 @@
                 }
             }
 
-            function payloadBytes(evt, member) {
-                var value = evt.payload && evt.payload[member];
+            // True when the feed is scrolled to (or within
+            // STICK_THRESHOLD of) its bottom - a feed shorter
+            // than its viewport counts as at-bottom.
+            function atBottom() {
+                if (!feed) return true;
+                return feed.scrollHeight - feed.scrollTop - feed.clientHeight
+                    <= STICK_THRESHOLD;
+            }
+
+            // Pin the feed to its bottom edge (the newest row).
+            function scrollToBottom() {
+                if (!feed) return;
+                feed.scrollTop = feed.scrollHeight;
+            }
+
+            // The daemon JSON-encodes tool arguments and tool
+            // result envelopes into STRINGS before storing them;
+            // older/fake pushes send plain objects. Parse the
+            // string forms, pass objects through, and answer
+            // undefined when a string is not JSON (the caller
+            // then shows the raw text so data is never hidden).
+            function parseMaybeJson(value) {
+                if (typeof value !== 'string') return value;
+                try {
+                    return JSON.parse(value);
+                } catch (error) {
+                    return undefined;
+                }
+            }
+
+            // True for a parsed member we can treat as a bag of
+            // properties (the object-shaped push, or a string
+            // that decoded to an object).
+            function isPlainObject(value) {
+                return value !== null && typeof value === 'object'
+                    && !Array.isArray(value);
+            }
+
+            function strText(value) {
+                return typeof value === 'string' ? value : '';
+            }
+
+            function strBytes(value) {
                 return typeof value === 'string' ? value.length : 0;
             }
 
+            function fmtDuration(ms) {
+                if (ms < 1000) return ms + ' ms';
+                return (ms / 1000).toFixed(1) + ' s';
+            }
+
             function fmtBytes(n) {
                 if (n < 1024) return n + ' B';
                 if (n < 1024 * 1024) return (n / 1024).toFixed(1) + ' KiB';
@@ -88,31 +197,106 @@
                 return pre;
             }
 
-            function resultSummary(evt) {
+            // The parsed result envelope: the daemon stores it
+            // as a JSON string ({"ok":..,"exit_code":..,
+            // "stdout":..,"stderr":..,"duration_ms":..,
+            // "bytes_out":..,"bytes_err":..,"truncated":..});
+            // the legacy object-shaped payload is the fallback.
+            function resultEnvelope(evt) {
+                var p = evt.payload || {};
+                var parsed = parseMaybeJson(p.result);
+                if (isPlainObject(parsed)) return parsed;
+                if (isPlainObject(p)) return p;
+                return {};
+            }
+
+            // Command plumbing: exit status, the streams and
+            // their sizes, plus the ledger's own framing keys.
+            // An envelope carrying ONLY these said nothing worth
+            // printing; anything else in it (a task_id,
+            // "recorded", a tasks list) IS the tool's answer
+            // and must be rendered, not swallowed by "(no
+            // output)".
+            var RESULT_PLUMBING = { ok: 1, exit_code: 1, stdout: 1,
+                stderr: 1, bytes_out: 1, bytes_err: 1, truncated: 1,
+                duration_ms: 1, turn: 1, tool: 1 };
+
+            function resultExtras(envelope) {
+                var extras = {};
+                var keys = Object.keys(envelope);
+                for (var i = 0; i < keys.length; i++) {
+                    if (RESULT_PLUMBING[keys[i]] === undefined
+                            && keys[i] !== 'error' && keys[i] !== 'note'
+                            && keys[i] !== 'timeout') {
+                        extras[keys[i]] = envelope[keys[i]];
+                    }
+                }
+                return extras;
+            }
+
+            function resultSummary(evt, envelope) {
                 var p = evt.payload || {};
                 var wrap = document.createElement('summary');
-                var label = document.createTextNode('result');
-                wrap.appendChild(label);
+                wrap.appendChild(document.createTextNode('result'));
                 var bits = [];
-                if (p.exit_code !== undefined && p.exit_code !== null) {
-                    bits.push('exit ' + p.exit_code);
+                if (envelope.exit_code !== undefined
+                        && envelope.exit_code !== null) {
+                    bits.push('exit ' + envelope.exit_code);
+                } else if (envelope.ok === false) {
+                    bits.push('failed');
+                }
+                // Prefer the envelope's own byte counters (they
+                // count the pre-truncation output); fall back to
+                // measuring the strings we hold.
+                var bytes;
+                if (typeof envelope.bytes_out === 'number'
+                        && typeof envelope.bytes_err === 'number') {
+                    bytes = envelope.bytes_out + envelope.bytes_err;
+                } else {
+                    bytes = strBytes(envelope.stdout) + strBytes(envelope.stderr);
                 }
-                var bytes = payloadBytes(evt, 'stdout') + payloadBytes(evt, 'stderr');
                 if (bytes > 0) bits.push(fmtBytes(bytes));
-                if (p.truncated) bits.push('truncated');
+                if (envelope.truncated) bits.push('truncated');
+                if (typeof envelope.duration_ms === 'number') {
+                    bits.push(fmtDuration(envelope.duration_ms));
+                }
                 if (p.turn !== undefined && p.turn !== null) bits.push('turn ' + p.turn);
+                // A result STRING that is not JSON carries no
+                // envelope; its own size is the only metric we
+                // have, and it is about to be rendered raw.
+                if (bits.length === 0 && typeof p.result === 'string'
+                        && !isPlainObject(parseMaybeJson(p.result))) {
+                    bits.push(fmtBytes(p.result.length));
+                }
                 if (bits.length > 0) {
                     wrap.appendChild(document.createTextNode(' (' + bits.join(', ') + ')'));
                 }
                 return wrap;
             }
 
-            function resultBody(evt) {
+            function resultBody(evt, envelope) {
                 var p = evt.payload || {};
                 var div = document.createElement('div');
                 div.className = 'event-result-body';
-                var out = p.stdout || '';
-                var err = p.stderr || '';
+                // A result string that is NOT JSON is the output
+                // text itself (the daemon sends truncated raw
+                // output this way): show it verbatim.
+                var out = '';
+                var err = '';
+                var parsed = parseMaybeJson(p.result);
+                if (isPlainObject(parsed)) {
+                    out = strText(envelope.stdout);
+                    err = strText(envelope.stderr);
+                } else if (typeof parsed === 'string') {
+                    out = parsed;  // a JSON string literal
+                } else if (typeof p.result === 'string') {
+                    out = p.result;
+                } else {
+                    // Legacy object-shaped payload: the members
+                    // sit on the payload itself.
+                    out = strText(envelope.stdout);
+                    err = strText(envelope.stderr);
+                }
                 if (out.length > 0) {
                     div.appendChild(preLine(out));
                 }
@@ -122,14 +306,80 @@
                     div.appendChild(errPre);
                 }
                 if (out.length === 0 && err.length === 0) {
-                    var empty = document.createElement('p');
-                    empty.className = 'muted';
-                    empty.textContent = '(no output)';
-                    div.appendChild(empty);
+                    // Empty stdout AND stderr. An error/note/
+                    // timeout member still said something, and a
+                    // non-run_command tool's answer IS the
+                    // envelope ({"recorded":true}, {"ok":true,
+                    // "task_id":4}, a tasks list) - render it
+                    // instead of claiming silence. Only an
+                    // envelope with nothing left to say is
+                    // "(no output)".
+                    var note = strText(envelope.error) || strText(envelope.note)
+                        || (envelope.timeout ? 'timed out' : '');
+                    if (note.length > 0) {
+                        var notePre = preLine(note);
+                        notePre.className = 'event-pre event-stderr';
+                        div.appendChild(notePre);
+                    } else {
+                        var extras = resultExtras(envelope);
+                        if (Object.keys(extras).length > 0) {
+                            var compact = JSON.stringify(extras);
+                            div.appendChild(preLine(compact.length <= 120
+                                ? compact : JSON.stringify(extras, null, 2)));
+                        } else {
+                            var empty = document.createElement('p');
+                            empty.className = 'muted';
+                            empty.textContent = '(no output)';
+                            div.appendChild(empty);
+                        }
+                    }
                 }
                 return div;
             }
 
+            // The tool call renders the tool name and then the
+            // command (or every parsed argument) as pre lines -
+            // what the agent asked for must be visible at a
+            // glance, not only inside a payload dump.
+            function toolCallBody(p) {
+                var frag = document.createDocumentFragment();
+                var tool = strText(p.tool).length > 0 ? strText(p.tool) : 'tool';
+                var parsed = parseMaybeJson(p.args);
+                var args = isPlainObject(parsed) ? parsed : null;
+                var line = tool;
+                if (args && strText(args.command).length > 0) {
+                    line += ': ' + args.command;
+                }
+                var head = preLine(line);
+                head.className = 'event-pre event-command';
+                frag.appendChild(head);
+                if (args) {
+                    var rest = argsWithout(args, 'command');
+                    if (rest.length > 0) frag.appendChild(preLine(rest));
+                } else if (strText(p.args).length > 0) {
+                    // Args that are not JSON: the daemon sent
+                    // text; never hide it behind a parse failure.
+                    frag.appendChild(preLine(p.args));
+                } else if (p.args !== undefined && p.args !== null
+                        && typeof p.args !== 'string') {
+                    frag.appendChild(preLine(JSON.stringify(p.args, null, 2)));
+                }
+                return frag;
+            }
+
+            // Every argument except `skip`, pretty-printed; ''
+            // when nothing else is left to show.
+            function argsWithout(args, skip) {
+                var rest = {};
+                var keys = Object.keys(args);
+                for (var i = 0; i < keys.length; i++) {
+                    if (keys[i] === skip) continue;
+                    rest[keys[i]] = args[keys[i]];
+                }
+                return Object.keys(rest).length > 0
+                    ? JSON.stringify(rest, null, 2) : '';
+            }
+
             function eventNode(evt) {
                 var row = document.createElement('div');
                 row.className = 'event-row';
@@ -141,7 +391,8 @@
                 head.appendChild(badge);
                 var actor = document.createElement('span');
                 actor.className = 'muted';
-                actor.textContent = ' ' + (evt.actor || '') + ' · ' + (evt.created_display || '');
+                actor.textContent = ' ' + (evt.actor || '') + ' \u00b7 '
+                    + (evt.created_display || '');
                 head.appendChild(actor);
                 row.appendChild(head);
                 var body = document.createElement('div');
@@ -150,18 +401,27 @@
                 if (evt.kind === 'turn_text' && typeof p.text === 'string') {
                     body.appendChild(preLine(p.text));
                 } else if (evt.kind === 'tool_call') {
-                    var args = p.args || {};
-                    var line = p.tool || 'tool';
-                    if (typeof args.command === 'string') line += ': ' + args.command;
-                    body.appendChild(preLine(line));
+                    body.appendChild(toolCallBody(p));
                 } else if (evt.kind === 'tool_result') {
+                    var envelope = resultEnvelope(evt);
                     var details = document.createElement('details');
                     details.className = 'event-result';
-                    details.appendChild(resultSummary(evt));
-                    details.appendChild(resultBody(evt));
+                    details.appendChild(resultSummary(evt, envelope));
+                    details.appendChild(resultBody(evt, envelope));
                     body.appendChild(details);
                 } else if (evt.kind === 'command_output') {
-                    body.appendChild(preLine(p.text || p.chunk || ''));
+                    // The command the agent ran rides the same
+                    // event: prefix it so the output reads as
+                    // '$ cmd ... output' instead of bare text.
+                    var text = p.text || p.chunk || '';
+                    if (strText(p.command).length > 0) {
+                        var cmdPre = preLine('$ ' + p.command);
+                        cmdPre.className = 'event-pre event-command';
+                        body.appendChild(cmdPre);
+                        body.appendChild(preLine(text));
+                    } else {
+                        body.appendChild(preLine(text));
+                    }
                 } else if (evt.kind === 'progress') {
                     var para = document.createElement('p');
                     para.textContent = p.text || '';
@@ -207,7 +467,7 @@
                     bits.push('turn ' + events[0].payload.turn);
                 }
                 if (events[0].session_id) bits.push('session ' + events[0].session_id);
-                head.textContent = bits.length > 0 ? bits.join(' · ') : 'agent turn';
+                head.textContent = bits.length > 0 ? bits.join(' \u00b7 ') : 'agent turn';
                 card.appendChild(head);
                 events.forEach(function (evt) {
                     card.appendChild(eventNode(evt));
@@ -215,15 +475,30 @@
                 return card;
             }
 
-            function render() {
+            // Rebuild the feed from the loaded window. `mode`
+            // is 'append' (live SSE rows landed at the tail -
+            // stick to the bottom only if the reader was
+            // there), 'prepend' (older history was added
+            // above - keep the viewport on the same rows), or
+            // undefined (first render, filter or search change
+            // - bottom is the reading position, so seed there;
+            // afterwards behave like 'append'). Every branch
+            // captures the scroll state before the rebuild and
+            // only moves the viewport per its rule - a reader
+            // scrolled up into the history is never yanked.
+            function render(mode) {
                 if (!feed) return;
+                var wasAtBottom = atBottom();
+                var heightBefore = feed.scrollHeight;
+                var scrollTopBefore = feed.scrollTop;
                 while (feed.firstChild) feed.removeChild(feed.firstChild);
                 var visibleRows = state.events.filter(visible);
                 if (visibleRows.length === 0) {
                     var empty = document.createElement('p');
                     empty.className = 'muted';
                     empty.textContent = state.events.length === 0
-                        ? 'Waiting for the first events…' : 'No events match the filters.';
+                        ? 'Waiting for the first events\u2026'
+                        : 'No events match the filters.';
                     feed.appendChild(empty);
                 }
                 // Grouping: consecutive turn-family events sharing a
@@ -263,7 +538,40 @@
                     moreButton.hidden = state.exhausted;
                     moreButton.disabled = state.loading;
                     moreButton.textContent = state.loading
-                        ? 'Loading…' : 'Load older events';
+                        ? 'Loading\u2026' : 'Load older events';
+                }
+                if (!state.seeded && visibleRows.length > 0) {
+                    // First render carrying rows: newest is the
+                    // reading position, start pinned to it.
+                    state.seeded = true;
+                    scrollToBottom();
+                } else if (mode === 'prepend') {
+                    if (wasAtBottom) {
+                        scrollToBottom();
+                    } else {
+                        // Grow the viewport downward by exactly
+                        // the height added above it, so the
+                        // rows under the cursor stay put.
+                        feed.scrollTop = scrollTopBefore
+                            + (feed.scrollHeight - heightBefore);
+                    }
+                } else if (wasAtBottom) {
+                    scrollToBottom();
+                } else {
+                    feed.scrollTop = scrollTopBefore;
+                }
+            }
+
+            function onStreamMessage(message) {
+                // Heartbeat frames carry the literal string
+                // "ping" as data; named listeners never fire
+                // because the server sends no event: line.
+                if (message.data === 'ping') return;
+                try {
+                    ingest(JSON.parse(message.data));
+                    render('append');
+                } catch (error) {
+                    console.warn('[log] bad event:', error);
                 }
             }
 
@@ -272,16 +580,11 @@
                 if (!log) return;
                 var base = '/tasks/' + log.task_id;
                 stream = new EventSource(base + '/events/stream');
-                stream.addEventListener('append', function (message) {
-                    try {
-                        ingest(JSON.parse(message.data));
-                        render();
-                    } catch (error) {
-                        console.warn('[log] bad event:', error);
-                    }
-                });
+                stream.onmessage = onStreamMessage;
                 stream.onerror = function () {
-                    // EventSource retries on its own (retry: 3s hint).
+                    // EventSource retries on its own (retry: 3s
+                    // hint); the reconnect replays from the
+                    // last seen id and ingest dedupes it.
                 };
                 if (moreButton) {
                     moreButton.addEventListener('click', function () {
@@ -290,19 +593,26 @@
                 }
             }
 
+            // Page one window of history older than the oldest
+            // row we hold. The endpoint serves newest-first
+            // within the page; reverse it so ingest prepends in
+            // chronological order, and keep the viewport put.
             function loadMore(base) {
                 if (state.loading || state.exhausted) return;
                 state.loading = true;
                 render();
                 var url = base + '/events?before=' + (state.oldest || 0)
-                    + '&limit=30';
+                    + '&limit=' + PAGE_SIZE;
                 fetch(url, { credentials: 'same-origin' })
                     .then(function (response) { return response.json(); })
                     .then(function (page) {
-                        (page.events || []).forEach(ingest);
-                        if (!page.more) state.exhausted = true;
+                        var rows = page.events || [];
+                        rows.slice().reverse().forEach(ingest);
+                        if (!page.more || rows.length === 0) {
+                            state.exhausted = true;
+                        }
                         state.loading = false;
-                        render();
+                        render('prepend');
                     })
                     .catch(function (error) {
                         console.warn('[log] paging failed:', error);
@@ -337,7 +647,10 @@
                 feed = document.querySelector('[data-log-feed]');
                 moreButton = document.querySelector('[data-log-more]');
                 var header = document.querySelector('[data-log-title]');
-                if (header) header.textContent = '#' + page.task_id + ' — ' + page.task_title;
+                if (header) {
+                    header.textContent = '#' + page.task_id + ' \u2014 '
+                        + page.task_title;
+                }
                 if (!stream && feed) connect();
                 render();
             });

+ 1 - 1
src/pages/tasks.html

@@ -5,7 +5,7 @@
     <pstm-template>main</pstm-template>
 <script>
     // 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

+ 854 - 0
test/test_task_log_js.py

@@ -0,0 +1,854 @@
+#!/usr/bin/env python3
+"""Static + logic tests for the task event-log page's client JS.
+
+The app binary cannot be built in every container (statum-mkpstm /
+libstatum are build-time dependencies), so this suite verifies
+src/pages/task_log.html two ways:
+
+1. Statically - the <script> body must be ASCII-only (the page
+   compiler HTML-escapes script bodies, so any non-ASCII literal is
+   re-served as literal entity text like "&middot;" after the
+   browser's one decode pass), must wire the SSE stream through
+   `onmessage` (the server sends id:/data: frames with no `event:`
+   name, so named listeners can never fire), must not listen for an
+   'append' event name, and its delimiter structure must balance
+   after strings and comments are stripped.
+
+2. Behaviourally - the ingest / render-order / scroll-stick /
+   load-more logic is ported verbatim below as pure python functions
+   and unit-tested: oldest-first chronology, id dedupe, the 48px
+   bottom-stick rule, prepend-mode scroll anchoring, and the
+   newest-first-page reversal with the `more` -> exhausted flag.
+
+Runnable standalone: python3 test/test_task_log_js.py
+"""
+
+import json
+import os
+import re
+import unittest
+
+PAGE = os.path.join(os.path.dirname(os.path.abspath(__file__)),
+                    os.pardir, "src", "pages", "task_log.html")
+
+
+def script_body():
+    with open(PAGE, encoding="utf-8") as handle:
+        source = handle.read()
+    head, rest = source.split("<script>", 1)
+    body, _ = rest.split("</script>", 1)
+    assert head, "no content before the script block"
+    return body
+
+
+# ---------------------------------------------------------------------------
+# Static checks
+# ---------------------------------------------------------------------------
+
+class ScriptSourceTests(unittest.TestCase):
+    """What the served bytes must look like after compilation."""
+
+    def setUp(self):
+        self.body = script_body()
+
+    def test_script_is_ascii_only(self):
+        """Non-ASCII in a script body comes back as literal entity
+        text ("&middot;") because the compiler escapes script bodies
+        and the browser decodes exactly once. ASCII (with \\uXXXX
+        escapes for the characters we want) is immune."""
+        offenders = [(index, hex(ord(char)))
+                     for index, char in enumerate(self.body) if ord(char) > 127]
+        self.assertEqual(offenders, [])
+
+    def test_unicode_escapes_used_for_separators(self):
+        """The separator characters must still render - as escapes,
+        not as raw bytes and not as HTML entities."""
+        for escape in ("\\u00b7", "\\u2026", "\\u2014"):
+            self.assertIn(escape, self.body)
+        self.assertNotIn("&middot;", self.body)
+        self.assertNotIn("&mdash;", self.body)
+
+    def test_stream_wired_through_onmessage(self):
+        self.assertRegex(self.body, r"stream\.onmessage\s*=")
+        self.assertNotIn("addEventListener('append'", self.body)
+        self.assertNotIn('addEventListener("append"', self.body)
+
+    def test_heartbeat_ping_is_skipped(self):
+        self.assertRegex(self.body, r"message\.data\s*===\s*'ping'")
+
+    def test_load_more_pages_before_oldest(self):
+        self.assertRegex(self.body, r"/events\?before='\s*\+\s*\(state\.oldest")
+
+    def test_page_is_reversed_before_ingest(self):
+        self.assertIn("slice().reverse().forEach(ingest)", self.body)
+
+    def test_exhausted_set_when_no_more(self):
+        self.assertIn("state.exhausted = true", self.body)
+
+    def test_more_button_sits_above_the_feed(self):
+        # Search the page markup only - the script's comments also
+        # mention [data-log-feed], before the elements do.
+        with open(PAGE, encoding="utf-8") as handle:
+            markup = handle.read().split("</script>", 1)[1]
+        more = markup.index("data-log-more")
+        feed = markup.index("data-log-feed")
+        self.assertLess(more, feed,
+                        "the load-older control must precede the feed markup")
+
+    def test_delimiters_balance(self):
+        """A cheap structural stand-in for a JS parser (none is
+        installed here): after stripping comments and string literals
+        every bracket must close."""
+        body = self.body
+        out = []
+        i = 0
+        mode = None  # None | 'line' | 'block' | "'" | '"'
+        while i < len(body):
+            char = body[i]
+            nxt = body[i + 1] if i + 1 < len(body) else ""
+            if mode is None:
+                if char == "/" and nxt == "/":
+                    mode = "line"
+                    i += 2
+                    continue
+                if char == "/" and nxt == "*":
+                    mode = "block"
+                    i += 2
+                    continue
+                if char in "'\"":
+                    mode = char
+                    i += 1
+                    continue
+                out.append(char)
+            elif mode == "line":
+                if char == "\n":
+                    mode = None
+                    out.append("\n")
+            elif mode == "block":
+                if char == "*" and nxt == "/":
+                    mode = None
+                    i += 2
+                    continue
+            else:  # inside a string literal
+                if char == "\\":
+                    i += 2
+                    continue
+                if char == mode:
+                    mode = None
+            i += 1
+        self.assertEqual(mode, None, "unterminated comment or string literal")
+        pairs = {")": "(", "]": "[", "}": "{"}
+        stack = []
+        for char in out:
+            if char in "([{":
+                stack.append(char)
+            elif char in pairs:
+                self.assertTrue(stack and stack[-1] == pairs[char],
+                                "unbalanced %r at depth %s" % (char, stack))
+                stack.pop()
+        self.assertEqual(stack, [], "unclosed delimiters %s" % stack)
+
+
+# ---------------------------------------------------------------------------
+# Port of the client logic (keep in step with task_log.html)
+# ---------------------------------------------------------------------------
+
+STICK_THRESHOLD = 48  # px that still counts as "reading the tail"
+PAGE_SIZE = 30
+
+
+class Feed(object):
+    """Scroll-state stand-in for [data-log-feed]."""
+
+    def __init__(self, scroll_height=0, client_height=600, scroll_top=0):
+        self.scroll_height = scroll_height
+        self.client_height = client_height
+        self.scroll_top = scroll_top
+
+
+def at_bottom(feed):
+    """PORT: a feed shorter than its viewport counts as at-bottom."""
+    return feed.scroll_height - feed.scroll_top - feed.client_height \
+        <= STICK_THRESHOLD
+
+
+class State(object):
+    """PORT: the module-level `state` object."""
+
+    def __init__(self):
+        self.events = []
+        self.oldest = 0
+        self.exhausted = False
+        self.loading = False
+        self.seeded = False
+
+
+def ingest(state, evt):
+    """PORT: keep state.events oldest-first (ascending id); skip ids
+    already held (SSE replay after a reconnect resends a frame)."""
+    i = 0
+    while i < len(state.events) and state.events[i]["id"] < evt["id"]:
+        i += 1
+    if i < len(state.events) and state.events[i]["id"] == evt["id"]:
+        return
+    state.events.insert(i, evt)
+    if evt["id"] < state.oldest or state.oldest == 0:
+        state.oldest = evt["id"]
+
+
+def render(state, feed, mode=None, height_after=None):
+    """PORT of render(mode): decide the post-render scroll position.
+    height_after stands in for the DOM rebuild's new scrollHeight."""
+    was_at_bottom = at_bottom(feed)
+    height_before = feed.scroll_height
+    scroll_top_before = feed.scroll_top
+    if height_after is not None:
+        feed.scroll_height = height_after
+    if not state.seeded and len(state.events) > 0:
+        state.seeded = True
+        feed.scroll_top = feed.scroll_height  # scrollToBottom()
+    elif mode == "prepend":
+        if was_at_bottom:
+            feed.scroll_top = feed.scroll_height
+        else:
+            feed.scroll_top = scroll_top_before + (feed.scroll_height
+                                                   - height_before)
+    elif was_at_bottom:
+        feed.scroll_top = feed.scroll_height
+    else:
+        feed.scroll_top = scroll_top_before
+    return feed.scroll_top
+
+
+def server_page(newest, oldest, more):
+    """The endpoint's wire shape: newest-first within the page."""
+    return {"events": [{"id": i} for i in range(newest, oldest - 1, -1)],
+            "more": more}
+
+
+def load_more(state, feed, page, height_after):
+    """PORT of loadMore's success path: reverse the newest-first page
+    into chronological order, ingest it, set exhausted, render."""
+    rows = page["events"]
+    for evt in reversed(rows):
+        ingest(state, evt)
+    if not page["more"] or len(rows) == 0:
+        state.exhausted = True
+    return render(state, feed, mode="prepend", height_after=height_after)
+
+
+def on_stream_message(state, feed, data, height_after):
+    """PORT of onStreamMessage: skip heartbeats, ingest one row."""
+    if data == "ping":
+        return feed.scroll_top
+    ingest(state, {"id": int(data)})
+    return render(state, feed, mode="append", height_after=height_after)
+
+
+# ---------------------------------------------------------------------------
+# Behaviour tests
+# ---------------------------------------------------------------------------
+
+def ids(state):
+    return [evt["id"] for evt in state.events]
+
+
+class ChronologyTests(unittest.TestCase):
+
+    def test_initial_window_renders_oldest_first(self):
+        """The SSE replay / first JSON page arrives newest-first
+        ([30..1]); the feed must render [1..30] - oldest at the top."""
+        state = State()
+        for evt in server_page(30, 1, True)["events"]:
+            ingest(state, evt)
+        self.assertEqual(ids(state), list(range(1, 31)))
+        self.assertEqual(state.oldest, 1)
+
+    def test_live_appends_land_in_order(self):
+        state = State()
+        for i in range(30, 0, -1):
+            ingest(state, {"id": i})
+        ingest(state, {"id": 31})
+        ingest(state, {"id": 33})
+        ingest(state, {"id": 32})  # out of order arrival
+        self.assertEqual(ids(state)[-3:], [31, 32, 33])
+
+    def test_duplicate_ids_are_dropped(self):
+        state = State()
+        ingest(state, {"id": 5})
+        ingest(state, {"id": 5})  # EventSource reconnect replay
+        self.assertEqual(ids(state), [5])
+
+
+class ScrollStickTests(unittest.TestCase):
+
+    def test_first_render_with_rows_seeds_at_the_bottom(self):
+        state = State()
+        feed = Feed(scroll_height=0, client_height=600)
+        for evt in server_page(30, 1, True)["events"]:
+            ingest(state, evt)
+        render(state, feed, height_after=3000)
+        self.assertTrue(state.seeded)
+        self.assertEqual(feed.scroll_top, 3000)
+
+    def test_append_sticks_when_reading_the_tail(self):
+        state = State()
+        feed = Feed(scroll_height=3000, client_height=600)
+        state.seeded = True
+        feed.scroll_top = 2400  # exactly at the bottom
+        self.assertTrue(at_bottom(feed))
+        ingest(state, {"id": 31})
+        render(state, feed, mode="append", height_after=3100)
+        self.assertEqual(feed.scroll_top, 3100)
+
+    def test_append_within_threshold_still_sticks(self):
+        feed = Feed(scroll_height=3000, client_height=600, scroll_top=2360)
+        self.assertTrue(at_bottom(feed))  # 40px shy: close enough
+
+    def test_just_past_threshold_is_not_the_tail(self):
+        feed = Feed(scroll_height=3000, client_height=600, scroll_top=2351)
+        self.assertFalse(at_bottom(feed))  # 49px shy: reading history
+
+    def test_scrolled_up_reader_is_never_yanked(self):
+        state = State()
+        feed = Feed(scroll_height=3000, client_height=600, scroll_top=500)
+        state.seeded = True
+        self.assertFalse(at_bottom(feed))
+        ingest(state, {"id": 31})
+        render(state, feed, mode="append", height_after=3100)
+        self.assertEqual(feed.scroll_top, 500)
+
+    def test_short_feed_counts_as_at_bottom(self):
+        """A feed shorter than its viewport cannot be scrolled away
+        from the newest row."""
+        feed = Feed(scroll_height=100, client_height=600, scroll_top=0)
+        self.assertTrue(at_bottom(feed))
+
+    def test_heartbeat_does_not_touch_anything(self):
+        state = State()
+        feed = Feed(scroll_height=3000, client_height=600, scroll_top=2400)
+        state.seeded = True
+        before = (list(ids(state)), feed.scroll_top)
+        on_stream_message(state, feed, "ping", height_after=3100)
+        self.assertEqual((list(ids(state)), feed.scroll_top), before)
+
+
+class LoadMoreTests(unittest.TestCase):
+
+    def test_older_page_is_reversed_and_prepended(self):
+        """before=<oldest>=1 returns [60..31] newest-first on the
+        wire; the display must gain [31..60] above [1..30]."""
+        state = State()
+        feed = Feed(scroll_height=3000, client_height=600, scroll_top=500)
+        for evt in server_page(30, 1, True)["events"]:
+            ingest(state, evt)
+        state.seeded = True
+        render(state, feed, height_after=3000)
+        feed.scroll_top = 500  # the reader scrolled up into history
+        load_more(state, feed, server_page(60, 31, True), height_after=6000)
+        self.assertEqual(ids(state), list(range(1, 61)))
+        self.assertEqual(state.oldest, 1)
+        self.assertFalse(state.exhausted)
+
+    def test_prepend_keeps_the_viewport_anchored(self):
+        """Content added above must not move the rows under the
+        reader's cursor: scrollTop grows by the added height."""
+        state = State()
+        feed = Feed(scroll_height=3000, client_height=600, scroll_top=500)
+        state.seeded = True
+        self.assertFalse(at_bottom(feed))
+        delta = 3000
+        load_more(state, feed, server_page(60, 31, True),
+                  height_after=3000 + delta)
+        self.assertEqual(feed.scroll_top, 500 + delta)
+
+    def test_prepend_while_at_bottom_stays_at_bottom(self):
+        state = State()
+        feed = Feed(scroll_height=3000, client_height=600, scroll_top=2400)
+        state.seeded = True
+        self.assertTrue(at_bottom(feed))
+        load_more(state, feed, server_page(60, 31, True), height_after=6000)
+        self.assertEqual(feed.scroll_top, 6000)
+
+    def test_exhausted_when_more_is_false(self):
+        state = State()
+        feed = Feed(scroll_height=3000, client_height=600, scroll_top=2400)
+        state.seeded = True
+        load_more(state, feed, server_page(60, 31, False), height_after=6000)
+        self.assertTrue(state.exhausted)
+
+    def test_empty_page_exhausts_too(self):
+        """A defensive guard: a page with no rows means no older
+        history, whatever the flag says."""
+        state = State()
+        feed = Feed(scroll_height=3000, client_height=600, scroll_top=2400)
+        state.seeded = True
+        ingest(state, {"id": 1})
+        load_more(state, feed, {"events": [], "more": True}, height_after=3000)
+        self.assertTrue(state.exhausted)
+
+
+class EndToEndSequenceTests(unittest.TestCase):
+    """The whole first-minute story: replay, live tail, history."""
+
+    def test_replay_then_append_then_history(self):
+        state = State()
+        feed = Feed(scroll_height=0, client_height=600)
+        # 1. SSE replay: most-recent-30 oldest-first on the wire.
+        for evt in server_page(30, 1, True)["events"]:
+            ingest(state, evt)
+        render(state, feed, height_after=3000)
+        self.assertEqual(ids(state), list(range(1, 31)))
+        self.assertEqual(feed.scroll_top, 3000)  # seeded at the tail
+
+        # 2. Two live rows arrive; the reader is at the bottom.
+        on_stream_message(state, feed, "31", height_after=3100)
+        on_stream_message(state, feed, "32", height_after=3200)
+        self.assertEqual(ids(state)[-2:], [31, 32])
+        self.assertEqual(feed.scroll_top, 3200)
+
+        # 3. A heartbeat is ignored, then the reader scrolls up.
+        on_stream_message(state, feed, "ping", height_after=3200)
+        feed.scroll_top = 100
+        on_stream_message(state, feed, "33", height_after=3300)
+        self.assertEqual(feed.scroll_top, 100)  # never yanked
+
+        # 4. Load older: before=1 -> [60..31] newest-first wire page.
+        self.assertEqual(state.oldest, 1)
+        load_more(state, feed, server_page(60, 31, False), height_after=6300)
+        self.assertEqual(ids(state), list(range(1, 61)))
+        self.assertTrue(state.exhausted)
+        self.assertEqual(feed.scroll_top, 100 + 3000)  # anchored
+
+
+# ---------------------------------------------------------------------------
+# Payload contract: the daemon JSON-encodes args/result into STRINGS
+# ---------------------------------------------------------------------------
+
+def parse_maybe_json(value):
+    """PORT of parseMaybeJson: strings decode, objects pass through,
+    a non-JSON string answers None so the caller can fall back to
+    rendering the raw text."""
+    if not isinstance(value, str):
+        return value
+    try:
+        return json.loads(value)
+    except ValueError:
+        return None
+
+
+def is_plain_object(value):
+    """PORT of isPlainObject."""
+    return isinstance(value, dict)
+
+
+def fmt_bytes(n):
+    """PORT of fmtBytes."""
+    if n < 1024:
+        return "%d B" % n
+    if n < 1024 * 1024:
+        return "%.1f KiB" % (n / 1024)
+    return "%.1f MiB" % (n / (1024 * 1024))
+
+
+def str_text(value):
+    """PORT of strText."""
+    return value if isinstance(value, str) else ""
+
+
+def fmt_duration(ms):
+    """PORT of fmtDuration."""
+    if ms < 1000:
+        return "%d ms" % ms
+    return "%.1f s" % (ms / 1000)
+
+
+def str_bytes(value):
+    """PORT of strBytes."""
+    return len(value) if isinstance(value, str) else 0
+
+
+def result_envelope(evt):
+    """PORT of resultEnvelope: the parsed result string when it
+    decodes to an object, else the object-shaped payload itself."""
+    payload = evt.get("payload") or {}
+    parsed = parse_maybe_json(payload.get("result"))
+    if is_plain_object(parsed):
+        return parsed
+    if is_plain_object(payload):
+        return payload
+    return {}
+
+
+RESULT_PLUMBING = ("ok", "exit_code", "stdout", "stderr", "bytes_out",
+                   "bytes_err", "truncated", "duration_ms", "turn", "tool")
+
+
+def result_extras(envelope):
+    """PORT of resultExtras: the envelope minus command plumbing
+    and minus the note members (rendered separately)."""
+    return dict((key, value) for key, value in envelope.items()
+                if key not in RESULT_PLUMBING
+                and key not in ("error", "note", "timeout"))
+
+
+def result_summary(evt):
+    """PORT of resultSummary -> the summary line's text."""
+    envelope = result_envelope(evt)
+    payload = evt.get("payload") or {}
+    bits = []
+    if envelope.get("exit_code") is not None:
+        bits.append("exit %s" % envelope["exit_code"])
+    elif envelope.get("ok") is False:
+        bits.append("failed")
+    if isinstance(envelope.get("bytes_out"), int) \
+            and isinstance(envelope.get("bytes_err"), int):
+        bytes_count = envelope["bytes_out"] + envelope["bytes_err"]
+    else:
+        bytes_count = str_bytes(envelope.get("stdout")) \
+            + str_bytes(envelope.get("stderr"))
+    if bytes_count > 0:
+        bits.append(fmt_bytes(bytes_count))
+    if envelope.get("truncated"):
+        bits.append("truncated")
+    if isinstance(envelope.get("duration_ms"), int):
+        bits.append(fmt_duration(envelope["duration_ms"]))
+    if payload.get("turn") is not None:
+        bits.append("turn %s" % payload["turn"])
+    # PORT: a non-JSON result string carries no envelope; its own
+    # size is the only metric we have.
+    if not bits and isinstance(payload.get("result"), str) \
+            and not is_plain_object(parse_maybe_json(payload["result"])):
+        bits.append(fmt_bytes(len(payload["result"])))
+    return "result" + (" (" + ", ".join(bits) + ")" if bits else "")
+
+
+def result_output(evt):
+    """PORT of resultBody's text decisions -> (stdout_text,
+    stderr_text, note_text, extras_text, no_output_flag)."""
+    envelope = result_envelope(evt)
+    payload = evt.get("payload") or {}
+    parsed = parse_maybe_json(payload.get("result"))
+    out = err = ""
+    if is_plain_object(parsed):
+        out = str_text(envelope.get("stdout"))
+        err = str_text(envelope.get("stderr"))
+    elif isinstance(parsed, str):
+        out = parsed  # a JSON string literal
+    elif isinstance(payload.get("result"), str):
+        out = payload["result"]
+    else:
+        out = str_text(envelope.get("stdout"))
+        err = str_text(envelope.get("stderr"))
+    note = ""
+    extras = ""
+    if not out and not err:
+        note = str_text(envelope.get("error")) or str_text(envelope.get("note")) \
+            or ("timed out" if envelope.get("timeout") else "")
+        if not note:
+            remainder = result_extras(envelope)
+            if remainder:
+                compact = json.dumps(remainder)
+                extras = compact if len(compact) <= 120 \
+                    else json.dumps(remainder, indent=2)
+    return out, err, note, extras, (not out and not err and not note and not extras)
+
+
+def args_without(args, skip):
+    """PORT of argsWithout: every member except `skip`, pretty-
+    printed; '' when nothing is left."""
+    rest = dict((key, value) for key, value in args.items() if key != skip)
+    return json.dumps(rest, indent=2) if rest else ""
+
+
+def tool_call_render(evt):
+    """PORT of toolCallBody -> the pre lines' texts, in order."""
+    payload = evt.get("payload") or {}
+    tool = str_text(payload.get("tool")) or "tool"
+    parsed = parse_maybe_json(payload.get("args"))
+    args = parsed if is_plain_object(parsed) else None
+    line = tool
+    if args and str_text(args.get("command")):
+        line += ": " + args["command"]
+    lines = [line]
+    if args is not None:
+        rest = args_without(args, "command")
+        if rest:
+            lines.append(rest)
+    elif str_text(payload.get("args")):
+        lines.append(payload["args"])
+    elif payload.get("args") is not None and not isinstance(payload.get("args"), str):
+        lines.append(json.dumps(payload["args"], indent=2))
+    return lines
+
+
+def command_output_render(evt):
+    """PORT of eventNode's command_output branch ->
+    (command_prefix, output_text)."""
+    payload = evt.get("payload") or {}
+    command = str_text(payload.get("command"))
+    text = payload.get("text") or payload.get("chunk") or ""
+    return ("$ " + command if command else ""), text
+
+
+# --- Fixtures: copied from the live ledger (web-db.sqlite, events
+# table) and the test fake's object-shaped pushes - the real wire
+# shapes, inner JSON spelled out as the daemon serialises it. ---
+
+# The daemon's inner JSON, built with json.dumps so the escaping
+# is exactly what the wire carries (a JSON string holding JSON).
+inner_call = json.dumps({"command": "cd /workspace && ls"})
+inner_progress = json.dumps(
+    {"text": "Root-caused the SSE listener; next: the payload parse"})
+inner_enqueue = json.dumps({"task_id": 3})
+inner_result = json.dumps(
+    {"ok": True, "exit_code": 0,
+     "stdout": "find /workspace/task-1 -maxdepth 2",
+     "stderr": "", "duration_ms": 11,
+     "bytes_out": 6042, "bytes_err": 0, "truncated": True})
+inner_error = json.dumps(
+    {"ok": False,
+     "error": "suspend refused: 3 draft task(s) were never enqueued"})
+inner_empty = json.dumps({"ok": True, "exit_code": 0,
+                          "stdout": "", "stderr": ""})
+
+FIXTURE_RUN_COMMAND_CALL = json.loads(
+    '{"turn": 52, "tool": "run_command", "args": ' + json.dumps(inner_call) + '}')
+
+FIXTURE_UPDATE_PROGRESS_CALL = json.loads(
+    '{"turn": 6, "tool": "update_progress", "args": '
+    + json.dumps(inner_progress) + '}')
+
+FIXTURE_ENQUEUE_CALL = json.loads(
+    '{"turn": 5, "tool": "enqueue_task", "args": '
+    + json.dumps(inner_enqueue) + '}')
+
+FIXTURE_RUN_COMMAND_RESULT = json.loads(
+    '{"tool": "run_command", "result": ' + json.dumps(inner_result)
+    + ', "turn": 52}')
+
+FIXTURE_ERROR_RESULT = json.loads(
+    '{"tool": "suspend", "result": ' + json.dumps(inner_error) + '}')
+
+FIXTURE_EMPTY_RESULT = json.loads(
+    '{"tool": "run_command", "result": ' + json.dumps(inner_empty) + '}')
+
+# Not valid JSON: the live daemon emitted result strings that are
+# truncated raw output (72 rows in the production ledger).
+FIXTURE_NOT_JSON_RESULT = {
+    "tool": "run_command",
+    "result": '{"ok":true,"exit_code":0,"stdout":"=== Spry ===\n'
+              'MANIFEST.usm  README.md  meson.build',
+}
+
+# command_output: the harness fake's shape; `command` is a member.
+FIXTURE_COMMAND_OUTPUT = {"command": "meson test -C builddir",
+                          "text": "nobody:/task-1$ done\r\n"}
+
+# The harness fake's legacy object-shaped payloads (plan 17's own
+# tests push these): args/result as real objects.
+FIXTURE_OBJECT_CALL = {"turn": 1, "tool": "run_command",
+                       "args": {"command": "echo hello"}}
+FIXTURE_OBJECT_RESULT = {"turn": 1, "tool": "run_command",
+                         "stdout": "hello\n", "exit_code": 0}
+
+
+class PayloadParserTests(unittest.TestCase):
+
+    def test_string_args_decode_to_an_object(self):
+        parsed = parse_maybe_json(FIXTURE_RUN_COMMAND_CALL["args"])
+        self.assertEqual(parsed, {"command": "cd /workspace && ls"})
+
+    def test_non_json_string_answers_none(self):
+        self.assertIsNone(parse_maybe_json(
+            FIXTURE_NOT_JSON_RESULT["result"]))
+
+    def test_objects_pass_through(self):
+        self.assertEqual(parse_maybe_json({"command": "ls"}), {"command": "ls"})
+        self.assertEqual(parse_maybe_json(None), None)
+
+
+class ToolCallRenderTests(unittest.TestCase):
+    """The command the agent ran must surface in EVERY render path."""
+
+    def evt(self, payload):
+        return {"kind": "tool_call", "payload": payload}
+
+    def test_run_command_call_surfaces_the_command(self):
+        lines = tool_call_render(self.evt(FIXTURE_RUN_COMMAND_CALL))
+        self.assertEqual(lines[0], "run_command: cd /workspace && ls")
+
+    def test_update_progress_call_renders_its_args(self):
+        """Non-run_command tools must show their args, not just the
+        tool name (the old renderer showed only the name)."""
+        lines = tool_call_render(self.evt(FIXTURE_UPDATE_PROGRESS_CALL))
+        self.assertEqual(lines[0], "update_progress")
+        self.assertEqual(len(lines), 2)
+        self.assertIn("Root-caused the SSE listener", lines[1])
+        self.assertIn('"text"', lines[1])
+
+    def test_enqueue_call_renders_the_id(self):
+        lines = tool_call_render(self.evt(FIXTURE_ENQUEUE_CALL))
+        self.assertEqual(lines[0], "enqueue_task")
+        self.assertIn('"task_id": 3', lines[1])
+
+    def test_object_shaped_call_still_works(self):
+        lines = tool_call_render(self.evt(FIXTURE_OBJECT_CALL))
+        self.assertEqual(lines[0], "run_command: echo hello")
+        self.assertEqual(len(lines), 1)
+
+    def test_non_json_args_render_raw(self):
+        lines = tool_call_render(self.evt(
+            {"tool": "run_command", "args": "not json at all"}))
+        self.assertEqual(lines, ["run_command", "not json at all"])
+
+    def test_no_args_at_all_renders_the_tool_name(self):
+        lines = tool_call_render(self.evt({"tool": "list_tasks"}))
+        self.assertEqual(lines, ["list_tasks"])
+
+
+class ToolResultRenderTests(unittest.TestCase):
+
+    def evt(self, payload):
+        return {"kind": "tool_result", "payload": payload}
+
+    def test_summary_comes_from_the_envelope(self):
+        line = result_summary(self.evt(FIXTURE_RUN_COMMAND_RESULT))
+        self.assertEqual(line,
+                         "result (exit 0, 5.9 KiB, truncated, 11 ms, turn 52)")
+
+    def test_summary_marks_failed_envelopes(self):
+        line = result_summary(self.evt(
+            {"tool": "run_command", "result": '{"ok":false,"error":"x"}'}))
+        self.assertEqual(line, "result (failed)")
+
+    def test_body_shows_the_envelopes_stdout(self):
+        out, err, note, extras, no_output = result_output(
+            self.evt(FIXTURE_RUN_COMMAND_RESULT))
+        self.assertEqual(out, "find /workspace/task-1 -maxdepth 2")
+        self.assertEqual(err, "")
+        self.assertFalse(no_output)
+
+    def test_non_json_result_renders_as_output_text(self):
+        out, err, note, extras, no_output = result_output(
+            self.evt(FIXTURE_NOT_JSON_RESULT))
+        self.assertIn("=== Spry ===", out)
+        self.assertFalse(no_output,
+                         "raw output must never be called (no output)")
+
+    def test_error_envelope_is_not_no_output(self):
+        out, err, note, extras, no_output = result_output(
+            self.evt(FIXTURE_ERROR_RESULT))
+        self.assertEqual((out, err), ("", ""))
+        self.assertIn("suspend refused", note)
+        self.assertFalse(no_output,
+                         "an error member means the result said something")
+
+    def test_genuinely_empty_result_says_no_output(self):
+        out, err, note, extras, no_output = result_output(self.evt(FIXTURE_EMPTY_RESULT))
+        self.assertEqual((out, err, note, extras), ("", "", "", ""))
+        self.assertTrue(no_output)
+
+    def test_object_shaped_result_still_works(self):
+        line = result_summary(self.evt(FIXTURE_OBJECT_RESULT))
+        self.assertEqual(line, "result (exit 0, 6 B, turn 1)")
+        out, err, note, extras, no_output = result_output(self.evt(FIXTURE_OBJECT_RESULT))
+        self.assertEqual(out, "hello\n")
+
+
+    def test_tool_answer_envelope_is_rendered_not_swallowed(self):
+        """The live ledger's update_progress/create_task/finish
+        results carry {"recorded":true} / {"ok":true,"task_id":4}
+        / {"accepted":true} with empty streams: that IS the tool's
+        answer and must render, not "(no output)"."""
+        for inner in ('{"recorded":true}',
+                      '{"ok":true,"task_id":4,"status":"draft"}',
+                      '{"accepted":true}',
+                      '{"suspended":true}',
+                      '{"ok":true,"status":"pending"}'):
+            out, err, note, extras, no_output = result_output(self.evt(
+                {"tool": "update_progress", "result": inner}))
+            self.assertFalse(no_output, inner)
+            self.assertEqual((out, err, note), ("", "", ""))
+            self.assertTrue(extras, inner)
+
+    def test_list_tasks_answer_renders_as_json(self):
+        inner = json.dumps({"ok": True, "tasks": [
+            {"id": 6, "title": "UX notes", "status": "queued"},
+            {"id": 5, "title": "Event log parse", "status": "working"}]})
+        out, err, note, extras, no_output = result_output(self.evt(
+            {"tool": "list_tasks", "result": inner}))
+        self.assertFalse(no_output)
+        self.assertIn('"tasks"', extras)
+        self.assertIn("UX notes", extras)
+
+    def test_run_command_with_empty_streams_is_still_no_output(self):
+        """Only command plumbing present: the run genuinely said
+        nothing - keep the honest "(no output)"."""
+        out, err, note, extras, no_output = result_output(self.evt(
+            {"tool": "run_command",
+             "result": '{"ok":true,"exit_code":0,"stdout":"",'
+                       '"stderr":"","duration_ms":5,"bytes_out":0,'
+                       '"bytes_err":0,"truncated":false}'}))
+        self.assertTrue(no_output)
+        self.assertEqual(extras, "")
+
+
+
+class CommandOutputRenderTests(unittest.TestCase):
+
+    def test_command_is_prefixed_as_a_shell_line(self):
+        command, text = command_output_render(
+            {"kind": "command_output", "payload": FIXTURE_COMMAND_OUTPUT})
+        self.assertEqual(command, "$ meson test -C builddir")
+        self.assertEqual(text, "nobody:/task-1$ done\r\n")
+
+    def test_text_only_chunk_keeps_the_old_render(self):
+        command, text = command_output_render(
+            {"kind": "command_output", "payload": {"text": "hello\r\n"}})
+        self.assertEqual(command, "")
+        self.assertEqual(text, "hello\r\n")
+
+
+class SearchHaystackTests(unittest.TestCase):
+    """A search for text inside the STRING-encoded args/result must
+    still find the event: the haystack appends the raw strings."""
+
+    def test_search_matches_through_the_string_encoding(self):
+        payload = FIXTURE_RUN_COMMAND_CALL
+        haystack = "tool_call daemon " + json.dumps(payload) + " " \
+            + str_text(payload.get("args")) + " " \
+            + str_text(payload.get("result"))
+        self.assertIn("cd /workspace && ls", haystack)
+
+
+class RendererSourceTests(unittest.TestCase):
+    """The shipped script itself (no JS engine in this container, so
+    the render decisions are pinned by the ports above plus these
+    shape checks on the served source)."""
+
+    def setUp(self):
+        self.body = script_body()
+
+    def test_parse_maybe_json_is_defined_and_used(self):
+        self.assertIn("function parseMaybeJson(value) {", self.body)
+        self.assertGreaterEqual(self.body.count("parseMaybeJson("), 4)
+
+    def test_no_innerhtml_anywhere(self):
+        self.assertNotIn(".innerHTML", self.body)
+
+    def test_command_output_prefixes_the_dollar(self):
+        self.assertIn("'$ ' + p.command", self.body)
+
+    def test_tool_call_line_uses_parsed_args(self):
+        self.assertIn("args.command", self.body)
+        self.assertIn("toolCallBody", self.body)
+
+    def test_result_reads_parsed_envelope(self):
+        self.assertIn("resultEnvelope", self.body)
+        self.assertIn("bytes_out", self.body)
+        self.assertIn("duration_ms", self.body)
+
+
+if __name__ == "__main__":
+    unittest.main(verbosity=2)

+ 91 - 0
test/test_template_ascii.py

@@ -0,0 +1,91 @@
+#!/usr/bin/env python3
+"""Template hygiene: stm-* expressions and <script> bodies must be pure ASCII.
+
+Why this invariant exists (verified against the live compiled app):
+
+  statum-mkpstm HTML-escapes the entire compiled page - including
+  <script> bodies and stm-* attribute EXPRESSION values - converting
+  non-ASCII characters to named entities (&mdash;, &middot;, ...).
+
+  * A <script> body is raw text in HTML: the browser never entity-decodes
+    it. An entity landing there reaches JavaScript source verbatim and
+    renders literally ("&mdash;" as text, or worse, a syntax error).
+
+  * An stm-* attribute value IS entity-decoded by the parser - but the
+    decoded ENTITY TEXT ("&mdash;", 7 characters) is what lands in the
+    expression string, not an em dash. It evaluates and renders
+    literally.
+
+  Plain element body text is unaffected (the browser decodes it once,
+  correctly), so non-ASCII and entities in body content are fine and are
+  deliberately NOT checked here.
+
+The fix is app-side (the compiler is external): keep every stm-*
+expression and every <script> body pure ASCII. Inside expressions and JS
+string literals, use JS unicode escapes ('\u2014', '\u00b7', '\u2026',
+'\u2192') to keep the exact glyph; in prose-only strings (stm-confirm
+reaches window.confirm verbatim) prefer ASCII punctuation. This test
+makes that rule permanent.
+
+Standalone: python3 test/test_template_ascii.py
+"""
+
+import os
+import re
+import sys
+import unittest
+
+PAGES_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)),
+                         "..", "src", "pages")
+
+# stm-* attributes carry Statum expression/JS values (stm-text, stm-if,
+# stm-attribute.href, stm-action, stm-confirm, stm-values, ...). All of
+# them are treated as JS or passed through raw, so all must be ASCII.
+STM_ATTR_RE = re.compile(r'\b(stm-[A-Za-z0-9_-]+)\s*=\s*"([^"]*)"')
+# <script> without a pstm-res-src (that form has no body).
+SCRIPT_RE = re.compile(r'<script\b[^>]*>(.*?)</script>', re.S)
+
+
+def non_ascii(text):
+    """[(offset, char)] for every non-ASCII character in text."""
+    return [(i, c) for i, c in enumerate(text) if ord(c) >= 128]
+
+
+def extract_violations(path):
+    """[(kind, detail)] for every non-ASCII character in an stm-*
+    attribute value or a <script> body of one template."""
+    with open(path, encoding="utf-8") as fh:
+        src = fh.read()
+    out = []
+    for m in STM_ATTR_RE.finditer(src):
+        for off, ch in non_ascii(m.group(2)):
+            line = src.count("\n", 0, m.start() + off) + 1
+            out.append(("stm attribute %s" % m.group(1),
+                        "line %d: U+%04X %r" % (line, ord(ch), ch)))
+    for m in SCRIPT_RE.finditer(src):
+        for off, ch in non_ascii(m.group(1)):
+            line = src.count("\n", 0, m.start() + off) + 1
+            out.append(("script body",
+                        "line %d: U+%04X %r" % (line, ord(ch), ch)))
+    return out
+
+
+class TemplateAsciiTest(unittest.TestCase):
+    def test_stm_expressions_and_scripts_are_ascii(self):
+        pages = sorted(f for f in os.listdir(PAGES_DIR) if f.endswith(".html"))
+        self.assertTrue(pages, "no templates found in src/pages")
+        failures = []
+        for name in pages:
+            for kind, detail in extract_violations(os.path.join(PAGES_DIR, name)):
+                failures.append("%s (%s): %s" % (name, kind, detail))
+        self.assertEqual(
+            failures, [],
+            "stm-* expressions and <script> bodies must be pure ASCII "
+            "(statum-mkpstm entity-encodes them; the browser never decodes "
+            "scripts, and decoded entity text renders literally in "
+            "expressions). Use \\uXXXX escapes or ASCII punctuation.\n"
+            + "\n".join(failures))
+
+
+if __name__ == "__main__":
+    unittest.main(verbosity=2)