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 %><% 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 %>