From 50d69036ccdb65c05e9b9828ea7454086691cfd8 Mon Sep 17 00:00:00 2001 From: Hampton Lintorn-Catlin Date: Mon, 27 Jul 2026 13:51:17 -0500 Subject: [PATCH 1/4] Nav hierarchy: scroll-aware title, calmer bar, one menu MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The signed-in nav had no clear order of importance — Workspace, Feed, a search box, a settings gear, a notifications bell, the user's name, and sign-out all competed for the bar at once, and a plan's title scrolled away entirely (following a comment notification dropped you in with no "where am I?"). Restructure the bar into three legible zones — identity → context → tools: - Logo is the Workspace home; the redundant "Workspace" link is gone. - A scroll-aware document title fades into the bar (IntersectionObserver on #plan-header) once the plan's own header scrolls behind it, so wayfinding costs zero space until you need it. Drop the now-pointless "Contents" label from the outline sidebar too. - Feed, Profile, Settings, and Sign out fold into one ☰ menu (reuses the app's popover menu — native light-dismiss + Esc). - Notifications stays a first-class bar control whenever there's room: the bell keeps its inline peek panel. Below the phone breakpoint the whole control folds into the menu (Notifications links to the full page there) and the unread count reappears as a dot on ☰. Touch: the ☰, folded search icon, and menu rows are ≥44px hit areas on phones and any coarse pointer. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../assets/stylesheets/coplan/application.css | 262 ++++++++++++------ .../coplan/inbox_badge_controller.js | 6 +- .../coplan/nav_title_controller.js | 41 +++ engine/app/views/coplan/plans/show.html.erb | 13 +- .../views/layouts/coplan/application.html.erb | 100 +++++-- 5 files changed, 306 insertions(+), 116 deletions(-) create mode 100644 engine/app/javascript/controllers/coplan/nav_title_controller.js diff --git a/engine/app/assets/stylesheets/coplan/application.css b/engine/app/assets/stylesheets/coplan/application.css index ce3621f7..2e823f70 100644 --- a/engine/app/assets/stylesheets/coplan/application.css +++ b/engine/app/assets/stylesheets/coplan/application.css @@ -384,73 +384,119 @@ img, svg { color: var(--color-primary); } -.site-nav__links { +/* The right cluster — search, notifications, menu — pinned to the right so + the bar reads left-to-right as identity → context → tools. Nothing here + shrinks: a search crushed to a sliver or a ☰ that drifts reads as breakage + (search folds to an icon at ≤900px, the bell into the menu at ≤640px). */ +.site-nav__right { display: flex; align-items: center; - gap: var(--space-lg); - list-style: none; - margin-left: var(--space-sm); - margin-right: auto; - flex-shrink: 0; + gap: var(--space-md); + margin-left: auto; + min-width: 0; } -.site-nav__links a { - color: var(--color-text-muted); - font-size: var(--text-sm); - font-weight: 500; - transition: color 0.15s; +.site-nav__menu, +.inbox-dropdown { + flex-shrink: 0; } -.site-nav__links a:hover { - color: var(--color-text); - text-decoration: none; +.site-nav__menu { + position: relative; } -.site-nav__links a.site-nav__link--active { - color: var(--color-text); - font-weight: 600; +.site-nav__menu-btn { + position: relative; + display: inline-flex; + align-items: center; + justify-content: center; + padding: 8px; + border: none; + background: none; + border-radius: var(--radius); + color: var(--color-text-muted); + cursor: pointer; + transition: color 0.15s, background 0.15s; } -.site-nav__profile-link { - color: inherit; - text-decoration: none; +.site-nav__menu-btn:hover { + color: var(--color-text); + background: var(--color-bg); } -.site-nav__profile-link:hover { - color: var(--color-text); +/* When the bell has folded into the menu (phone widths), the ☰ carries a + small unread dot so a waiting notification is still visible at a glance. + coplan--inbox-badge toggles .site-nav--has-unread from the live count; + the dot itself only shows once the bell is gone (see the phone block). */ +.site-nav__menu-btn::after { + content: ""; + position: absolute; + top: 6px; + right: 6px; + width: 8px; + height: 8px; + border-radius: 9999px; + background: var(--color-danger); + display: none; } -/* The controls may compress as a group (the user's name truncates), but - the icons themselves never shrink — a gear crushed to a sliver, or a - bell whose badge outlives it, reads as breakage. */ -.site-nav__right { +/* Scroll-aware document title in the top nav. Collapsed to zero width when + inactive so it steals no space and causes no reflow at the top of a plan; + coplan--nav-title flips --visible once the header scrolls behind the bar. + It lives in the left "where am I?" cluster (see .site-nav__inner). */ +.site-nav__doc-title { display: flex; align-items: center; - gap: var(--space-lg); - margin-left: auto; + gap: var(--space-xs); + /* Barely shrinkable itself; the search box (below) yields first and far + faster, down to a usable floor. So a short title shows in full, and a + long one only ellipsizes once the search has already given up its + slack — never the other way round. */ + flex: 0 1 auto; min-width: 0; + max-width: 0; + overflow: hidden; + opacity: 0; + color: var(--color-text-muted); + font-size: var(--text-sm); + font-weight: 600; + line-height: 1; + white-space: nowrap; + text-decoration: none; + pointer-events: none; + /* Grow a touch slower than the fade, so it reads as sliding in from the + links rather than shoving the search box aside. */ + transition: max-width 0.28s ease, opacity 0.18s ease; } -.site-nav__icon-link, -.site-nav__bell, -.inbox-dropdown { - flex-shrink: 0; +.site-nav__doc-title--visible { + max-width: 22rem; + opacity: 1; + pointer-events: auto; } -.site-nav__user { - display: flex; - align-items: center; - gap: var(--space-sm); - color: var(--color-text-muted); - font-size: var(--text-sm); - white-space: nowrap; - min-width: 0; +.site-nav__doc-title:hover { + color: var(--color-text); } -.site-nav__profile-link { +.site-nav__doc-title-text { overflow: hidden; text-overflow: ellipsis; - white-space: nowrap; +} + +.site-nav__doc-title .plan-type-icon { + flex-shrink: 0; +} + +/* The title sits in the left cluster right after the brand; .site-nav__right + is pushed away by its own margin-left:auto, so the title simply grows into + the free space between them — no margin juggling needed now that the bar + holds nothing else on the left. On a phone it's the ONLY plan context (the + Contents sidebar is hidden < 1024px), so let it take more of the row. */ +@media (max-width: 640px) { + .site-nav__doc-title--visible { + max-width: 58vw; + } } /* Main content */ @@ -2185,21 +2231,13 @@ img.avatar { margin-right: 0; } +/* No label anymore — just the collapse control, nudged to the right edge + above the outline. No border/separator: there's nothing to separate. */ .content-nav__header { display: flex; align-items: center; - justify-content: space-between; - padding-bottom: var(--space-sm); - margin-bottom: var(--space-sm); - border-bottom: 1px solid var(--color-border); -} - -.content-nav__title { - font-size: var(--text-sm); - font-weight: 600; - color: var(--color-text-muted); - text-transform: uppercase; - letter-spacing: 0.05em; + justify-content: flex-end; + margin-bottom: var(--space-xs); } .content-nav__toggle { @@ -2371,23 +2409,6 @@ img.avatar { display: inline-flex; } -.site-nav__icon-link { - display: inline-flex; - align-items: center; - justify-content: center; - color: var(--color-text-muted); - padding: var(--space-xs); - border-radius: var(--radius); - line-height: normal; - transition: color 0.15s, background 0.15s; -} - -.site-nav__icon-link:hover { - color: var(--color-text); - background: var(--color-bg); - text-decoration: none; -} - .site-nav__bell { position: relative; display: inline-flex; @@ -3842,7 +3863,7 @@ img.avatar { align-items: center; gap: var(--space-sm); padding: 6px 10px; - margin: 0 var(--space-md); + margin: 0; flex: 0 1 320px; min-width: 0; background: var(--color-bg); @@ -4139,6 +4160,30 @@ 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 { + display: none; +} + /* Full-width state banner on the plan page (e.g. archived notice). */ .plan-banner { display: flex; @@ -5358,13 +5403,28 @@ img.avatar { margin: 0; border-radius: 9999px; } +} + +/* ---- Touch devices (any width) ---- + A finger needs a bigger target than a cursor. On coarse pointers the bar's + icon controls and the menu rows grow to ~44px regardless of screen width, + so a touch tablet with the bell still on the bar is just as tappable. */ +@media (pointer: coarse) { + .site-nav__menu-btn, + .site-nav__bell { + min-width: 44px; + min-height: 44px; + } - .site-nav__right { + .menu__item { + min-height: 44px; + padding: 10px var(--space-sm); gap: var(--space-md); } - .site-nav__profile-link { - max-width: 9rem; + .menu__item svg { + width: 18px; + height: 18px; } } @@ -5378,21 +5438,55 @@ img.avatar { gap: var(--space-sm); } - /* Icons over labels: brand text, the settings gear, and the sign-out - link all yield to the things a phone visit is actually for. */ - .site-nav__brand-text, - .site-nav__icon-link, - .site-nav__user a[data-turbo-method="delete"] { + /* Icons over labels: the brand wordmark yields to the logo alone, since + search and the ☰ menu are what a phone visit reaches for. */ + .site-nav__brand-text { + display: none; + } + + /* No room for the bell + its peek panel — the whole control folds into the + menu (its row is revealed there, linking to the full page), and the + unread signal moves to a dot on the ☰. */ + .inbox-dropdown { display: none; } - .site-nav__links { - margin-left: var(--space-xs); + .menu__item--notifications { + display: flex; + } + + .site-nav--has-unread .site-nav__menu-btn::after { + display: block; + } + + /* Finger-sized tap targets: the ☰ and the folded search icon each fill a + 44px hit area, and menu rows get real height + spacing so they're not a + pixel-hunt to tap. */ + .site-nav__menu-btn { + min-width: 44px; + min-height: 44px; + } + + .site-nav__search { + min-width: 44px; + min-height: 44px; + padding: 12px; + } + + .menu { + min-width: 15rem; + padding: var(--space-sm); + } + + .menu__item { + min-height: 44px; + padding: 10px var(--space-sm); gap: var(--space-md); } - .site-nav__profile-link { - max-width: 6.5rem; + .menu__item svg { + width: 18px; + height: 18px; } .main-content { diff --git a/engine/app/javascript/controllers/coplan/inbox_badge_controller.js b/engine/app/javascript/controllers/coplan/inbox_badge_controller.js index 410d3258..218d5df2 100644 --- a/engine/app/javascript/controllers/coplan/inbox_badge_controller.js +++ b/engine/app/javascript/controllers/coplan/inbox_badge_controller.js @@ -16,6 +16,10 @@ export default class extends Controller { updateVisibility() { const count = parseInt(this.element.textContent.trim(), 10) - this.element.classList.toggle("inbox-badge--hidden", !count || count === 0) + const hasUnread = !!count && count > 0 + this.element.classList.toggle("inbox-badge--hidden", !hasUnread) + // Flag the nav so the menu button can show an unread dot on phone + // widths, where the bell itself folds into the menu. + this.element.closest(".site-nav")?.classList.toggle("site-nav--has-unread", hasUnread) } } diff --git a/engine/app/javascript/controllers/coplan/nav_title_controller.js b/engine/app/javascript/controllers/coplan/nav_title_controller.js new file mode 100644 index 00000000..72385813 --- /dev/null +++ b/engine/app/javascript/controllers/coplan/nav_title_controller.js @@ -0,0 +1,41 @@ +import { Controller } from "@hotwired/stimulus" + +// Fades the plan title into the sticky top nav once the document's own +// header has scrolled up behind the bar. Persistent wayfinding — +// especially on mobile and on comment deep links, where you land centered +// on an anchor with the masthead already off-screen — that costs zero +// space while the header is still visible. +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 + + 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) + } + + disconnect() { + this._observer?.disconnect() + this._setVisible(false) + } + + _setVisible(visible) { + this.element.classList.toggle("site-nav__doc-title--visible", visible) + } + + // --nav-height is authored in rem; resolve it to px for rootMargin. + _navHeightPx() { + const root = document.documentElement + const rem = parseFloat(getComputedStyle(root).getPropertyValue("--nav-height")) || 3.5 + return rem * parseFloat(getComputedStyle(root).fontSize) + } +} diff --git a/engine/app/views/coplan/plans/show.html.erb b/engine/app/views/coplan/plans/show.html.erb index da27034c..7f548c8e 100644 --- a/engine/app/views/coplan/plans/show.html.erb +++ b/engine/app/views/coplan/plans/show.html.erb @@ -9,6 +9,16 @@ <% end %> +<%# Fills the sticky nav's title slot (see layouts/coplan/application). The + icon + title mirror the masthead's #plan-header; coplan--nav-title fades + it in once that header scrolls behind the bar. %> +<% content_for :nav_title do %> + +<% end %> + <%= turbo_stream_from @plan %> <% my_placement = current_user && @shelf_placements.find { |p| p.library.writable_by?(current_user) } %> @@ -39,8 +49,9 @@ <% if @plan.current_content.present? %>