Bläddra i källkod

Merge remote-tracking branch 'origin/task/41-284' into goal/ui-ux

# Conflicts:
#	src/pages/app.css
saddle-ClankerSaddle 1 dag sedan
förälder
incheckning
cd95fd9ca0
5 ändrade filer med 504 tillägg och 167 borttagningar
  1. 25 0
      src/entrypoints/GoalDetailEntrypoint.vala
  2. 86 0
      src/pages/app.css
  3. 325 152
      src/pages/goal_detail.html
  4. 1 1
      test/bundles/goals.py
  5. 67 14
      test/bundles/goalui.py

+ 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>(

+ 86 - 0
src/pages/app.css

@@ -1487,3 +1487,89 @@ td .truncate.repo-url {
     white-space: nowrap;
 }
 .log-jump:hover { background: var(--spry-accent); color: var(--spry-accent-fg); }
+
+/* ==========================================================================
+   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 leading review cards (goal 41 T6 "actions surface"): a pending
+   plan or completion review renders as its own card at the TOP of
+   the acting column, accented like the status head so the open
+   decision reads before anything else. */
+.plan-review-card { border-left: 4px solid var(--spry-accent); }
+
+/* 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; }
+}

+ 325 - 152
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) {
@@ -85,11 +150,11 @@
             // now lives once); only the block resolution is page
             // state.
             // Plan 16 S2: the block for a review container resolves
-            // from the ACTUAL [data-plan-review] element - the pending
-            // completion review renders as an attention block (NOT the
-            // plan section), so goal.plan_pending_block alone never
-            // resolves it; every open block plus the plan section's
-            // row are all candidates.
+            // from the ACTUAL [data-plan-review] element — the
+            // pending completion review renders as a LEADING review
+            // card (goal 41 T6), so goal.plan_pending_block alone
+            // never resolves it; the slot's three review shapes plus
+            // every open block are all candidates.
             function resolveReviewBlock(id) {
                 var goal = statum.read('goal');
                 if (!goal) return null;
@@ -97,6 +162,10 @@
                 (goal.open_blocks || []).forEach(function (candidate) {
                     if (String(candidate.id) === String(id)) block = candidate;
                 });
+                if (!block && goal.completion_pending_block
+                        && String(goal.completion_pending_block.id) === String(id)) {
+                    block = goal.completion_pending_block;
+                }
                 if (!block && goal.plan_pending_block
                         && String(goal.plan_pending_block.id) === String(id)) {
                     block = goal.plan_pending_block;
@@ -121,22 +190,166 @@
 </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>
+
+            <!-- GOAL 41 T6 ("actions surface"): while a plan review is
+                 pending, the review surface LEADS the acting column
+                 (stm-if ordering by state) — the reader answers before
+                 anything else. The plan-history card below renders
+                 only when nothing is pending. -->
+            <section class="card plan-review-card goal-section"
+                     stm-if="goal.plan_pending_block != null && goal.plan_pending_block != false"
+                     id="goal-sec-plan-review">
+                <h3>Plan review</h3>
+                <div class="plan-review" data-plan-review
+                     stm-attribute.data-block-id="goal.plan_pending_block.id">
+                    <h4>Plan submitted for review</h4>
+                    <div class="md rfc-body goal-body" data-plan-body
+                         stm-markdown="goal.plan_pending_block.body">revision</div>
+                    <div class="actions">
+                        <form stm-action="goal.plan_pending_block.answer_action" class="cluster">
+                            <input type="hidden" name="verdict" value="accept">
+                            <button type="submit" class="btn-primary" data-plan-accept>Approve plan</button>
+                        </form>
+                        <button type="button" data-plan-toggle>Reject with feedback</button>
+                    </div>
+                    <div data-plan-feedback hidden>
+                        <p class="text-muted">Highlight plan text to attach a remark to it — the queue
+                            rides the rejection numbered, then clears. Approving ignores the queue.</p>
+                        <ul class="plan-comments" data-plan-remarks></ul>
+                        <textarea name="feedback" rows="2"
+                                  placeholder="Additional feedback (optional)"></textarea>
+                        <button type="button" class="btn-primary" data-plan-submit>Submit feedback</button>
+                    </div>
+                </div>
+            </section>
+
+            <!-- GOAL 41 T6 ("actions surface"): the pending completion
+                 review leads the acting column the same way — hoisted
+                 out of the blocks loop (which skips review rows) and
+                 keyed on the slot's completion_pending_block. -->
+            <section class="card plan-review-card goal-section"
+                     stm-if="goal.completion_pending_block != null && goal.completion_pending_block != false"
+                     id="goal-sec-completion-review">
+                <h3>Completion review</h3>
+                <div class="plan-review" data-plan-review
+                     stm-attribute.data-block-id="goal.completion_pending_block.id">
+                    <pstm-fragment name="goal-branches"/>
+                    <pstm-fragment name="goal-compare"/>
+                    <div class="md rfc-body goal-body" data-plan-body
+                         stm-markdown="goal.completion_pending_block.body">report</div>
+                    <div class="actions">
+                        <form stm-action="goal.completion_pending_block.answer_action" class="cluster">
+                            <input type="hidden" name="verdict" value="accept">
+                            <button type="submit" class="btn-primary" data-plan-accept>Accept goal</button>
+                        </form>
+                        <button type="button" data-plan-toggle>Reject with feedback</button>
+                    </div>
+                    <div data-plan-feedback hidden>
+                        <p class="text-muted">Highlight report text to attach a remark to it — the queue
+                            rides the rejection numbered, then clears. Accepting ignores the queue.</p>
+                        <ul class="plan-comments" data-plan-remarks></ul>
+                        <textarea name="feedback" rows="2"
+                                  placeholder="Additional feedback (optional)"></textarea>
+                        <button type="button" class="btn-primary" data-plan-submit>Submit feedback</button>
+                    </div>
+                </div>
+            </section>
+
+            <!-- Status head (first in the ACT column while no review
+                 awaits): lifecycle badge, branch pair with the
+                 daemon-reported push-state chips, priority,
+                 timestamps, the assigned/parking agent saddle and
+                 the FULL lifecycle ladder — with run control parity
+                 (plan 14 B1): Retry supervision for a failed goal,
+                 Requeue-to-resume for a run stale/parked on a dead
+                 saddle, Cancel. NO notify-me toggle — goals notify
+                 unconditionally by design. Yeehaw stays on task
+                 rows only. The strip above carries the one-glance
+                 verdict; this card keeps the detail. -->
+            <section class="card goal-head goal-section" id="goal-sec-status">
                 <div class="card-head">
                     <h3>Status</h3>
                 </div>
@@ -144,7 +357,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,43 +427,20 @@
                  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"
+                    <!-- A pending completion review renders in the
+                         LEADING completion-review card above (goal 41
+                         T6 "actions surface"); the loop shows every
+                         other open block — questions and RFCs with
+                         full markdown bodies and inline answer forms. -->
+                    <p stm-if="!block.is_completion_review"><span stm-attribute.class="'badge badge-block-' + block.block_type"
                              stm-text="block.block_type">kind</span>
                         <strong stm-text="block.question">question</strong>
                         <span class="text-muted" stm-text="block.created_display">when</span></p>
-                    <!-- The pending completion review renders in the rich
-                         review form (highlight-remarks queue shared with
-                         the plan review; push chips + compare above the
-                         report — chips are advisory, the push rule is
-                         enforced daemon-side at the agent's completion
-                         attempt). -->
-                    <div stm-if="block.is_completion_review"
-                         class="plan-review" data-plan-review
-                         stm-attribute.data-block-id="block.id">
-                        <pstm-fragment name="goal-branches"/>
-                        <pstm-fragment name="goal-compare"/>
-                        <div class="md rfc-body goal-body" data-plan-body
-                             stm-markdown="block.body">report</div>
-                        <div class="actions">
-                            <form stm-action="block.answer_action" class="cluster">
-                                <input type="hidden" name="verdict" value="accept">
-                                <button type="submit" class="btn-primary" data-plan-accept>Accept goal</button>
-                            </form>
-                            <button type="button" data-plan-toggle>Reject with feedback</button>
-                        </div>
-                        <div data-plan-feedback hidden>
-                            <p class="text-muted">Highlight report text to attach a remark to it — the queue
-                                rides the rejection numbered, then clears. Accepting ignores the queue.</p>
-                            <ul class="plan-comments" data-plan-remarks></ul>
-                            <textarea name="feedback" rows="2"
-                                      placeholder="Additional feedback (optional)"></textarea>
-                            <button type="button" class="btn-primary" data-plan-submit>Submit feedback</button>
-                        </div>
-                    </div>
                     <div stm-if="!block.is_completion_review">
                         <div stm-if="block.show_body && block.body.length > 0"
                              class="md rfc-body goal-body" stm-markdown="block.body">body</div>
@@ -277,48 +468,23 @@
                  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"/>
                 <div class="md rfc-body goal-body" stm-markdown="goal.completion_report">report</div>
             </section>
 
-            <!-- The plan (plan 15 C4): BETWEEN attention and tasks.
-                 While a plan review is open the pending submission
-                 renders EXPANDED with the remarks queue (plan 14 B2);
-                 otherwise the approved plan and the revision history
-                 (approved + rejected) sit behind a collapsed
-                 expander — the goal page leads with what needs a
-                 human, not with settled history. -->
-            <section class="card">
+            <!-- The plan history (plan 15 C4): the approved plan and
+                 the revision history (approved + rejected) sit behind
+                 a collapsed expander — the goal page leads with what
+                 needs a human, not with settled history. While a plan
+                 review is open the LEADING plan-review card above
+                 carries it (this card waits below). -->
+            <section class="card goal-section" id="goal-sec-plan">
                 <h3>Plan</h3>
 
-                <div stm-if="goal.plan_pending_block != null && goal.plan_pending_block != false"
-                     class="plan-review" data-plan-review
-                     stm-attribute.data-block-id="goal.plan_pending_block.id">
-                    <h4>Plan submitted for review</h4>
-                    <div class="md rfc-body goal-body" data-plan-body
-                         stm-markdown="goal.plan_pending_block.body">revision</div>
-                    <div class="actions">
-                        <form stm-action="goal.plan_pending_block.answer_action" class="cluster">
-                            <input type="hidden" name="verdict" value="accept">
-                            <button type="submit" class="btn-primary" data-plan-accept>Approve plan</button>
-                        </form>
-                        <button type="button" data-plan-toggle>Reject with feedback</button>
-                    </div>
-                    <div data-plan-feedback hidden>
-                        <p class="text-muted">Highlight plan text to attach a remark to it — the queue
-                            rides the rejection numbered, then clears. Approving ignores the queue.</p>
-                        <ul class="plan-comments" data-plan-remarks></ul>
-                        <textarea name="feedback" rows="2"
-                                  placeholder="Additional feedback (optional)"></textarea>
-                        <button type="button" class="btn-primary" data-plan-submit>Submit feedback</button>
-                    </div>
-                </div>
-
-                <details class="history-expander"
-                         stm-if="goal.plan_pending_block == null || goal.plan_pending_block == false">
+                <details class="history-expander">
                     <summary>Approved plan &amp; revisions (<span
                             stm-text="goal.plan_revisions.length">0</span>)</summary>
                     <div stm-if="goal.plan.length > 0" class="md rfc-body goal-body" data-plan-body
@@ -339,10 +505,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 +620,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 +697,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 +714,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 +737,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")]

+ 67 - 14
test/bundles/goalui.py

@@ -126,9 +126,12 @@ def run(ctx):
     check(goal_state(goal)["current_activity"] == "parked awaiting the plan review",
           "the head line reads 'parked awaiting the plan review' while one is open")
     goal_page = http_get_page(auth, "/goals/%d" % goal)
-    check(goal_page.index("Goal agent blocks") < goal_page.index("<h3>Plan</h3>")
-          < goal_page.index("<h3>Tasks</h3>"),
-          "the plan section sits between attention and tasks")
+    # Goal 41 T6 ("actions surface"): a pending plan review LEADS the
+    # acting column as its own card — before the status head, the
+    # blocks and the (history-only) plan card.
+    check(goal_page.index("Plan review") < goal_page.index('class="card goal-head')
+          < goal_page.index("Goal agent blocks") < goal_page.index("<h3>Plan</h3>"),
+          "a pending plan review leads the acting column (status, blocks, history after)")
     check("data-plan-review" in goal_page and "Approved plan &amp; revisions" in goal_page,
           "an open review renders expanded beside the collapsed approved-plan expander")
 
@@ -415,28 +418,78 @@ 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 = [
+        ('id="goal-sec-plan-review"', "plan review (when pending)"),
+        ('id="goal-sec-completion-review"', "completion review (when pending)"),
+        ('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(),