From 882379954857f93b9a365f5e7f3f203e916f0545 Mon Sep 17 00:00:00 2001 From: Hampton Lintorn-Catlin Date: Tue, 28 Jul 2026 14:24:45 -0400 Subject: [PATCH] Keep live nav state synchronized Reconnect the scroll-aware title after Turbo replaces the plan header and sync renamed titles into the sticky nav. Avoid exposing hidden navigation controls or stale unread counts, and keep header anchor jumps clear of the sticky bar. Amp-Thread-ID: https://ampcode.com/threads/T-019fa9f4-9144-76b8-a764-98d7ba1cc0e9 Co-authored-by: Amp --- .../assets/stylesheets/coplan/application.css | 25 ++------ .../coplan/nav_title_controller.js | 59 +++++++++++++++---- .../views/layouts/coplan/application.html.erb | 4 +- 3 files changed, 56 insertions(+), 32 deletions(-) diff --git a/engine/app/assets/stylesheets/coplan/application.css b/engine/app/assets/stylesheets/coplan/application.css index 94813ee5..189855c1 100644 --- a/engine/app/assets/stylesheets/coplan/application.css +++ b/engine/app/assets/stylesheets/coplan/application.css @@ -2372,13 +2372,16 @@ img.avatar { display: block; } -/* Add scroll-margin to headings so they clear the sticky nav */ +/* Add scroll-margin to headings — and the plan's own masthead header — so + they clear the sticky nav. The nav-title control scrolls to the very top + in JS; this keeps a no-JS #plan-header jump landing below the bar too. */ .markdown-rendered h1, .markdown-rendered h2, .markdown-rendered h3, .markdown-rendered h4, .markdown-rendered h5, -.markdown-rendered h6 { +.markdown-rendered h6, +.page-header--plan { scroll-margin-top: calc(var(--nav-height) + var(--space-lg)); } @@ -4159,24 +4162,6 @@ img.avatar { background: var(--color-border); } -/* Unread count pill on a menu row (Notifications), pushed to the trailing - edge so the label stays left-aligned with its siblings. */ -.menu__badge { - margin-left: auto; - display: inline-flex; - align-items: center; - justify-content: center; - min-width: 18px; - height: 18px; - padding: 0 5px; - font-size: 0.7rem; - font-weight: 700; - color: var(--color-text-inverse); - background: var(--color-danger); - border-radius: 9999px; - line-height: 1; -} - /* Notifications is a bar control (the bell) whenever there's room; this menu copy is the phone fallback, revealed only once the bell folds away. */ .menu__item--notifications { diff --git a/engine/app/javascript/controllers/coplan/nav_title_controller.js b/engine/app/javascript/controllers/coplan/nav_title_controller.js index c9ed8824..4eae6746 100644 --- a/engine/app/javascript/controllers/coplan/nav_title_controller.js +++ b/engine/app/javascript/controllers/coplan/nav_title_controller.js @@ -9,22 +9,27 @@ export default class extends Controller { static values = { anchor: { type: String, default: "plan-header" } } connect() { - const anchor = document.getElementById(this.anchorValue) - // No header to track (empty doc, unexpected markup) — stay hidden - // rather than showing a title that never has a home to return to. - if (!anchor) return + // #plan-header is stream-replaced on every live plan update, which would + // strand an observer bound to the old (detached) node. The .plan-masthead + // wrapper around it is never itself a stream target, so watch that: when + // it mutates we re-point the IntersectionObserver at the fresh header and + // re-sync the title copy from it. + this.masthead = document.querySelector(".plan-masthead") + this.textEl = this.element.querySelector(".site-nav__doc-title-text") + this._observeAnchor() - this._observer = new IntersectionObserver( - ([entry]) => this._setVisible(!entry.isIntersecting), - // Trip the moment the header clears the sticky nav, not the very top - // of the viewport — otherwise the title would linger under the bar. - { rootMargin: `-${this._navHeightPx()}px 0px 0px 0px`, threshold: 0 } - ) - this._observer.observe(anchor) + if (this.masthead) { + this._mutation = new MutationObserver(() => { + this._observeAnchor() + this._syncTitle() + }) + this._mutation.observe(this.masthead, { childList: true, subtree: true, characterData: true }) + } } disconnect() { this._observer?.disconnect() + this._mutation?.disconnect() this._setVisible(false) } @@ -37,8 +42,40 @@ export default class extends Controller { window.scrollTo({ top: 0, behavior: "smooth" }) } + // (Re)bind the IntersectionObserver to the current header element. Only + // rebuilds when the node actually changed, so routine masthead updates + // (presence, toolbar) don't churn the observer. + _observeAnchor() { + const anchor = document.getElementById(this.anchorValue) + if (!anchor || anchor === this._anchor) return + this._anchor = anchor + + this._observer?.disconnect() + this._observer = new IntersectionObserver( + ([entry]) => this._setVisible(!entry.isIntersecting), + // Trip the moment the header clears the sticky nav, not the very top + // of the viewport — otherwise the title would linger under the bar. + { rootMargin: `-${this._navHeightPx()}px 0px 0px 0px`, threshold: 0 } + ) + this._observer.observe(anchor) + } + + // Keep the sticky copy in step with the live header, so a rename that + // arrives over the wire doesn't leave a stale name pinned to the bar. + _syncTitle() { + const title = this.masthead?.querySelector(".page-header__title")?.textContent?.trim() + if (title && this.textEl && this.textEl.textContent !== title) { + this.textEl.textContent = title + } + } + _setVisible(visible) { this.element.classList.toggle("site-nav__doc-title--visible", visible) + // Collapsed, it's decorative and must stay out of the tab order; once + // shown it's a real return-to-top control, so expose it to keyboard and + // screen-reader users too. + this.element.setAttribute("aria-hidden", String(!visible)) + this.element.tabIndex = visible ? 0 : -1 } // --nav-height is authored in rem; resolve it to px for rootMargin. diff --git a/engine/app/views/layouts/coplan/application.html.erb b/engine/app/views/layouts/coplan/application.html.erb index 10b8e3dc..d6aa32f0 100644 --- a/engine/app/views/layouts/coplan/application.html.erb +++ b/engine/app/views/layouts/coplan/application.html.erb @@ -91,7 +91,9 @@ <%= link_to coplan.notifications_path, class: "menu__item menu__item--notifications", role: "menuitem" do %> Notifications - <% if unread > 0 %><%= unread %><% end %> + <%# No count here: only #inbox-badge gets live updates, so a + number rendered once would go stale. Unread is signalled + live by the dot on the ☰ button instead. %> <% end %> <%= link_to coplan.profile_path(current_user.username.presence || current_user.id), class: "menu__item", role: "menuitem" do %>