소스 검색

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 일 전
부모
커밋
993543612c

+ 14 - 0
src/Json.vala

@@ -5,6 +5,20 @@ using Statum;
 
 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 —
      * 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; }
     }
 
-    /**
-     * 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
      * and the advisor actions ({@link SendAdvisorMessageAction} answers
@@ -176,11 +167,11 @@ namespace ClankerSaddle {
                     suggestions.to_immutable_buffer());
                 // Goal 41 T9: the breadcrumb trail (Dashboard / Chats /
                 // <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());
             } else {
                 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; }
     }
 
-    /**
-     * 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; }
@@ -294,7 +284,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<CrumbView> crumbs { get; set; default = new Vector<CrumbView>(); }
         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
                 // renders it; the leaf carries no href and reads
                 // aria-current from the partial).
-                view.crumbs.add(new GoalCrumbView() {
+                view.crumbs.add(new CrumbView() {
                     href = "/", label = "Dashboard"
                 });
-                view.crumbs.add(new GoalCrumbView() {
+                view.crumbs.add(new CrumbView() {
                     href = "/goals", label = "Goals"
                 });
-                view.crumbs.add(new GoalCrumbView() {
+                view.crumbs.add(new CrumbView() {
                     label = @"#$(goal_id)"
                 });
                 foreach (var block in view.blocks) {
@@ -423,7 +413,7 @@ 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>(
+            pub_root["crumbs"] = StateJson.list<CrumbView>(
                 view.crumbs.to_immutable_buffer());
             pub_root["plan_revisions"] = StateJson.list<GoalBlockView>(
                 plan_revisions.to_immutable_buffer());

+ 5 - 14
src/entrypoints/ProjectsEntrypoint.vala

@@ -121,15 +121,6 @@ namespace ClankerSaddle {
         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. */
     public class ProjectRuleRowView : Object {
         public int64 link_id { get; set; }
@@ -465,11 +456,11 @@ namespace ClankerSaddle {
             // Goal 41 T9: the breadcrumb trail (Dashboard / Projects /
             // <name>) — the shared partial renders it, replacing the
             // hand-drawn "All projects" anchor.
-            var crumbs = new Vector<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() {
                 type_name = "project",

+ 0 - 11
src/entrypoints/TaskDetailEntrypoint.vala

@@ -60,17 +60,6 @@ namespace ClankerSaddle {
         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. */
     public class TaskRuleRowView : Object {
         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"
    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
    shape): the description sits inside the composer card, above the
@@ -1395,12 +1395,15 @@ td .truncate.repo-url {
    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
@@ -1408,15 +1411,16 @@ td .truncate.repo-url {
    @media block (media queries add no specificity). Restore the stack
    here so Consult drops under Act on narrow screens — Act leads. */
 @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 {
     display: flex;
     gap: var(--spry-space-1);
@@ -1521,58 +1525,12 @@ td .truncate.repo-url {
    + "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; }
-}
+/* 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
@@ -1624,6 +1582,7 @@ td .truncate.repo-url {
    Goal 41 T8 — saddles head/tail + fleet admin pages
    (saddles/users/model_settings/stack_detail; appended per the spry
    conversion contract; every value consumes a --spry-* token).
+   ========================================================================== */
 
 /* 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

+ 143 - 0
src/pages/app.js

@@ -326,6 +326,149 @@
         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) {
             (function () {
                 'use strict';

+ 22 - 79
src/pages/goal_detail.html

@@ -67,67 +67,17 @@
                 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) {
                 window.initQuoteComments({
                     matches: function (node) {
@@ -253,7 +203,7 @@
                  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>
+            <nav class="subnav" aria-label="Sections" data-subnav>
                 <a href="#goal-sec-status">Status</a>
                 <a href="#goal-sec-blocks">Blocks</a>
                 <a href="#goal-sec-plan">Plan</a>
@@ -271,7 +221,7 @@
                  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="act-consult with-sidebar" data-goal-columns>
                 <div class="stack" data-goal-act>
 
             <!-- GOAL 41 T6 ("actions surface"): while a plan review is
@@ -279,9 +229,7 @@
                  (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">
+            <section class="card plan-review-card" stm-if="goal.plan_pending_block != null && goal.plan_pending_block != false" id="goal-sec-plan-review" data-subnav-section="goal-sec-plan-review">
                 <h3>Plan review</h3>
                 <div class="plan-review" data-plan-review
                      stm-attribute.data-block-id="goal.plan_pending_block.id">
@@ -310,9 +258,7 @@
                  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">
+            <section class="card plan-review-card" stm-if="goal.completion_pending_block != null && goal.completion_pending_block != false" id="goal-sec-completion-review" data-subnav-section="goal-sec-completion-review">
                 <h3>Completion review</h3>
                 <div class="plan-review" data-plan-review
                      stm-attribute.data-block-id="goal.completion_pending_block.id">
@@ -349,7 +295,7 @@
                  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">
+            <section class="card goal-head" id="goal-sec-status" data-subnav-section="goal-sec-status">
                 <div class="card-head">
                     <h3>Status</h3>
                 </div>
@@ -427,9 +373,7 @@
                  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 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>
                 <div stm-for-block-in="goal.open_blocks" class="goal-block">
                     <!-- A pending completion review renders in the
@@ -468,7 +412,7 @@
                  once the goal is complete/failed, with the report and
                  the compare link. While PENDING, the review lives in
                  the attention blocks above (the rich review form). -->
-            <section class="card 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>
                 <pstm-fragment name="goal-branches"/>
                 <pstm-fragment name="goal-compare"/>
@@ -481,7 +425,7 @@
                  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">
+            <section class="card" id="goal-sec-plan" data-subnav-section="goal-sec-plan">
                 <h3>Plan</h3>
 
                 <details class="history-expander">
@@ -512,7 +456,7 @@
                  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">
+            <section class="card" id="goal-sec-comments" data-subnav-section="goal-sec-comments">
                 <h3>Comments</h3>
                 <!-- Plan 15 C4: timeless-comment footnote styling —
                      the BODY leads; the author and time read as a
@@ -567,7 +511,7 @@
             <!-- 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 goal-section" id="goal-sec-tasks">
+            <section class="card" id="goal-sec-tasks" data-subnav-section="goal-sec-tasks">
                 <h3>Tasks</h3>
                 <p class="goal-strip">
                     <button type="button" stm-attribute.class="window.goalFilterClass('draft')"
@@ -620,8 +564,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 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
                     <button type="button" class="btn-ghost" stm-if="goal.update != null"
                             data-lf-toggle>Edit</button>
@@ -697,7 +640,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 goal-section" id="goal-sec-narrative">
+            <section class="card" id="goal-sec-narrative" data-subnav-section="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"
@@ -740,7 +683,7 @@
             </section>
 
                 </div><!-- /data-goal-consult -->
-            </div><!-- /goal-columns -->
+            </div><!-- /act-consult columns -->
         </div>
     </div>
 </body>

+ 18 - 146
src/pages/task_detail.html

@@ -16,140 +16,12 @@
     // no fixed-interval polling. RFC bodies get the shared
     // 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) {
                 var task = statum.read('task');
                 if (!task || !task.refresh) return;
@@ -190,10 +62,10 @@
                 window.initLongFormEditors();
             }
 
-            statum.afterEvaluate(function () {
-                bindSubnav();
-            });
-            bindSubnav();
+            // The shared anchor sub-nav engine (app.js).
+            if (window.initSubnav) {
+                window.initSubnav();
+            }
         })();
     }
 </script>
@@ -253,12 +125,12 @@
                  human CONSULTS - mandate, goal reference, the folded
                  configuration + session history. Stacks
                  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). -->
-            <div class="with-sidebar task-columns">
+            <div class="with-sidebar act-consult">
                 <!-- ================= 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
                              D): the agent's newest line first, its
                              history behind the "Show history"
@@ -281,7 +153,7 @@
                          answered ones fold behind the history expander.
                          This is the page's VERB: it leads the acting
                          column, above every reference surface. -->
-                    <section data-subnav-section="task-blocks">
+                    <section data-subnav-section="task-blocks" id="task-blocks">
                         <h3>Blocks <span class="text-muted" stm-if="task.open_blocks.length > 0">(<span stm-text="task.open_blocks.length">0</span> open)</span></h3>
                         <div stm-for-block-in="task.open_blocks" stm-key="block.id"
                              stm-attribute.class="'block-card ' + block.block_type + (block.answered ? ' answered' : '')">
@@ -467,7 +339,7 @@
                         </div>
                     </section>
 
-                    <section data-subnav-section="task-comments">
+                    <section data-subnav-section="task-comments" id="task-comments">
                         <h3>Comments</h3>
                         <!-- Plan 15 C2: harness-authored bookkeeping (aborts,
                              requeues, retries, session restorations) renders as a
@@ -517,7 +389,7 @@
                 </div>
 
                 <!-- ================ CONSULT ================ -->
-                <div class="stack task-col-consult">
+                <div class="stack">
                     <!-- Plan 12: the mandate — the plan-11 "Description"
                          card, framed as the binding work order. Task 12:
                          drafts swap the rendered markdown for an in-place
@@ -569,7 +441,7 @@
                          with a summary count line, so reference
                          material no longer competes with the action
                          surfaces. Spry styles the bare details. -->
-                    <section data-subnav-section="task-config">
+                    <section data-subnav-section="task-config" id="task-config">
                         <details class="config-history">
                             <summary><strong>Configuration &amp; history</strong>
                                 <span class="text-muted">— <span stm-text="task.rules.length">0</span> rules ·

+ 2 - 2
test/bundles/goalui.py

@@ -427,7 +427,7 @@ def run(ctx):
     # 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('class="subnav"')
           < goal_page.index("data-goal-act")
           < goal_page.index("data-goal-consult"),
           "the goal page reads strip → sub-nav → act column → consult column")
@@ -446,7 +446,7 @@ def run(ctx):
     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"),
+        ('class="card goal-head"', "status head"),
         ("Agent progress", "progress"),
         ("Goal agent blocks", "attention blocks"),
         ("<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;
     # status + description share a 50/50 grid; the plan detail hides
     # 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"),
           "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"),
           "the blocks section leads the acting column above the status card")
     check("Mandate" in page and "plan-expander" not in page,