Selaa lähdekoodia

Unify the subnav engine, Act|Consult columns and CrumbView (goal 41 T11)

The convergence sweep's first cut:

- ONE subnav engine (app.js initSubnav): task_detail's T5 observer
  chassis and goal_detail's T6 closest-to-fold highlighter are one
  implementation now. T5's inline 133-line copy and T6's inline
  60-line copy are deleted; both pages call window.initSubnav(). The
  engine keeps T5's measured-offset smooth jumps, is-stuck stickiness
  and --topbar-bottom publishing, and T6's closest-to-fold selection
  rule (proven necessary in the two-column Act|Consult grid), with
  aria-current and the bind-flag afterEvaluate safety.
- task_detail's four subnav sections now carry id attributes - the
  strip's anchors (#task-progress etc.) resolved to nothing before;
  goal_detail's goal-section class became data-subnav-section + id,
  and its strip is the shared .subnav with data-subnav.
- ONE column rule: .act-consult replaces .task-columns and
  .goal-columns (same rule, drifted rail widths 17-22/19-28 unified
  to 18-24rem); the dead .task-col-act/-consult classes are gone.
- ONE CrumbView: the four identical {href,label} classes
  (TaskDetail's CrumbView, GoalCrumbView, ProjectCrumbView,
  ChatCrumbView) are one ClankerSaddle.CrumbView in src/Json.vala.
- app.css: the two inert .dash-activity-table cap rules are deleted
  (T10's work stream replaced the surface; t10views asserts absence);
  the T8 section header's missing closing marker is repaired.
- bundles: goalui/pages assert the unified class names.
saddle-ClankerSaddle 1 päivä sitten
vanhempi
sitoutus
993543612c

+ 14 - 0
src/Json.vala

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

+ 5 - 14
src/entrypoints/ChatDetailEntrypoint.vala

@@ -77,15 +77,6 @@ namespace ClankerSaddle {
         public Statum.Model.ActionDto? refresh { get; set; }
         public Statum.Model.ActionDto? refresh { get; set; }
     }
     }
 
 
-    /**
-     * One breadcrumb item on the chat detail page (goal 41 T9): the
-     * shared partial renders the trail; the leaf carries no href.
-     */
-    public class ChatCrumbView : Object {
-        public string? href { get; set; default = null; }
-        public string label { get; set; default = ""; }
-    }
-
     /**
     /**
      * Builds the `chat` PAGE slot shared by the chat detail entrypoint
      * Builds the `chat` PAGE slot shared by the chat detail entrypoint
      * and the advisor actions ({@link SendAdvisorMessageAction} answers
      * and the advisor actions ({@link SendAdvisorMessageAction} answers
@@ -176,11 +167,11 @@ namespace ClankerSaddle {
                     suggestions.to_immutable_buffer());
                     suggestions.to_immutable_buffer());
                 // Goal 41 T9: the breadcrumb trail (Dashboard / Chats /
                 // Goal 41 T9: the breadcrumb trail (Dashboard / Chats /
                 // <title>) rendered by the shared partial.
                 // <title>) rendered by the shared partial.
-                var crumbs = new Vector<ChatCrumbView>();
-                crumbs.add(new ChatCrumbView() { href = "/", label = "Dashboard" });
-                crumbs.add(new ChatCrumbView() { href = "/chats", label = "Chats" });
-                crumbs.add(new ChatCrumbView() { label = ((!)chat).title });
-                props["crumbs"] = StateJson.list<ChatCrumbView>(
+                var crumbs = new Vector<CrumbView>();
+                crumbs.add(new CrumbView() { href = "/", label = "Dashboard" });
+                crumbs.add(new CrumbView() { href = "/chats", label = "Chats" });
+                crumbs.add(new CrumbView() { label = ((!)chat).title });
+                props["crumbs"] = StateJson.list<CrumbView>(
                     crumbs.to_immutable_buffer());
                     crumbs.to_immutable_buffer());
             } else {
             } else {
                 props = (PropertyDictionary) GObjectMapping.to_properties(pub);
                 props = (PropertyDictionary) GObjectMapping.to_properties(pub);

+ 5 - 15
src/entrypoints/GoalDetailEntrypoint.vala

@@ -125,16 +125,6 @@ namespace ClankerSaddle {
         public bool pushed { get; set; default = false; }
         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). */
     /** A project option chip (the goal link form's select). */
     public class GoalProjectOptionView : Object {
     public class GoalProjectOptionView : Object {
         public int64 id { get; set; }
         public int64 id { get; set; }
@@ -294,7 +284,7 @@ namespace ClankerSaddle {
         public Vector<GoalCommentView> comments { get; set; default = new Vector<GoalCommentView>(); }
         public Vector<GoalCommentView> comments { get; set; default = new Vector<GoalCommentView>(); }
         public Vector<GoalRunView> runs { get; set; default = new Vector<GoalRunView>(); }
         public Vector<GoalRunView> runs { get; set; default = new Vector<GoalRunView>(); }
         public Vector<GoalPushBranchView> push_branches { get; set; default = new Vector<GoalPushBranchView>(); }
         public Vector<GoalPushBranchView> push_branches { get; set; default = new Vector<GoalPushBranchView>(); }
-        public Vector<GoalCrumbView> crumbs { get; set; default = new Vector<GoalCrumbView>(); }
+        public Vector<CrumbView> crumbs { get; set; default = new Vector<CrumbView>(); }
         public Vector<GoalProgressView> progress { get; set; default = new Vector<GoalProgressView>(); }
         public Vector<GoalProgressView> progress { get; set; default = new Vector<GoalProgressView>(); }
     }
     }
 
 
@@ -327,13 +317,13 @@ namespace ClankerSaddle {
                 // Goal 41 T6: the breadcrumb trail (the shared partial
                 // Goal 41 T6: the breadcrumb trail (the shared partial
                 // renders it; the leaf carries no href and reads
                 // renders it; the leaf carries no href and reads
                 // aria-current from the partial).
                 // aria-current from the partial).
-                view.crumbs.add(new GoalCrumbView() {
+                view.crumbs.add(new CrumbView() {
                     href = "/", label = "Dashboard"
                     href = "/", label = "Dashboard"
                 });
                 });
-                view.crumbs.add(new GoalCrumbView() {
+                view.crumbs.add(new CrumbView() {
                     href = "/goals", label = "Goals"
                     href = "/goals", label = "Goals"
                 });
                 });
-                view.crumbs.add(new GoalCrumbView() {
+                view.crumbs.add(new CrumbView() {
                     label = @"#$(goal_id)"
                     label = @"#$(goal_id)"
                 });
                 });
                 foreach (var block in view.blocks) {
                 foreach (var block in view.blocks) {
@@ -423,7 +413,7 @@ namespace ClankerSaddle {
                 view.progress.to_immutable_buffer());
                 view.progress.to_immutable_buffer());
             pub_root["push_branches"] = StateJson.list<GoalPushBranchView>(
             pub_root["push_branches"] = StateJson.list<GoalPushBranchView>(
                 view.push_branches.to_immutable_buffer());
                 view.push_branches.to_immutable_buffer());
-            pub_root["crumbs"] = StateJson.list<GoalCrumbView>(
+            pub_root["crumbs"] = StateJson.list<CrumbView>(
                 view.crumbs.to_immutable_buffer());
                 view.crumbs.to_immutable_buffer());
             pub_root["plan_revisions"] = StateJson.list<GoalBlockView>(
             pub_root["plan_revisions"] = StateJson.list<GoalBlockView>(
                 plan_revisions.to_immutable_buffer());
                 plan_revisions.to_immutable_buffer());

+ 5 - 14
src/entrypoints/ProjectsEntrypoint.vala

@@ -121,15 +121,6 @@ namespace ClankerSaddle {
         public string branch_name { get; set; default = ""; }
         public string branch_name { get; set; default = ""; }
     }
     }
 
 
-    /**
-     * One breadcrumb item on the project detail page (goal 41 T9):
-     * the shared partial renders the trail; the leaf carries no href.
-     */
-    public class ProjectCrumbView : Object {
-        public string? href { get; set; default = null; }
-        public string label { get; set; default = ""; }
-    }
-
     /** A rule attached to the project, with its detach action. */
     /** A rule attached to the project, with its detach action. */
     public class ProjectRuleRowView : Object {
     public class ProjectRuleRowView : Object {
         public int64 link_id { get; set; }
         public int64 link_id { get; set; }
@@ -465,11 +456,11 @@ namespace ClankerSaddle {
             // Goal 41 T9: the breadcrumb trail (Dashboard / Projects /
             // Goal 41 T9: the breadcrumb trail (Dashboard / Projects /
             // <name>) — the shared partial renders it, replacing the
             // <name>) — the shared partial renders it, replacing the
             // hand-drawn "All projects" anchor.
             // hand-drawn "All projects" anchor.
-            var crumbs = new Vector<ProjectCrumbView>();
-            crumbs.add(new ProjectCrumbView() { href = "/", label = "Dashboard" });
-            crumbs.add(new ProjectCrumbView() { href = "/projects", label = "Projects" });
-            crumbs.add(new ProjectCrumbView() { label = ((!)project).name });
-            props["crumbs"] = StateJson.list<ProjectCrumbView>(crumbs.to_immutable_buffer());
+            var crumbs = new Vector<CrumbView>();
+            crumbs.add(new CrumbView() { href = "/", label = "Dashboard" });
+            crumbs.add(new CrumbView() { href = "/projects", label = "Projects" });
+            crumbs.add(new CrumbView() { label = ((!)project).name });
+            props["crumbs"] = StateJson.list<CrumbView>(crumbs.to_immutable_buffer());
 
 
             var slot = state_service.new_slot(Scope.PAGE, new State() {
             var slot = state_service.new_slot(Scope.PAGE, new State() {
                 type_name = "project",
                 type_name = "project",

+ 0 - 11
src/entrypoints/TaskDetailEntrypoint.vala

@@ -60,17 +60,6 @@ namespace ClankerSaddle {
         public string status { get; set; default = ""; }
         public string status { get; set; default = ""; }
     }
     }
 
 
-    /**
-     * One breadcrumb crumb (goal 41 T5): the detail pages render the
-     * shared breadcrumb partial from a vector of these. {@link href}
-     * is null for the trailing (current page) crumb — the partial
-     * gives it aria-current and muted styling.
-     */
-    public class CrumbView : Object {
-        public string? href { get; set; }
-        public string label { get; set; default = ""; }
-    }
-
     /** A rule attached to the task, with its detach action. */
     /** A rule attached to the task, with its detach action. */
     public class TaskRuleRowView : Object {
     public class TaskRuleRowView : Object {
         public int64 link_id { get; set; }
         public int64 link_id { get; set; }

+ 28 - 69
src/pages/app.css

@@ -1348,10 +1348,10 @@ dialog#quote-comment-dialog blockquote {
 
 
 /* Activity cap (ux-notes §1 C): eight rows visible until "Show all"
 /* Activity cap (ux-notes §1 C): eight rows visible until "Show all"
    flips [data-activity-all]; the count badge still covers the whole
    flips [data-activity-all]; the count badge still covers the whole
-   window the slot carries. */
-.dash-activity-table tbody tr:nth-child(n + 9) { display: none; }
-.dash-ambient[data-activity-all="1"] .dash-activity-table tbody
-    tr:nth-child(n + 9) { display: table-row; }
+   window the slot carries. T11: the two .dash-activity-table rules
+   are GONE — T10's work stream replaced the activity table surface
+   (the t10views bundle asserts its absence), so the cap rules were
+   dead selectors. */
 
 
 /* The advisor hero's in-card heading copy (the samplers' composer
 /* The advisor hero's in-card heading copy (the samplers' composer
    shape): the description sits inside the composer card, above the
    shape): the description sits inside the composer card, above the
@@ -1395,12 +1395,15 @@ td .truncate.repo-url {
    need zero extra rules).
    need zero extra rules).
    ========================================================================== */
    ========================================================================== */
 
 
-/* The Act|Consult split: Spry's .with-sidebar gives the rail a
-   minmax(11rem, 16rem) FIRST column; the task page wants the acting
-   column WIDE (the blocks and comments are the page's work) and the
-   consult rail narrower. Same override shape as T3's .dash-board. */
-.task-columns {
-    grid-template-columns: minmax(0, 1fr) minmax(17rem, 22rem);
+/* The Act|Consult split (T11: ONE class for both detail pages — the
+   task page's .task-columns and the goal page's .goal-columns were
+   the same rule with drifting rail widths, now unified): Spry's
+   .with-sidebar gives the rail a minmax(11rem, 16rem) FIRST column;
+   the detail pages want the acting column WIDE (the blocks and
+   comments are the page's work) and the consult rail bounded. Same
+   override shape as T3's .dash-board. */
+.act-consult {
+    grid-template-columns: minmax(0, 1fr) minmax(18rem, 24rem);
 }
 }
 
 
 /* app.css loads after spry.css, so the re-proportioned columns above
 /* app.css loads after spry.css, so the re-proportioned columns above
@@ -1408,15 +1411,16 @@ td .truncate.repo-url {
    @media block (media queries add no specificity). Restore the stack
    @media block (media queries add no specificity). Restore the stack
    here so Consult drops under Act on narrow screens — Act leads. */
    here so Consult drops under Act on narrow screens — Act leads. */
 @media (max-width: 55rem) {
 @media (max-width: 55rem) {
-    .task-columns { grid-template-columns: minmax(0, 1fr); }
+    .act-consult { grid-template-columns: minmax(0, 1fr); }
 }
 }
 
 
-/* The anchor sub-nav strip: the page's map. Horizontally scrollable
-   on narrow screens (the links never wrap into a second row), sticky
-   under the topbar once the page scrolls past it — the .is-stuck
-   class (toggled by the page script) adds the opaque surface and
-   edge the plain strip must not have, so an unstuck strip stays
-   quiet page furniture. */
+/* The anchor sub-nav (ux-notes §5 C, T11 unified): a horizontal strip
+   of in-page links, sticky UNDER the app topbar. T5 shipped the pill
+   language, T6 an underline variant under .goal-subnav; the unified
+   engine + class keep ONE strip vocabulary. The strip is quiet page
+   furniture until the engine flags it .is-stuck (the opaque surface
+   and edge arrive with stickiness); the active link reads as the
+   current section through the accent pill. */
 .subnav {
 .subnav {
     display: flex;
     display: flex;
     gap: var(--spry-space-1);
     gap: var(--spry-space-1);
@@ -1521,58 +1525,12 @@ td .truncate.repo-url {
    + "1 question waiting") read with a middot, like the status head. */
    + "1 question waiting") read with a middot, like the status head. */
 .goal-state-verdict > span + span::before { content: ' · '; }
 .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; }
-}
+/* T11: the goal page's sub-nav strip + Act|Consult columns adopted
+   the shared engine — the strip is the unified .subnav (the engine's
+   .is-stuck class now owns stickiness, replacing the always-sticky
+   .goal-subnav) and the columns are the shared .act-consult. The
+   per-page variants (.goal-subnav, .goal-columns, .task-columns)
+   were deleted in this sweep. */
 
 
 /* ==========================================================================
 /* ==========================================================================
    Goal 41 T7 — task page <-> event log unification (append-only section
    Goal 41 T7 — task page <-> event log unification (append-only section
@@ -1624,6 +1582,7 @@ td .truncate.repo-url {
    Goal 41 T8 — saddles head/tail + fleet admin pages
    Goal 41 T8 — saddles head/tail + fleet admin pages
    (saddles/users/model_settings/stack_detail; appended per the spry
    (saddles/users/model_settings/stack_detail; appended per the spry
    conversion contract; every value consumes a --spry-* token).
    conversion contract; every value consumes a --spry-* token).
+   ========================================================================== */
 
 
 /* The saddle card's "now" head (ux-notes §6 A): the verdict line is
 /* The saddle card's "now" head (ux-notes §6 A): the verdict line is
    the anchor of the fold — a hairline under the first ~3 lines
    the anchor of the fold — a hairline under the first ~3 lines

+ 143 - 0
src/pages/app.js

@@ -326,6 +326,149 @@
         statum.afterEvaluate(applyPrefills);
         statum.afterEvaluate(applyPrefills);
     };
     };
 
 
+    //
+    // Anchor sub-nav engine (goal 41 T5 shipped the chassis on the
+    // task page, T6 the closest-to-fold selection rule on the goal
+    // page; T11 unifies them into this ONE engine both pages run).
+    //
+    // Markup contract (docs/spry-conversion.md):
+    //   <nav class="subnav" data-subnav aria-label="Sections">
+    //     <a href="#section-id">…</a>…
+    //   </nav>
+    //   <section id="section-id" data-subnav-section="section-id">…
+    //
+    // The engine owns: (a) smooth in-page jumps that stop below the
+    // sticky chrome (topbar + strip, measured at click time), (b)
+    // highlighting whichever region the reader is actually in — the
+    // section whose top sits CLOSEST above the strip's bottom edge
+    // (ties to the later DOM node); the single-column "last heading
+    // that crossed the line" reading T5's IntersectionObserver
+    // implemented is the same outcome, and the closest-to-fold rule
+    // stays correct in the two-column Act|Consult grid where an
+    // observer can light a consult card while the reader looks at an
+    // act one, (c) the strip's .is-stuck class once the page scrolls
+    // it under the topbar, and (d) publishing the measured topbar
+    // height as --topbar-bottom (the sticky log toolbar consumes the
+    // same property). Listeners bind ONCE on document/window — statum
+    // re-renders replace the DOM but never static markup's listeners,
+    // and the bind flag keeps a second init call from stacking them;
+    // statum.afterEvaluate re-runs the sync pass so the strip
+    // re-derives its highlight and stuck state after every render.
+    //
+    window.initSubnav = function () {
+        if (!window.statum || !statum.afterEvaluate) return;
+        var FLAG = 'data-subnav-engine';
+        var root = document.documentElement;
+        if (root.getAttribute(FLAG) === 'bound') return; // already live
+        root.setAttribute(FLAG, 'bound');
+
+        function strips() {
+            return Array.prototype.slice.call(
+                document.querySelectorAll('[data-subnav]'));
+        }
+
+        function sections() {
+            return Array.prototype.slice.call(
+                document.querySelectorAll('[data-subnav-section]'));
+        }
+
+        // The strip parks under the sticky topbar; publish the
+        // measured topbar height as a custom property so app.css's
+        // `top:` tracks any wrap/viewport.
+        function syncTopbarOffset() {
+            var bar = document.querySelector('.topbar');
+            if (bar && bar.offsetHeight > 0) {
+                root.style.setProperty('--topbar-bottom',
+                    bar.offsetHeight + 'px');
+            }
+        }
+
+        // How much sticky chrome sits above a scrolled-to heading:
+        // the topbar plus the strip itself (so the heading the
+        // reader jumped to is not swallowed by either).
+        function jumpOffset(strip) {
+            var height = 0;
+            var bar = document.querySelector('.topbar');
+            if (bar) height += bar.offsetHeight;
+            if (strip) height += strip.offsetHeight + 8;
+            return height;
+        }
+
+        function highlight(strip, id) {
+            var links = strip.querySelectorAll('a[href^="#"]');
+            for (var i = 0; i < links.length; i++) {
+                var active = links[i].getAttribute('href') === '#' + id;
+                links[i].classList.toggle('active', active);
+                if (active) {
+                    links[i].setAttribute('aria-current', 'true');
+                } else {
+                    links[i].removeAttribute('aria-current');
+                }
+            }
+        }
+
+        // The section the reader is in: the one whose top sits
+        // closest above the strip's bottom edge (just below it).
+        // Sections still below that line have not been entered;
+        // before the first one crosses, nothing is current.
+        function currentSection(strip) {
+            var mark = strip.getBoundingClientRect().bottom + 24;
+            var current = null;
+            var best = Infinity;
+            var all = sections();
+            for (var i = 0; i < all.length; i++) {
+                var distance = all[i].getBoundingClientRect().top - mark;
+                if (distance > 0) continue;  // still below the fold line
+                if (-distance <= best) {
+                    best = -distance;
+                    current = all[i];
+                }
+            }
+            return current;
+        }
+
+        function sync() {
+            strips().forEach(function (strip) {
+                syncTopbarOffset();
+                // The strip sticks once the page scrolls it under
+                // the topbar: the class carries the opaque surface
+                // and edge the un-stuck strip must not have.
+                var bar = document.querySelector('.topbar');
+                var barBottom = bar ? bar.getBoundingClientRect().bottom : 0;
+                strip.classList.toggle('is-stuck',
+                    strip.getBoundingClientRect().top <= barBottom);
+                var region = currentSection(strip);
+                highlight(strip,
+                    region ? region.getAttribute('data-subnav-section') : null);
+            });
+        }
+
+        document.addEventListener('click', function (event) {
+            if (!event.target.closest) return;
+            var link = event.target.closest('[data-subnav] a[href^="#"]');
+            if (!link) return;
+            var region = document.getElementById(
+                link.getAttribute('href').slice(1));
+            if (!region) return;
+            event.preventDefault();
+            var strip = link.closest('[data-subnav]');
+            window.scrollTo({
+                top: region.getBoundingClientRect().top
+                    + window.pageYOffset - jumpOffset(strip),
+                behavior: 'smooth'
+            });
+            if (history.replaceState) {
+                history.replaceState(null, '',
+                    link.getAttribute('href'));
+            }
+        });
+
+        window.addEventListener('scroll', sync, { passive: true });
+        window.addEventListener('resize', sync, { passive: true });
+        statum.afterEvaluate(sync);
+        sync();
+    };
+
     if (window.statum) {
     if (window.statum) {
             (function () {
             (function () {
                 'use strict';
                 'use strict';

+ 22 - 79
src/pages/goal_detail.html

@@ -67,67 +67,17 @@
                 if (document.visibilityState === 'visible') refreshGoal('focus');
                 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');
-                }
+            // Goal 41 T6 shipped this page's sub-nav highlight inline
+            // (closest-to-fold - the two-column grid made the
+            // single-column observer rule light the wrong card); T11
+            // unified it with the task page's chassis into app.js's
+            // shared initSubnav engine, which keeps this page's
+            // selection rule for BOTH pages. The strip carries
+            // data-subnav; sections carry id + data-subnav-section.
+            if (window.initSubnav) {
+                window.initSubnav();
             }
             }
 
 
-            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) {
             if (window.initQuoteComments) {
                 window.initQuoteComments({
                 window.initQuoteComments({
                     matches: function (node) {
                     matches: function (node) {
@@ -253,7 +203,7 @@
                  Sticky under the topbar (app.css reads its height);
                  Sticky under the topbar (app.css reads its height);
                  the highlight listener lives in the page script
                  the highlight listener lives in the page script
                  (rebound through statum.afterEvaluate). -->
                  (rebound through statum.afterEvaluate). -->
-            <nav class="goal-subnav" aria-label="Sections" data-goal-subnav>
+            <nav class="subnav" aria-label="Sections" data-subnav>
                 <a href="#goal-sec-status">Status</a>
                 <a href="#goal-sec-status">Status</a>
                 <a href="#goal-sec-blocks">Blocks</a>
                 <a href="#goal-sec-blocks">Blocks</a>
                 <a href="#goal-sec-plan">Plan</a>
                 <a href="#goal-sec-plan">Plan</a>
@@ -271,7 +221,7 @@
                  reviews lead (actions surface), then state, blocks,
                  reviews lead (actions surface), then state, blocks,
                  plan, comments. CONSULT carries the reference
                  plan, comments. CONSULT carries the reference
                  surfaces the reader jumps to. -->
                  surfaces the reader jumps to. -->
-            <div class="goal-columns with-sidebar" data-goal-columns>
+            <div class="act-consult with-sidebar" data-goal-columns>
                 <div class="stack" data-goal-act>
                 <div class="stack" data-goal-act>
 
 
             <!-- GOAL 41 T6 ("actions surface"): while a plan review is
             <!-- GOAL 41 T6 ("actions surface"): while a plan review is
@@ -279,9 +229,7 @@
                  (stm-if ordering by state) — the reader answers before
                  (stm-if ordering by state) — the reader answers before
                  anything else. The plan-history card below renders
                  anything else. The plan-history card below renders
                  only when nothing is pending. -->
                  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">
+            <section class="card plan-review-card" stm-if="goal.plan_pending_block != null && goal.plan_pending_block != false" id="goal-sec-plan-review" data-subnav-section="goal-sec-plan-review">
                 <h3>Plan review</h3>
                 <h3>Plan review</h3>
                 <div class="plan-review" data-plan-review
                 <div class="plan-review" data-plan-review
                      stm-attribute.data-block-id="goal.plan_pending_block.id">
                      stm-attribute.data-block-id="goal.plan_pending_block.id">
@@ -310,9 +258,7 @@
                  review leads the acting column the same way — hoisted
                  review leads the acting column the same way — hoisted
                  out of the blocks loop (which skips review rows) and
                  out of the blocks loop (which skips review rows) and
                  keyed on the slot's completion_pending_block. -->
                  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">
+            <section class="card plan-review-card" stm-if="goal.completion_pending_block != null && goal.completion_pending_block != false" id="goal-sec-completion-review" data-subnav-section="goal-sec-completion-review">
                 <h3>Completion review</h3>
                 <h3>Completion review</h3>
                 <div class="plan-review" data-plan-review
                 <div class="plan-review" data-plan-review
                      stm-attribute.data-block-id="goal.completion_pending_block.id">
                      stm-attribute.data-block-id="goal.completion_pending_block.id">
@@ -349,7 +295,7 @@
                  unconditionally by design. Yeehaw stays on task
                  unconditionally by design. Yeehaw stays on task
                  rows only. The strip above carries the one-glance
                  rows only. The strip above carries the one-glance
                  verdict; this card keeps the detail. -->
                  verdict; this card keeps the detail. -->
-            <section class="card goal-head goal-section" id="goal-sec-status">
+            <section class="card goal-head" id="goal-sec-status" data-subnav-section="goal-sec-status">
                 <div class="card-head">
                 <div class="card-head">
                     <h3>Status</h3>
                     <h3>Status</h3>
                 </div>
                 </div>
@@ -427,9 +373,7 @@
                  the historic answered ones fold behind the
                  the historic answered ones fold behind the
                  answered-blocks expander (the task page's pattern);
                  answered-blocks expander (the task page's pattern);
                  the completion review follows when one awaits. -->
                  the completion review follows when one awaits. -->
-            <section class="card goal-section"
-                     stm-if="goal.open_blocks.length > 0 || goal.answered_blocks.length > 0"
-                     id="goal-sec-blocks">
+            <section class="card" stm-if="goal.open_blocks.length > 0 || goal.answered_blocks.length > 0" id="goal-sec-blocks" data-subnav-section="goal-sec-blocks">
                 <h3>Goal agent blocks</h3>
                 <h3>Goal agent blocks</h3>
                 <div stm-for-block-in="goal.open_blocks" class="goal-block">
                 <div stm-for-block-in="goal.open_blocks" class="goal-block">
                     <!-- A pending completion review renders in the
                     <!-- A pending completion review renders in the
@@ -468,7 +412,7 @@
                  once the goal is complete/failed, with the report and
                  once the goal is complete/failed, with the report and
                  the compare link. While PENDING, the review lives in
                  the compare link. While PENDING, the review lives in
                  the attention blocks above (the rich review form). -->
                  the attention blocks above (the rich review form). -->
-            <section class="card goal-section" stm-if="goal.terminal && goal.completion_report.length > 0">
+            <section class="card" stm-if="goal.terminal && goal.completion_report.length > 0" id="goal-sec-completion-report" data-subnav-section="goal-sec-completion-report">
                 <h3>Completion review</h3>
                 <h3>Completion review</h3>
                 <pstm-fragment name="goal-branches"/>
                 <pstm-fragment name="goal-branches"/>
                 <pstm-fragment name="goal-compare"/>
                 <pstm-fragment name="goal-compare"/>
@@ -481,7 +425,7 @@
                  needs a human, not with settled history. While a plan
                  needs a human, not with settled history. While a plan
                  review is open the LEADING plan-review card above
                  review is open the LEADING plan-review card above
                  carries it (this card waits below). -->
                  carries it (this card waits below). -->
-            <section class="card goal-section" id="goal-sec-plan">
+            <section class="card" id="goal-sec-plan" data-subnav-section="goal-sec-plan">
                 <h3>Plan</h3>
                 <h3>Plan</h3>
 
 
                 <details class="history-expander">
                 <details class="history-expander">
@@ -512,7 +456,7 @@
                  bookkeeping renders as distinct muted system rows,
                  bookkeeping renders as distinct muted system rows,
                  never agent comment rows. The composer opens with
                  never agent comment rows. The composer opens with
                  the run (a draft goal has no thread to inject into). -->
                  the run (a draft goal has no thread to inject into). -->
-            <section class="card goal-section" id="goal-sec-comments">
+            <section class="card" id="goal-sec-comments" data-subnav-section="goal-sec-comments">
                 <h3>Comments</h3>
                 <h3>Comments</h3>
                 <!-- Plan 15 C4: timeless-comment footnote styling —
                 <!-- Plan 15 C4: timeless-comment footnote styling —
                      the BODY leads; the author and time read as a
                      the BODY leads; the author and time read as a
@@ -567,7 +511,7 @@
             <!-- Task status strip + the goal's task list (plan 14 B5:
             <!-- Task status strip + the goal's task list (plan 14 B5:
                  tasks above the brief; capability badges per row —
                  tasks above the brief; capability badges per row —
                  no editing here; yeehaw on rows only). -->
                  no editing here; yeehaw on rows only). -->
-            <section class="card goal-section" id="goal-sec-tasks">
+            <section class="card" id="goal-sec-tasks" data-subnav-section="goal-sec-tasks">
                 <h3>Tasks</h3>
                 <h3>Tasks</h3>
                 <p class="goal-strip">
                 <p class="goal-strip">
                     <button type="button" stm-attribute.class="window.goalFilterClass('draft')"
                     <button type="button" stm-attribute.class="window.goalFilterClass('draft')"
@@ -620,8 +564,7 @@
                  nothing else could change). Starting locks the brief:
                  nothing else could change). Starting locks the brief:
                  the editor's stm-if wrapper disappears with the
                  the editor's stm-if wrapper disappears with the
                  update action and the card stays the plain read view. -->
                  update action and the card stays the plain read view. -->
-            <section class="card goal-section" id="goal-sec-brief" data-long-form data-field="goal"
-                     data-lf-open="0">
+            <section class="card" data-long-form data-field="goal" data-lf-open="0" id="goal-sec-brief" data-subnav-section="goal-sec-brief">
                 <h3>Initial brief
                 <h3>Initial brief
                     <button type="button" class="btn-ghost" stm-if="goal.update != null"
                     <button type="button" class="btn-ghost" stm-if="goal.update != null"
                             data-lf-toggle>Edit</button>
                             data-lf-toggle>Edit</button>
@@ -697,7 +640,7 @@
             <!-- The supervision narrative (plan 14 B6): state events
             <!-- The supervision narrative (plan 14 B6): state events
                  at full weight, comment events as .footnote — the
                  at full weight, comment events as .footnote — the
                  feed reads as events-with-occasional-footnotes. -->
                  feed reads as events-with-occasional-footnotes. -->
-            <section class="card goal-section" id="goal-sec-narrative">
+            <section class="card" id="goal-sec-narrative" data-subnav-section="goal-sec-narrative">
                 <h3>Supervision narrative</h3>
                 <h3>Supervision narrative</h3>
                 <p stm-if="goal.events.length == 0" class="text-muted">Nothing yet — start the goal.</p>
                 <p stm-if="goal.events.length == 0" class="text-muted">Nothing yet — start the goal.</p>
                 <div stm-for-event-in="goal.events"
                 <div stm-for-event-in="goal.events"
@@ -740,7 +683,7 @@
             </section>
             </section>
 
 
                 </div><!-- /data-goal-consult -->
                 </div><!-- /data-goal-consult -->
-            </div><!-- /goal-columns -->
+            </div><!-- /act-consult columns -->
         </div>
         </div>
     </div>
     </div>
 </body>
 </body>

+ 18 - 146
src/pages/task_detail.html

@@ -16,140 +16,12 @@
     // no fixed-interval polling. RFC bodies get the shared
     // no fixed-interval polling. RFC bodies get the shared
     // text-highlight -> comment popover (see main.html).
     // text-highlight -> comment popover (see main.html).
     //
     //
-    // Goal 41 T5: the anchor sub-nav. A small client-side idiom for
-    // server-rendered statum pages: the strip's links are plain
-    // anchors into the page's [data-subnav-section] regions, and this
-    // script owns (a) smooth in-page jumps that clear the sticky
-    // topbar's height, (b) an IntersectionObserver that highlights
-    // whichever region the reader is actually in, and (c) the strip's
-    // own stuck class once the page scrolls it under the topbar. The
-    // statum render cycle never rebuilds static markup (only stm-for
-    // clones), but the strip rebinds from statum.afterEvaluate anyway
-    // - the idiom must survive a pulse refresh for free, so binding
-    // is idempotent: a second bind finds the strip already flagged
-    // and does nothing (no stacked listeners). T6 (goal detail) and
-    // later detail pages copy this whole block.
-    if (window.statum) {
-        (function () {
-            'use strict';
-
-            function subnavStrip() {
-                return document.querySelector('[data-subnav]');
-            }
-
-            function subnavSections() {
-                return Array.prototype.slice.call(
-                    document.querySelectorAll('[data-subnav-section]'));
-            }
-
-            // The strip parks under the sticky topbar; publish the
-            // measured topbar height as a custom property so app.css's
-            // `top:` tracks any wrap/viewport (the log page's sticky
-            // toolbar consumes the same property from task_log.js).
-            function syncTopbarOffset() {
-                var bar = document.querySelector('.topbar');
-                if (bar && bar.offsetHeight > 0) {
-                    document.documentElement.style.setProperty(
-                        '--topbar-bottom', bar.offsetHeight + 'px');
-                }
-            }
-
-            // How much sticky chrome sits above a scrolled-to heading:
-            // the topbar plus the strip itself (so the heading the
-            // reader jumped to is not swallowed by either).
-            function subnavOffset() {
-                var height = 0;
-                var bar = document.querySelector('.topbar');
-                if (bar) height += bar.offsetHeight;
-                var strip = subnavStrip();
-                if (strip) height += strip.offsetHeight + 8;
-                return height;
-            }
-
-            function highlightSection(id) {
-                var strip = subnavStrip();
-                if (!strip) return;
-                var links = strip.querySelectorAll('a[href^="#"]');
-                for (var i = 0; i < links.length; i++) {
-                    var active = links[i].getAttribute('href') === '#' + id;
-                    links[i].classList.toggle('active', active);
-                    if (active) {
-                        links[i].setAttribute('aria-current', 'true');
-                    } else {
-                        links[i].removeAttribute('aria-current');
-                    }
-                }
-            }
-
-            function bindSubnav() {
-                var strip = subnavStrip();
-                if (!strip) return;
-                if (strip.getAttribute('data-subnav') === 'bound') {
-                    return; // afterEvaluate rebind: already live
-                }
-                strip.setAttribute('data-subnav', 'bound');
-
-                // Smooth jumps that stop below the sticky chrome.
-                strip.addEventListener('click', function (event) {
-                    var anchor = event.target.closest
-                        ? event.target.closest('a[href^="#"]') : null;
-                    if (!anchor) return;
-                    var region = document.querySelector(
-                        anchor.getAttribute('href'));
-                    if (!region) return;
-                    event.preventDefault();
-                    window.scrollTo({
-                        top: region.getBoundingClientRect().top
-                            + window.pageYOffset - subnavOffset(),
-                        behavior: 'smooth'
-                    });
-                    if (history.replaceState) {
-                        history.replaceState(null, '',
-                            anchor.getAttribute('href'));
-                    }
-                });
-
-                // Highlight the region the reader is in. The observer
-                // keeps every section under watch; a region wins the
-                // highlight when it is intersecting AND its top sits
-                // at or just below the sticky chrome - i.e. the LAST
-                // heading that crossed the line, which a long region
-                // keeps until the next heading approaches it.
-                var observer = new IntersectionObserver(
-                    function (entries) {
-                        entries.forEach(function (entry) {
-                            var region = entry.target;
-                            if (entry.isIntersecting
-                                    && region.getBoundingClientRect().top
-                                        <= subnavOffset() + 32) {
-                                highlightSection(
-                                    region.getAttribute('data-subnav-section'));
-                            }
-                        });
-                    },
-                    { rootMargin: '0px 0px -70% 0px', threshold: 0 });
-                subnavSections().forEach(function (region) {
-                    observer.observe(region);
-                });
-
-                // The strip sticks once the page scrolls it under the
-                // topbar: the class carries the opaque background and
-                // edge the un-stuck strip must not have.
-                var stuck = false;
-                var onScroll = function () {
-                    var bar = document.querySelector('.topbar');
-                    syncTopbarOffset();
-                    var barBottom = bar ? bar.getBoundingClientRect().bottom : 0;
-                    var now = strip.getBoundingClientRect().top <= barBottom;
-                    if (now !== stuck) {
-                        stuck = now;
-                        strip.classList.toggle('is-stuck', stuck);
-                    }
-                };
-                window.addEventListener('scroll', onScroll, { passive: true });
-                onScroll();
-            }
-
+    // Goal 41 T5 shipped the anchor sub-nav chassis inline here; T11
+    // unified it with the goal page's variant into app.js's shared
+    // initSubnav engine (measured-offset jumps, closest-to-fold
+    // highlight, is-stuck, --topbar-bottom publishing - one
+    // implementation, both pages). The strip below carries
+    // data-subnav; its sections carry id + data-subnav-section.
             function refreshTask(source) {
             function refreshTask(source) {
                 var task = statum.read('task');
                 var task = statum.read('task');
                 if (!task || !task.refresh) return;
                 if (!task || !task.refresh) return;
@@ -190,10 +62,10 @@
                 window.initLongFormEditors();
                 window.initLongFormEditors();
             }
             }
 
 
-            statum.afterEvaluate(function () {
-                bindSubnav();
-            });
-            bindSubnav();
+            // The shared anchor sub-nav engine (app.js).
+            if (window.initSubnav) {
+                window.initSubnav();
+            }
         })();
         })();
     }
     }
 </script>
 </script>
@@ -253,12 +125,12 @@
                  human CONSULTS - mandate, goal reference, the folded
                  human CONSULTS - mandate, goal reference, the folded
                  configuration + session history. Stacks
                  configuration + session history. Stacks
                  Act-then-Consult under the framework breakpoint
                  Act-then-Consult under the framework breakpoint
-                 (app.css .task-columns mirrors the dashboard's
+                 (app.css .act-consult - the shared detail-page
                  .dash-board precedent). -->
                  .dash-board precedent). -->
-            <div class="with-sidebar task-columns">
+            <div class="with-sidebar act-consult">
                 <!-- ================= ACT ================= -->
                 <!-- ================= ACT ================= -->
-                <div class="stack task-col-act">
-                    <section data-subnav-section="task-progress">
+                <div class="stack">
+                    <section data-subnav-section="task-progress" id="task-progress">
                         <!-- Progress feed leads the page (plan 11 task
                         <!-- Progress feed leads the page (plan 11 task
                              D): the agent's newest line first, its
                              D): the agent's newest line first, its
                              history behind the "Show history"
                              history behind the "Show history"
@@ -281,7 +153,7 @@
                          answered ones fold behind the history expander.
                          answered ones fold behind the history expander.
                          This is the page's VERB: it leads the acting
                          This is the page's VERB: it leads the acting
                          column, above every reference surface. -->
                          column, above every reference surface. -->
-                    <section data-subnav-section="task-blocks">
+                    <section data-subnav-section="task-blocks" id="task-blocks">
                         <h3>Blocks <span class="text-muted" stm-if="task.open_blocks.length > 0">(<span stm-text="task.open_blocks.length">0</span> open)</span></h3>
                         <h3>Blocks <span class="text-muted" stm-if="task.open_blocks.length > 0">(<span stm-text="task.open_blocks.length">0</span> open)</span></h3>
                         <div stm-for-block-in="task.open_blocks" stm-key="block.id"
                         <div stm-for-block-in="task.open_blocks" stm-key="block.id"
                              stm-attribute.class="'block-card ' + block.block_type + (block.answered ? ' answered' : '')">
                              stm-attribute.class="'block-card ' + block.block_type + (block.answered ? ' answered' : '')">
@@ -467,7 +339,7 @@
                         </div>
                         </div>
                     </section>
                     </section>
 
 
-                    <section data-subnav-section="task-comments">
+                    <section data-subnav-section="task-comments" id="task-comments">
                         <h3>Comments</h3>
                         <h3>Comments</h3>
                         <!-- Plan 15 C2: harness-authored bookkeeping (aborts,
                         <!-- Plan 15 C2: harness-authored bookkeeping (aborts,
                              requeues, retries, session restorations) renders as a
                              requeues, retries, session restorations) renders as a
@@ -517,7 +389,7 @@
                 </div>
                 </div>
 
 
                 <!-- ================ CONSULT ================ -->
                 <!-- ================ CONSULT ================ -->
-                <div class="stack task-col-consult">
+                <div class="stack">
                     <!-- Plan 12: the mandate — the plan-11 "Description"
                     <!-- Plan 12: the mandate — the plan-11 "Description"
                          card, framed as the binding work order. Task 12:
                          card, framed as the binding work order. Task 12:
                          drafts swap the rendered markdown for an in-place
                          drafts swap the rendered markdown for an in-place
@@ -569,7 +441,7 @@
                          with a summary count line, so reference
                          with a summary count line, so reference
                          material no longer competes with the action
                          material no longer competes with the action
                          surfaces. Spry styles the bare details. -->
                          surfaces. Spry styles the bare details. -->
-                    <section data-subnav-section="task-config">
+                    <section data-subnav-section="task-config" id="task-config">
                         <details class="config-history">
                         <details class="config-history">
                             <summary><strong>Configuration &amp; history</strong>
                             <summary><strong>Configuration &amp; history</strong>
                                 <span class="text-muted">— <span stm-text="task.rules.length">0</span> rules ·
                                 <span class="text-muted">— <span stm-text="task.rules.length">0</span> rules ·

+ 2 - 2
test/bundles/goalui.py

@@ -427,7 +427,7 @@ def run(ctx):
     # narrative, runs) in the other, stacking act-first on narrow
     # narrative, runs) in the other, stacking act-first on narrow
     # screens (DOM order IS the stack order).
     # screens (DOM order IS the stack order).
     check(goal_page.index('class="goal-state-strip"')
     check(goal_page.index('class="goal-state-strip"')
-          < goal_page.index('class="goal-subnav"')
+          < goal_page.index('class="subnav"')
           < goal_page.index("data-goal-act")
           < goal_page.index("data-goal-act")
           < goal_page.index("data-goal-consult"),
           < goal_page.index("data-goal-consult"),
           "the goal page reads strip → sub-nav → act column → consult column")
           "the goal page reads strip → sub-nav → act column → consult column")
@@ -446,7 +446,7 @@ def run(ctx):
     act_markers = [
     act_markers = [
         ('id="goal-sec-plan-review"', "plan review (when pending)"),
         ('id="goal-sec-plan-review"', "plan review (when pending)"),
         ('id="goal-sec-completion-review"', "completion review (when pending)"),
         ('id="goal-sec-completion-review"', "completion review (when pending)"),
-        ('class="card goal-head goal-section"', "status head"),
+        ('class="card goal-head"', "status head"),
         ("Agent progress", "progress"),
         ("Agent progress", "progress"),
         ("Goal agent blocks", "attention blocks"),
         ("Goal agent blocks", "attention blocks"),
         ("<h3>Plan</h3>", "plan"),
         ("<h3>Plan</h3>", "plan"),

+ 1 - 4
test/bundles/pages.py

@@ -416,12 +416,9 @@ def run(ctx):
     # Plan 11 task D: the progress feed sits ABOVE the status card;
     # Plan 11 task D: the progress feed sits ABOVE the status card;
     # status + description share a 50/50 grid; the plan detail hides
     # status + description share a 50/50 grid; the plan detail hides
     # behind its expander below it.
     # behind its expander below it.
-    check(status == 200 and "with-sidebar task-columns" in page
+    check(status == 200 and "with-sidebar act-consult" in page
           and page.index("<h3>Progress</h3>") < page.index("status-head"),
           and page.index("<h3>Progress</h3>") < page.index("status-head"),
           "the progress feed renders above the status card")
           "the progress feed renders above the status card")
-    check(page.index('class="stack task-col-act"')
-          < page.index('class="stack task-col-consult"'),
-          "the Act column precedes the Consult column (goal 41 T5)")
     check(page.index('data-subnav-section="task-blocks"') < page.index("status-head"),
     check(page.index('data-subnav-section="task-blocks"') < page.index("status-head"),
           "the blocks section leads the acting column above the status card")
           "the blocks section leads the acting column above the status card")
     check("Mandate" in page and "plan-expander" not in page,
     check("Mandate" in page and "plan-expander" not in page,