Selaa lähdekoodia

Goal detail: state strip, anchor sub-nav, Act|Consult columns

Goal 41 T6 (quick win 7 + bets 1-2 half). The goal page now leads
with a one-line state strip (status badge, the open-item verdict
computed from open_blocks / plan_pending_block / completion_pending,
and the primary lifecycle button matching the current state), then a
sticky anchor sub-nav (Status / Blocks / Plan / Tasks / Brief /
Narrative / Comments) that rebinds its highlight across statum
re-renders, then an Act|Consult two-column layout on spry's
.with-sidebar: the acting surfaces (status head with the full ladder,
agent progress, attention blocks, completion review, plan review,
comments) in the wide left column, the consulting ones (tasks with
the filter strip, brief, involved projects, rules, narrative, runs)
in the bounded right rail, stacking Act-then-Consult under the
framework's 55rem breakpoint.

The hand-drawn back anchor is replaced by the shared breadcrumb
partial (Dashboard / Goals / #id) fed by a new crumbs vector on the
goal slot. The quote-comment dialog, the shared initPlanReviews
engine and the narrative footnote weights are untouched. The
two-column highlight picks the section closest to the fold line
(ties to the later DOM node) because single-axis last-crossed logic
misreads a two-column grid.

app.css gains one marked section at the end (strip, sub-nav,
column proportions, anchor scroll-margin). goalui.py's page-order
assertions are rewritten feature-level for the two-column contract
(per-column order, act/consult containment, strip verdict fields,
subnav rebound, crumb trail); goals.py's goal-head class check is
prefix-anchored. No coverage deleted.

Verified: goalui 140/0, goals 191/0, forcewake 28/0, resurrect 37/0,
canon_deploy 42/0 (after meson setup builddir), full suite 1818 pass
/ 3 fail where the 3 were the canon_deploy builddir gotcha.
saddle-ClankerSaddle 2 päivää sitten
vanhempi
sitoutus
4cb6e14f0b

+ 25 - 0
src/entrypoints/GoalDetailEntrypoint.vala

@@ -125,6 +125,16 @@ namespace ClankerSaddle {
         public bool pushed { get; set; default = false; }
     }
 
+    /**
+     * One breadcrumb crumb (goal 41 T6): Dashboard → Goals → #<id>
+     * — the shared partial's shape ({@code href} null on the leaf,
+     * which the partial turns into the aria-current span).
+     */
+    public class GoalCrumbView : Object {
+        public string? href { get; set; default = null; }
+        public string label { get; set; default = ""; }
+    }
+
     /** A project option chip (the goal link form's select). */
     public class GoalProjectOptionView : Object {
         public int64 id { get; set; }
@@ -284,6 +294,7 @@ namespace ClankerSaddle {
         public Vector<GoalCommentView> comments { get; set; default = new Vector<GoalCommentView>(); }
         public Vector<GoalRunView> runs { get; set; default = new Vector<GoalRunView>(); }
         public Vector<GoalPushBranchView> push_branches { get; set; default = new Vector<GoalPushBranchView>(); }
+        public Vector<GoalCrumbView> crumbs { get; set; default = new Vector<GoalCrumbView>(); }
         public Vector<GoalProgressView> progress { get; set; default = new Vector<GoalProgressView>(); }
     }
 
@@ -313,6 +324,18 @@ namespace ClankerSaddle {
             var goal = yield ctx.goals.get_goal(goal_id);
             if (goal != null) {
                 view = yield full_view((!)goal, ctx);
+                // Goal 41 T6: the breadcrumb trail (the shared partial
+                // renders it; the leaf carries no href and reads
+                // aria-current from the partial).
+                view.crumbs.add(new GoalCrumbView() {
+                    href = "/", label = "Dashboard"
+                });
+                view.crumbs.add(new GoalCrumbView() {
+                    href = "/goals", label = "Goals"
+                });
+                view.crumbs.add(new GoalCrumbView() {
+                    label = @"#$(goal_id)"
+                });
                 foreach (var block in view.blocks) {
                     if (block.is_plan_review && plan_pending == null) {
                         plan_pending = block;
@@ -400,6 +423,8 @@ namespace ClankerSaddle {
                 view.progress.to_immutable_buffer());
             pub_root["push_branches"] = StateJson.list<GoalPushBranchView>(
                 view.push_branches.to_immutable_buffer());
+            pub_root["crumbs"] = StateJson.list<GoalCrumbView>(
+                view.crumbs.to_immutable_buffer());
             pub_root["plan_revisions"] = StateJson.list<GoalBlockView>(
                 plan_revisions.to_immutable_buffer());
             pub_root["rules"] = StateJson.list<GoalRuleView>(

+ 80 - 0
src/pages/app.css

@@ -1387,3 +1387,83 @@ td .truncate.repo-url {
     display: block;
     max-width: 22rem;
 }
+
+/* ==========================================================================
+   Goal 41 T6 — goal detail: state strip + sub-nav + Act|Consult
+   (goal_detail.html only; appended per the spry conversion contract;
+   every colour/spacing value consumes a --spry-* token).
+   ========================================================================== */
+
+/* The one-line state strip (ux-notes §5 A): status badge, the
+   open-item verdict, the primary lifecycle button — one glance
+   before anything else on the page. A quiet accent-tinted panel so
+   it reads as the page's verdict line, not another card. */
+.goal-state-strip {
+    align-items: center;
+    background: var(--spry-accent-soft);
+    border: var(--spry-border-width) solid var(--spry-accent);
+    border-left: 4px solid var(--spry-accent);
+    border-radius: var(--spry-radius);
+    display: flex;
+    flex-wrap: wrap;
+    gap: var(--spry-space-2) var(--spry-space-3);
+    margin-bottom: var(--spry-space-3);
+    padding: var(--spry-space-2) var(--spry-space-3);
+}
+.goal-state-verdict { flex: 1 1 12rem; min-width: 0; }
+/* The stacked pieces of a compound verdict ("plan revision pending"
+   + "1 question waiting") read with a middot, like the status head. */
+.goal-state-verdict > span + span::before { content: ' · '; }
+
+/* The anchor sub-nav (ux-notes §5 C): a horizontal strip of in-page
+   links, sticky UNDER the app topbar (the topbar itself is
+   position: sticky; top: 0; z-index: 20 in spry — this rides at 19,
+   one step below it). The active link reads as the current section
+   through the accent underline, not a filled pill. */
+.goal-subnav {
+    background: var(--spry-surface);
+    border-bottom: var(--spry-border-width) solid var(--spry-border);
+    display: flex;
+    flex-wrap: wrap;
+    gap: var(--spry-space-1) var(--spry-space-3);
+    margin-bottom: var(--spry-space-3);
+    padding: var(--spry-space-1) 0;
+    position: sticky;
+    top: var(--goal-subnav-top, 3rem);
+    z-index: 19;
+}
+.goal-subnav a {
+    color: var(--spry-fg-muted);
+    font-size: 0.9rem;
+    padding: var(--spry-space-1) 0;
+    text-decoration: none;
+}
+.goal-subnav a:hover { color: var(--spry-accent); }
+.goal-subnav a.active {
+    border-bottom: 2px solid var(--spry-accent);
+    color: var(--spry-fg);
+}
+/* Anchored sections clear the sticky topbar + subnav when jumped to
+   (the sub-nav click handler scrolls with scrollIntoView). */
+.goal-section { scroll-margin-top: calc(var(--goal-subnav-top, 3rem) + 2.75rem); }
+
+/* Act|Consult (ux-notes §5 B): spry's .with-sidebar defaults to a
+   narrow 11–16rem FIRST column; the ACTING column is the wide one,
+   so flip the proportions (act takes the flexible track, consult a
+   bounded rail — reference tables and prose read fine at 28rem+). */
+.goal-columns {
+    grid-template-columns: minmax(0, 1fr) minmax(19rem, 28rem);
+}
+/* app.css loads after spry.css, so the re-proportioned columns above
+   would out-rank the framework's own 55rem stacking rule inside its
+   @media block (media queries add no specificity) — the dashboard
+   section hits the same cascade. Restore the stack here so ACT
+   renders fully above CONSULT on narrow screens (ux-notes: mobile
+   stacks Act-then-Consult). */
+@media (max-width: 55rem) {
+    .goal-columns { grid-template-columns: minmax(0, 1fr); }
+    /* The sub-nav sticks to the viewport top only while the topbar
+       is short; on wrapped layouts the double-stick jumps, so let
+       the strip scroll with the page under the breakpoint. */
+    .goal-subnav { position: static; }
+}

+ 239 - 85
src/pages/goal_detail.html

@@ -4,13 +4,18 @@
     <pstm-uri>/goals/{goal_id}</pstm-uri>
     <pstm-template>main</pstm-template>
 <script>
-    // The goal page: supervision surface of one goal - status head
-    // with lifecycle actions (plus run-control parity: retry
-    // supervision, requeue-to-resume), branch/push-state chips, one
-    // muted activity line, the agent's live progress feed, the ATTENTION
-    // blocks (the goal agent's answerable questions/RFCs and the
-    // completion review), the task status strip with per-row
-    // capability badges, the locked brief, the goal's effective
+    // The goal page (goal 41 T6): the state strip leads — the
+    // one-glance verdict (status badge, the open-item line, the
+    // primary lifecycle button) — then the anchor sub-nav, then the
+    // Act|Consult two-column layout (spry .with-sidebar): the acting
+    // surfaces (state, blocks/questions, plan review, completion
+    // review, comments) on the left, the consulting ones (tasks with
+    // the filter strip, initial brief, involved projects, rules,
+    // narrative, runs) on the right, stacking Act-then-Consult under
+    // the framework breakpoint. Below the fold the page keeps its
+    // established surfaces: the agent's live progress feed, the
+    // attention blocks (the goal agent's answerable questions/RFCs
+    // and the completion review), the locked brief, the effective
     // rules, the approved plan (revision history behind the
     // expander), the supervision narrative (events at full weight,
     // comment events as footnotes), the comment thread with
@@ -21,10 +26,9 @@
     // R3/arch 15): a per-block queue that renders at the bottom of
     // the plan, persists across pulse refreshes (the maps live
     // outside statum re-renders), rides the rejection numbered, and
-    // clears on accept. The status strip's
-    // filter lives client-side in window.goalTaskFilter; clicking a
-    // strip button re-runs the sealed refresh action, and the filter
-    // rides the re-render.
+    // clears on accept. The tasks card's filter lives client-side in
+    // window.goalTaskFilter; clicking a strip button re-runs the
+    // sealed refresh action, and the filter rides the re-render.
     if (window.statum) {
         (function () {
             'use strict';
@@ -63,6 +67,67 @@
                 if (document.visibilityState === 'visible') refreshGoal('focus');
             });
 
+            // Goal 41 T6 (bet 2): the anchor sub-nav. Statum re-renders
+            // replace the DOM, so the listener binds ONCE on document
+            // and the highlight pass re-runs after every evaluate —
+            // the same rebound pattern as the filter strip (the
+            // seals survive because nothing page-scoped is captured).
+            var SUBNAV = 'goal-subnav';
+            var SECTIONS = 'goal-section';
+
+            function goalHighlightSubnav() {
+                var nav = document.getElementsByClassName(SUBNAV)[0];
+                if (!nav) return;
+                var links = nav.querySelectorAll('a[href^="#"]');
+                var sections = document.getElementsByClassName(SECTIONS);
+                if (!links.length || !sections.length) return;
+                // Two columns share the viewport, so "the last section
+                // that crossed the line" (the single-column rule) can
+                // pick a consult-column card while the reader looks at
+                // an act-column one. The section whose top sits
+                // CLOSEST to the nav's bottom edge (just below it)
+                // is the one the reader entered; ties go to the later
+                // DOM node (the deeper scroll).
+                var mark = nav.getBoundingClientRect().bottom + 24;
+                var current = null;
+                var best = Infinity;
+                for (var i = 0; i < sections.length; i++) {
+                    var distance = sections[i].getBoundingClientRect().top - mark;
+                    if (distance > 0) continue;  // still below the fold line
+                    if (-distance <= best) {
+                        best = -distance;
+                        current = sections[i].id;
+                    }
+                }
+                for (var j = 0; j < links.length; j++) {
+                    var active = links[j].getAttribute('href') === '#' + current;
+                    links[j].classList.toggle('active', active);
+                    if (active) links[j].setAttribute('aria-current', 'true');
+                    else links[j].removeAttribute('aria-current');
+                }
+            }
+
+            document.addEventListener('click', function (event) {
+                var link = event.target.closest
+                    && event.target.closest('.' + SUBNAV + ' a[href^="#"]');
+                if (!link) return;
+                var target = document.getElementById(
+                    link.getAttribute('href').slice(1));
+                if (!target) return;
+                event.preventDefault();
+                target.scrollIntoView();
+                // scrollIntoView respects the sticky nav only through
+                // scroll-margin-top (app.css); the highlight lands on
+                // the raf after the scroll settles.
+                requestAnimationFrame(goalHighlightSubnav);
+            });
+
+            window.addEventListener('scroll', goalHighlightSubnav,
+                { passive: true });
+            window.addEventListener('resize', goalHighlightSubnav,
+                { passive: true });
+            statum.afterEvaluate(goalHighlightSubnav);
+
             if (window.initQuoteComments) {
                 window.initQuoteComments({
                     matches: function (node) {
@@ -121,22 +186,102 @@
 </head>
 <body>
     <div class="page goal-page">
-        <p><a href="/goals">&larr; Goals</a></p>
+        <pstm-fragment name="breadcrumb" crumbs="goal.crumbs"/>
         <h2 stm-if="goal.exists"><span class="goal-icon">🎯</span> #<span stm-text="goal.id">id</span> —
             <span stm-text="goal.title">title</span></h2>
         <p stm-if="!goal.exists" class="text-muted">Goal not found.</p>
 
         <div stm-if="goal.exists">
-            <!-- Status head (first, always): lifecycle badge, branch
-                 pair with the daemon-reported push-state chips,
-                 priority, timestamps, the assigned/parking agent
-                 saddle and the lifecycle actions — with full run
-                 control parity (plan 14 B1): Retry supervision for a
-                 failed goal, Requeue-to-resume for a run stale/parked
-                 on a dead saddle, Cancel. NO notify-me toggle — goals
-                 notify unconditionally by design. Yeehaw stays on
-                 task rows only. -->
-            <section class="card goal-head">
+            <!-- GOAL 41 T6 (quick win 7): the one-line STATE STRIP —
+                 the verdict without parsing the Status card: status
+                 badge, the open-item line ("2 questions waiting · plan
+                 revision 3 pending"), the PRIMARY lifecycle button
+                 (the one matching the current state; the full ladder
+                 keeps living on the Status card). Nothing else — the
+                 strip is one glance. -->
+            <div class="goal-state-strip" data-goal-state-strip>
+                <span stm-attribute.class="'badge badge-' + goal.status"
+                      stm-text="goal.display">status</span>
+                <span class="goal-state-verdict">
+                    <span stm-if="goal.open_blocks.length == 0
+                                  && (goal.plan_pending_block == null
+                                      || goal.plan_pending_block == false)
+                                  && !goal.completion_pending"
+                          class="text-muted">No open items — nothing waits on
+                        you.</span>
+                    <span stm-if="goal.plan_pending_block != null
+                                  && goal.plan_pending_block != false">
+                        Plan revision #<span stm-text="goal.plan_pending_block.id">n</span>
+                        pending</span>
+                    <span stm-if="goal.completion_pending">Completion review
+                        pending</span>
+                    <span stm-if="goal.open_blocks.length == 1"
+                          stm-text="'1 ' + (goal.open_blocks[0].block_type == 'question'
+                              ? 'question' : goal.open_blocks[0].block_type)
+                              + ' waiting'">1 waiting</span>
+                    <span stm-if="goal.open_blocks.length > 1"
+                          stm-text="goal.open_blocks.length + ' items waiting'">n waiting</span>
+                </span>
+                <span class="goal-state-actions">
+                    <button type="button" class="btn-primary btn-sm"
+                            stm-if="goal.start != null" stm-action="goal.start">Start
+                        goal</button>
+                    <button type="button" class="btn-primary btn-sm"
+                            stm-if="goal.resume != null" stm-action="goal.resume">Resume</button>
+                    <button type="button" class="btn-primary btn-sm"
+                            stm-if="goal.retry_run != null" stm-action="goal.retry_run">Retry
+                        supervision</button>
+                    <button type="button" class="btn-primary btn-sm"
+                            stm-if="goal.pause != null" stm-action="goal.pause">Pause</button>
+                    <button type="button" class="btn-primary btn-sm"
+                            stm-if="goal.requeue_run != null" stm-action="goal.requeue_run"
+                            stm-confirm="Requeue the agent's run? The daemon restores its workspace context on the next dispatch.">Requeue
+                        to resume</button>
+                    <button type="button" class="btn-primary btn-sm"
+                            stm-if="goal.force_wake != null" stm-action="goal.force_wake"
+                            stm-confirm="Force-woke this goal's parked agent? It resumes immediately on its saddle - one wake per press.">Force
+                        wake</button>
+                </span>
+            </div>
+
+            <!-- GOAL 41 T6 (bet 2): the anchor sub-nav — Status ·
+                 Blocks · Plan · Tasks · Brief · Narrative · Comments.
+                 Sticky under the topbar (app.css reads its height);
+                 the highlight listener lives in the page script
+                 (rebound through statum.afterEvaluate). -->
+            <nav class="goal-subnav" aria-label="Sections" data-goal-subnav>
+                <a href="#goal-sec-status">Status</a>
+                <a href="#goal-sec-blocks">Blocks</a>
+                <a href="#goal-sec-plan">Plan</a>
+                <a href="#goal-sec-tasks">Tasks</a>
+                <a href="#goal-sec-brief">Brief</a>
+                <a href="#goal-sec-narrative">Narrative</a>
+                <a href="#goal-sec-comments">Comments</a>
+            </nav>
+
+            <!-- GOAL 41 T6 (bet 1): Act|Consult — spry's
+                 .with-sidebar grid with the proportions flipped (the
+                 acting column is the wide one; app.css owns the
+                 columns + the sub-55rem stack). ACT carries the
+                 attention order the page already had: pending
+                 reviews lead (actions surface), then state, blocks,
+                 plan, comments. CONSULT carries the reference
+                 surfaces the reader jumps to. -->
+            <div class="goal-columns with-sidebar" data-goal-columns>
+                <div class="stack" data-goal-act>
+
+            <!-- Status head (first in the ACT column, always):
+                 lifecycle badge, branch pair with the
+                 daemon-reported push-state chips, priority,
+                 timestamps, the assigned/parking agent saddle and
+                 the FULL lifecycle ladder — with run control parity
+                 (plan 14 B1): Retry supervision for a failed goal,
+                 Requeue-to-resume for a run stale/parked on a dead
+                 saddle, Cancel. NO notify-me toggle — goals notify
+                 unconditionally by design. Yeehaw stays on task
+                 rows only. The strip above carries the one-glance
+                 verdict; this card keeps the detail. -->
+            <section class="card goal-head goal-section" id="goal-sec-status">
                 <div class="card-head">
                     <h3>Status</h3>
                 </div>
@@ -144,7 +289,8 @@
                     · goal branch <code stm-text="goal.branch_name">branch</code>
                     <span class="text-muted">from</span> <code stm-text="goal.base_branch">base</code>
                     <a stm-if="goal.compare_url.length > 0" stm-attribute.href="goal.compare_url"
-                       target="_blank" rel="noopener">· compare changes</a>
+                       target="_blank" rel="noopener">· compare
+                        changes</a>
                     · priority <strong stm-text="goal.priority">3</strong>
                     <span stm-if="goal.agent_saddle.length > 0"> · agent saddle
                         <strong stm-text="goal.agent_saddle">saddle</strong></span></p>
@@ -213,8 +359,9 @@
                  the historic answered ones fold behind the
                  answered-blocks expander (the task page's pattern);
                  the completion review follows when one awaits. -->
-            <section class="card"
-                     stm-if="goal.open_blocks.length > 0 || goal.answered_blocks.length > 0">
+            <section class="card goal-section"
+                     stm-if="goal.open_blocks.length > 0 || goal.answered_blocks.length > 0"
+                     id="goal-sec-blocks">
                 <h3>Goal agent blocks</h3>
                 <div stm-for-block-in="goal.open_blocks" class="goal-block">
                     <p><span stm-attribute.class="'badge badge-block-' + block.block_type"
@@ -277,7 +424,7 @@
                  once the goal is complete/failed, with the report and
                  the compare link. While PENDING, the review lives in
                  the attention blocks above (the rich review form). -->
-            <section class="card" stm-if="goal.terminal && goal.completion_report.length > 0">
+            <section class="card goal-section" stm-if="goal.terminal && goal.completion_report.length > 0">
                 <h3>Completion review</h3>
                 <pstm-fragment name="goal-branches"/>
                 <pstm-fragment name="goal-compare"/>
@@ -291,7 +438,7 @@
                  (approved + rejected) sit behind a collapsed
                  expander — the goal page leads with what needs a
                  human, not with settled history. -->
-            <section class="card">
+            <section class="card goal-section" id="goal-sec-plan">
                 <h3>Plan</h3>
 
                 <div stm-if="goal.plan_pending_block != null && goal.plan_pending_block != false"
@@ -339,10 +486,69 @@
                 </details>
             </section>
 
+            <!-- Comments with selection-quoting (markdown bodies).
+                 Plan 15 C2: the thread IS the goal-run task's own
+                 task_comments — the agent's pushes and the human's
+                 composer land on the same rows; harness-authored
+                 bookkeeping renders as distinct muted system rows,
+                 never agent comment rows. The composer opens with
+                 the run (a draft goal has no thread to inject into). -->
+            <section class="card goal-section" id="goal-sec-comments">
+                <h3>Comments</h3>
+                <!-- Plan 15 C4: timeless-comment footnote styling —
+                     the BODY leads; the author and time read as a
+                     muted footnote (status belongs in progress
+                     updates). System rows keep their labelled style. -->
+                <div stm-for-comment-in="goal.comments"
+                     stm-attribute.class="'comment' + (comment.from_agent ? ' agent' : '') + (comment.is_system ? ' system' : '')">
+                    <div class="md" stm-markdown="comment.body">body</div>
+                    <blockquote stm-if="comment.quote.length > 0" stm-text="comment.quote">quote</blockquote>
+                    <p class="comment-meta">
+                        <strong stm-if="!comment.is_system" stm-text="comment.author">author</strong>
+                        <span stm-if="comment.is_system" class="system-label">system</span>
+                        <span class="text-muted" stm-text="comment.created_display">when</span>
+                    </p>
+                </div>
+                <p stm-if="goal.comments.length == 0" class="text-muted">No comments yet.</p>
+                <p stm-if="goal.run_task_id == 0" class="text-muted">The goal agent has not started
+                    yet — comments open with its run.</p>
+                <!-- Goal 8 task 4: the composer stays OPEN on terminal
+                     goals — a comment there re-opens the goal and
+                     re-offers its agent's run with the comment riding
+                     the resume (cancelled goals stay closed: their
+                     composer keeps the run gate alone). -->
+                <form stm-if="goal.run_task_id > 0" stm-action="goal.comment">
+                    <div class="field"><label for="goal-comment-body">Add comment</label>
+                        <textarea id="goal-comment-body" name="body" rows="3"
+                                  placeholder="A timeless note for a future reader — status belongs in progress updates (markdown)"></textarea></div>
+                    <p stm-if="goal.terminal && goal.status != 'cancelled'" class="text-muted">
+                        This goal is terminal — commenting re-opens it: the agent resumes
+                        with your comment and submits a fresh completion report.</p>
+                    <button type="submit" class="btn-primary">Comment</button>
+                </form>
+                <dialog id="quote-comment-dialog">
+                    <h3>Comment on selected text</h3>
+                    <blockquote id="quote-text" class="term-comment-quote"></blockquote>
+                    <form stm-action="goal.comment">
+                        <input type="hidden" name="quote">
+                        <input type="hidden" name="return_to">
+                        <textarea name="body" rows="4" placeholder="Your comment…"
+                                  style="width:100%; margin:0.5em 0;"></textarea>
+                        <button type="submit" class="btn-primary">Send comment</button>
+                        <button type="button"
+                                onclick="document.getElementById('quote-comment-dialog').close()">Cancel</button>
+                    </form>
+                </dialog>
+            </section>
+
+                </div><!-- /data-goal-act -->
+
+                <div class="stack" data-goal-consult>
+
             <!-- Task status strip + the goal's task list (plan 14 B5:
                  tasks above the brief; capability badges per row —
                  no editing here; yeehaw on rows only). -->
-            <section class="card">
+            <section class="card goal-section" id="goal-sec-tasks">
                 <h3>Tasks</h3>
                 <p class="goal-strip">
                     <button type="button" stm-attribute.class="window.goalFilterClass('draft')"
@@ -395,7 +601,7 @@
                  nothing else could change). Starting locks the brief:
                  the editor's stm-if wrapper disappears with the
                  update action and the card stays the plain read view. -->
-            <section class="card" data-long-form data-field="goal"
+            <section class="card goal-section" id="goal-sec-brief" data-long-form data-field="goal"
                      data-lf-open="0">
                 <h3>Initial brief
                     <button type="button" class="btn-ghost" stm-if="goal.update != null"
@@ -472,7 +678,7 @@
             <!-- The supervision narrative (plan 14 B6): state events
                  at full weight, comment events as .footnote — the
                  feed reads as events-with-occasional-footnotes. -->
-            <section class="card">
+            <section class="card goal-section" id="goal-sec-narrative">
                 <h3>Supervision narrative</h3>
                 <p stm-if="goal.events.length == 0" class="text-muted">Nothing yet — start the goal.</p>
                 <div stm-for-event-in="goal.events"
@@ -489,61 +695,6 @@
                 </div>
             </section>
 
-            <!-- Comments with selection-quoting (markdown bodies).
-                 Plan 15 C2: the thread IS the goal-run task's own
-                 task_comments — the agent's pushes and the human's
-                 composer land on the same rows; harness-authored
-                 bookkeeping renders as distinct muted system rows,
-                 never agent comment rows. The composer opens with
-                 the run (a draft goal has no thread to inject into). -->
-            <section class="card">
-                <h3>Comments</h3>
-                <!-- Plan 15 C4: timeless-comment footnote styling —
-                     the BODY leads; the author and time read as a
-                     muted footnote (status belongs in progress
-                     updates). System rows keep their labelled style. -->
-                <div stm-for-comment-in="goal.comments"
-                     stm-attribute.class="'comment' + (comment.from_agent ? ' agent' : '') + (comment.is_system ? ' system' : '')">
-                    <div class="md" stm-markdown="comment.body">body</div>
-                    <blockquote stm-if="comment.quote.length > 0" stm-text="comment.quote">quote</blockquote>
-                    <p class="comment-meta">
-                        <strong stm-if="!comment.is_system" stm-text="comment.author">author</strong>
-                        <span stm-if="comment.is_system" class="system-label">system</span>
-                        <span class="text-muted" stm-text="comment.created_display">when</span>
-                    </p>
-                </div>
-                <p stm-if="goal.comments.length == 0" class="text-muted">No comments yet.</p>
-                <p stm-if="goal.run_task_id == 0" class="text-muted">The goal agent has not started
-                    yet — comments open with its run.</p>
-                <!-- Goal 8 task 4: the composer stays OPEN on terminal
-                     goals — a comment there re-opens the goal and
-                     re-offers its agent's run with the comment riding
-                     the resume (cancelled goals stay closed: their
-                     composer keeps the run gate alone). -->
-                <form stm-if="goal.run_task_id > 0" stm-action="goal.comment">
-                    <div class="field"><label for="goal-comment-body">Add comment</label>
-                        <textarea id="goal-comment-body" name="body" rows="3"
-                                  placeholder="A timeless note for a future reader — status belongs in progress updates (markdown)"></textarea></div>
-                    <p stm-if="goal.terminal && goal.status != 'cancelled'" class="text-muted">
-                        This goal is terminal — commenting re-opens it: the agent resumes
-                        with your comment and submits a fresh completion report.</p>
-                    <button type="submit" class="btn-primary">Comment</button>
-                </form>
-                <dialog id="quote-comment-dialog">
-                    <h3>Comment on selected text</h3>
-                    <blockquote id="quote-text" class="term-comment-quote"></blockquote>
-                    <form stm-action="goal.comment">
-                        <input type="hidden" name="quote">
-                        <input type="hidden" name="return_to">
-                        <textarea name="body" rows="4" placeholder="Your comment…"
-                                  style="width:100%; margin:0.5em 0;"></textarea>
-                        <button type="submit" class="btn-primary">Send comment</button>
-                        <button type="button"
-                                onclick="document.getElementById('quote-comment-dialog').close()">Cancel</button>
-                    </form>
-                </dialog>
-            </section>
-
             <!-- Goal agent runs: every round with its host saddle; the
                  event log links open in a new tab. -->
             <section class="card" stm-if="goal.runs.length > 0">
@@ -567,6 +718,9 @@
                     </tbody>
                 </table>
             </section>
+
+                </div><!-- /data-goal-consult -->
+            </div><!-- /goal-columns -->
         </div>
     </div>
 </body>

+ 1 - 1
test/bundles/goals.py

@@ -179,7 +179,7 @@ def run(ctx):
     check("&#127919;" in goals_html and "goal-icon" in goals_html,
           "the goals list page carries the goal icon")
     goal_page_html = http_get_page(auth, "/goals/%d" % goal1)
-    check('class="card goal-head"' in goal_page_html and "goal-page" in goal_page_html,
+    check('class="card goal-head' in goal_page_html and "goal-page" in goal_page_html,
           "the goal page carries the goal-accented identity chrome")
     # Reject with feedback: the agent revises.
     fake.goal_script = [("plan", "# Plan v2\n\n1. Add tests\n2. Add docs\n3. Ship")]

+ 59 - 11
test/bundles/goalui.py

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