From 42d04254b95e85a64f80b1b355d26468e37f056c Mon Sep 17 00:00:00 2001 From: Hampton Lintorn-Catlin Date: Sat, 15 Aug 2026 12:27:47 -0500 Subject: [PATCH 1/3] Add containing-folder navigation Amp-Thread-ID: https://ampcode.com/threads/T-01a00629-a3e7-74ba-b72b-1e67445add17 Co-authored-by: Amp --- .../assets/stylesheets/coplan/application.css | 92 ++++++++++++++++--- .../coplan/libraries_controller.rb | 52 ++++++++--- .../controllers/coplan/plans_controller.rb | 5 +- .../coplan/nav_title_controller.js | 14 +-- engine/app/models/coplan/plan.rb | 7 ++ .../app/views/coplan/libraries/show.html.erb | 70 ++++++++++---- .../app/views/coplan/plans/_header.html.erb | 2 + .../coplan/plans/_location_link.html.erb | 19 ++++ engine/app/views/coplan/plans/show.html.erb | 23 +++-- spec/models/plan_spec.rb | 12 +++ spec/requests/libraries_spec.rb | 51 ++++++++-- spec/system/folders_workspace_spec.rb | 43 +++++++++ 12 files changed, 319 insertions(+), 71 deletions(-) create mode 100644 engine/app/views/coplan/plans/_location_link.html.erb diff --git a/engine/app/assets/stylesheets/coplan/application.css b/engine/app/assets/stylesheets/coplan/application.css index a3e2e78f..de36cd07 100644 --- a/engine/app/assets/stylesheets/coplan/application.css +++ b/engine/app/assets/stylesheets/coplan/application.css @@ -515,11 +515,11 @@ img, svg { display: none; } -/* 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 { +/* Scroll-aware plan context in the top nav: containing folder + document title. + Collapsed to zero width while the masthead is visible, so it steals no + space and causes no reflow at the top of a plan. coplan--nav-title reveals + the whole context once #plan-header scrolls behind the bar. */ +.site-nav__plan-context { display: flex; align-items: center; gap: var(--space-xs); @@ -529,6 +529,7 @@ img, svg { slack — never the other way round. */ flex: 0 1 auto; min-width: 0; + width: 0; max-width: 0; overflow: hidden; opacity: 0; @@ -541,17 +542,28 @@ img, svg { 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; + transition: width 0.28s ease, max-width 0.28s ease, opacity 0.18s ease; } -.site-nav__doc-title--visible { - max-width: 22rem; +.site-nav__plan-context--visible { + width: 25rem; + max-width: 25rem; opacity: 1; pointer-events: auto; } +.site-nav__doc-title { + display: flex; + align-items: center; + gap: var(--space-xs); + min-width: 0; + color: var(--color-text-muted); + text-decoration: none; +} + .site-nav__doc-title:hover { color: var(--color-text); + text-decoration: none; } .site-nav__doc-title-text { @@ -569,7 +581,7 @@ img, svg { 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 { + .site-nav__plan-context--visible { max-width: 58vw; } } @@ -622,6 +634,47 @@ img, svg { margin-bottom: -2px; } +/* Compact "up to containing folder" control. In the masthead it lives in + the byline, where it takes no width away from the title; its accessible + name and tooltip carry the full folder path. */ +.plan-location-link { + display: inline-flex; + align-items: center; + justify-content: center; + flex: 0 0 auto; + color: var(--color-text-muted); + background: transparent; + border: 0; + border-radius: 6px; + text-decoration: none; + transition: color 0.15s, background 0.15s; +} + +.plan-location-link:hover, +.plan-location-link:focus-visible { + color: var(--color-primary); + background: var(--color-primary-light); + text-decoration: none; +} + +.plan-location-link--masthead { + width: 24px; + height: 24px; + margin: -2px 0 -2px 2px; +} + +.plan-location-link--nav { + width: 26px; + height: 26px; + color: var(--color-text); + background: var(--color-surface-muted); +} + +.plan-location-link--nav svg { + width: 16px; + height: 16px; +} + /* Plan page header: one lockup — the file icon beside a text stack (title over byline) that shares a single left edge. The icon anchors to the title's first line (36px icon inside a ~38px line box), so it @@ -5918,9 +5971,9 @@ img.avatar { } /* ---- 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. */ + A finger needs a bigger target than a cursor. On coarse pointers the icon + controls and 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 { @@ -5928,6 +5981,21 @@ img.avatar { min-height: 44px; } + /* Preserve a 44px touch target without giving the compact control 44px + of layout footprint in the byline or sticky plan context. */ + .plan-location-link { + width: 44px; + height: 44px; + } + + .plan-location-link--masthead { + margin: -12px -10px -12px -8px; + } + + .plan-location-link--nav { + margin: -9px; + } + .menu__item { min-height: 44px; padding: 10px var(--space-sm); diff --git a/engine/app/controllers/coplan/libraries_controller.rb b/engine/app/controllers/coplan/libraries_controller.rb index 41d57052..2b174588 100644 --- a/engine/app/controllers/coplan/libraries_controller.rb +++ b/engine/app/controllers/coplan/libraries_controller.rb @@ -1,37 +1,65 @@ module CoPlan - # A library is a data-model concept, not a destination: a person's - # library is browsed on their profile, so these routes redirect there - # (fragments like #folder-x survive the redirect). The standalone page - # only renders for a future non-user owner (e.g. a team) that has no - # profile to redirect to. + # Read-only folder navigation for someone else's library. Owners continue + # into their editable workspace; everyone else gets the same level-by-level + # folder model without drag, move, or create controls. class LibrariesController < ApplicationController def mine - redirect_to profile_path(current_user.username.presence || current_user.id) + redirect_to plans_path end def show @library = Library.find(params[:id]) authorize!(@library, :show?) - if @library.owner.is_a?(CoPlan::User) - owner = @library.owner - redirect_to profile_path(owner.username.presence || owner.id) + if @library.writable_by?(current_user) + redirect_to plans_path(folder: params[:folder].presence) return end @owner = @library.owner @folders = @library.folders.order(:name).to_a + @folders_by_id = @folders.index_by(&:id) @folder_children = @folders.group_by(&:parent_id) - @root_folders = @folder_children[nil] || [] + @folder = @folders_by_id[params[:folder]] if params[:folder].present? + if params[:folder].present? && @folder.nil? + redirect_to library_path(@library), alert: "That folder no longer exists." + return + end placements = @library.placements .visible_to(current_user) .where(plan: Plan.active) .joins(:plan).order("coplan_plans.updated_at DESC") - .includes(plan: [ :created_by_user, :plan_type, :tags ]) + .includes(:folder, plan: [ :created_by_user, :plan_type, :current_version_stub ]) .to_a @placements_by_folder = placements.group_by(&:folder_id) - @plan_count = placements.size + + @root_plans = if @owner.is_a?(CoPlan::User) + Plan.visible_to(current_user).active + .where(created_by_user_id: @owner.id) + .where.not(id: @library.placements.select(:plan_id)) + .order(updated_at: :desc) + .includes(:created_by_user, :plan_type, :current_version_stub) + .to_a + else + [] + end + + @breadcrumbs = [] + node = @folder + while node + @breadcrumbs.unshift(node) + node = @folders_by_id[node.parent_id] + end + @subfolders = (@folder_children[@folder&.id] || []).sort_by { |folder| folder.name.downcase } + @plans = @folder ? (@placements_by_folder[@folder.id] || []).map(&:plan) : @root_plans + @plan_count = placements.size + @root_plans.size + + direct_counts = @placements_by_folder.transform_values(&:size) + count_folder = lambda do |folder| + direct_counts.fetch(folder.id, 0) + (@folder_children[folder.id] || []).sum { |child| count_folder.call(child) } + end + @folder_counts = @folders.index_with { |folder| count_folder.call(folder) }.transform_keys(&:id) end end end diff --git a/engine/app/controllers/coplan/plans_controller.rb b/engine/app/controllers/coplan/plans_controller.rb index e219c76d..aefed257 100644 --- a/engine/app/controllers/coplan/plans_controller.rb +++ b/engine/app/controllers/coplan/plans_controller.rb @@ -142,11 +142,12 @@ def show # Old ?tab=history links: history is its own page now (the other # former tabs are same-page sections). return redirect_to history_plan_path(@plan) if params[:tab] == "history" - # The viewer's own placement (if any) drives the toolbar's - # Save/Saved state and the folder navigator's current-folder mark. + # Placements drive both the viewer-relative Save/Saved state and the + # compact jump up to the containing folder in the author's library. @shelf_placements = @plan.placements .includes(:library, folder: { parent: :parent }) .order(:created_at) + @author_placement = @shelf_placements.find { |placement| placement.library_id == @plan.created_by_user.library.id } @my_folders = current_user.library.folders.order(:name).to_a @threads = @plan.comment_threads.with_kept_comments.includes(:comments, :created_by_user).order(:created_at) # The reader view joins auto-extracted resources to their Markdown diff --git a/engine/app/javascript/controllers/coplan/nav_title_controller.js b/engine/app/javascript/controllers/coplan/nav_title_controller.js index 4eae6746..dfb423c9 100644 --- a/engine/app/javascript/controllers/coplan/nav_title_controller.js +++ b/engine/app/javascript/controllers/coplan/nav_title_controller.js @@ -1,7 +1,7 @@ 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 — +// Fades the plan context (containing-folder control + 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. @@ -70,12 +70,14 @@ export default class extends Controller { } _setVisible(visible) { - this.element.classList.toggle("site-nav__doc-title--visible", visible) + this.element.classList.toggle("site-nav__plan-context--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. + // shown, both the containing-folder and return-to-top links are real controls, + // so expose them to keyboard and screen-reader users too. this.element.setAttribute("aria-hidden", String(!visible)) - this.element.tabIndex = visible ? 0 : -1 + this.element.querySelectorAll("a").forEach(link => { + link.tabIndex = visible ? 0 : -1 + }) } // --nav-height is authored in rem; resolve it to px for rootMargin. diff --git a/engine/app/models/coplan/plan.rb b/engine/app/models/coplan/plan.rb index 312668f6..520dae93 100644 --- a/engine/app/models/coplan/plan.rb +++ b/engine/app/models/coplan/plan.rb @@ -196,6 +196,13 @@ def archived? archived_at.present? end + # A plan's containing location is the folder chosen by its author in + # their own library. Other people may save the same plan elsewhere, but + # those placements are personal organization rather than its home. + def author_placement + placements.find_by(library_id: created_by_user.library.id) + end + # Legacy API compatibility (see LEGACY_STATUSES). Emits the closest # five-state equivalent of the current visibility/archival state. def legacy_status diff --git a/engine/app/views/coplan/libraries/show.html.erb b/engine/app/views/coplan/libraries/show.html.erb index 1f41988f..648c876e 100644 --- a/engine/app/views/coplan/libraries/show.html.erb +++ b/engine/app/views/coplan/libraries/show.html.erb @@ -1,5 +1,4 @@ <% owner_name = @owner.respond_to?(:name) ? @owner.name : @library.name %> -<% mine = @library.writable_by?(current_user) %>
@@ -8,33 +7,64 @@ <%= user_avatar(@owner, size: "lg") %> <% end %>
-

<%= mine ? "Your library" : "#{owner_name}’s library" %>

+

<%= owner_name %>’s library

<%= pluralize(@plan_count, "plan") %> across <%= pluralize(@folders.size, "folder") %> - <% if mine %> · the read-only view others see from your profile<% end %> - <% if !mine && @owner.is_a?(CoPlan::User) %> · curated by <%= profile_link(@owner) %><% end %> + <% if @owner.is_a?(CoPlan::User) %> · curated by <%= profile_link(@owner) %><% end %>

- <% if mine %> - <%= link_to "Organize in workspace", plans_path, class: "btn btn--secondary btn--sm" %> - <% end %> - <% if @folders.empty? %> + + + <% @subfolders.each do |folder| %> + <%= link_to library_path(@library, folder: folder.id), class: "folder-row", data: { turbo_prefetch: true } do %> + + <%= folder.name %> + <% count = @folder_counts[folder.id] %> + <%= count.zero? ? "empty" : pluralize(count, "item") %> + <% end %> + <% end %> + + <% @plans.each do |plan| %> + <% summary = plan.try(:summary).presence || plan_content_preview(plan) %> +
+ <%= plan_type_icon(plan, size: :lg) %> +
+
+ <%= link_to plan.title, plan_path(plan), class: "plan-row__title", data: { turbo_prefetch: true } %><%= plan_state_badge(plan) %> +
+ <% if summary.present? %>

<%= summary %>

<% end %> +
+
+ <%= time_ago_in_words(plan.updated_at) %> ago + <%= link_to user_avatar(plan.created_by_user), profile_path_for(plan.created_by_user), title: plan.created_by_user.name %> +
+
+ <% end %> + + <% if @subfolders.empty? && @plans.empty? %>
-

Nothing on these shelves yet.

- <% if mine %> -

Create folders in your <%= link_to "workspace", plans_path %> sidebar and drag plans onto them — yours or anyone’s published work.

- <% else %> -

<%= owner_name %> hasn’t organized any plans into folders yet.

- <% end %> -
- <% else %> -
- <% @root_folders.sort_by { |f| f.name.downcase }.each do |folder| %> - <%= render "coplan/libraries/shelf", folder: folder, depth: 1 %> - <% end %> +

Nothing in this folder yet.

<% end %> diff --git a/engine/app/views/coplan/plans/_header.html.erb b/engine/app/views/coplan/plans/_header.html.erb index 37c14459..666dc242 100644 --- a/engine/app/views/coplan/plans/_header.html.erb +++ b/engine/app/views/coplan/plans/_header.html.erb @@ -3,12 +3,14 @@ (content commits, visibility changes, API edits) with no current_user, so viewer-relative chrome (presence, the owner toolbar, Save) lives in the masthead's side column next to this, never inside it. %> +<% author_placement = local_assigns.fetch(:author_placement) { plan.author_placement } %> diff --git a/engine/app/views/coplan/plans/_location_link.html.erb b/engine/app/views/coplan/plans/_location_link.html.erb new file mode 100644 index 00000000..7be0f402 --- /dev/null +++ b/engine/app/views/coplan/plans/_location_link.html.erb @@ -0,0 +1,19 @@ +<%# Go up from the document to its containing folder in the author's + library. An unfiled document goes to that library's root. %> +<% unless plan.archived? %> + <% library = plan.created_by_user.library %> + <% folder = placement&.folder %> + <% label = folder ? "Up to containing folder — #{folder.path}" : "Up to #{plan.created_by_user.name}’s library" %> + <%= link_to library_path(library, folder: folder&.id), + class: "plan-location-link plan-location-link--#{location}", + title: label, + aria: { label: label }, + tabindex: location == :nav ? -1 : nil, + data: { turbo_prefetch: true } do %> + + <% end %> +<% end %> diff --git a/engine/app/views/coplan/plans/show.html.erb b/engine/app/views/coplan/plans/show.html.erb index 2d67bcd1..a4b74067 100644 --- a/engine/app/views/coplan/plans/show.html.erb +++ b/engine/app/views/coplan/plans/show.html.erb @@ -9,21 +9,24 @@ <% end %> +<% my_placement = current_user && @shelf_placements.find { |p| p.library.writable_by?(current_user) } %> +<% reference_count = plan_reference_count(@plan, @references) %> + <%# 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. %> + containing-folder control, icon, and title mirror the masthead; + coplan--nav-title fades them in once #plan-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) } %> -<% reference_count = plan_reference_count(@plan, @references) %> - <%# plan-keys: Backspace goes back to wherever you came from; [ / ] jump between the document and its footnote sections. Cold opens (direct link, new tab) have no history — fall back to where the plan lives in the @@ -37,7 +40,7 @@ broadcasts own the left, this request (and the visibility/archive stream responses) own the right, so neither can clobber the other. %>
- <%= render partial: "coplan/plans/header", locals: { plan: @plan } %> + <%= render partial: "coplan/plans/header", locals: { plan: @plan, author_placement: @author_placement } %>
<%= render partial: "coplan/plans/viewers", locals: { viewers: CoPlan::PlanViewer.active_viewers_for(@plan), current_user: current_user } %> <%= render partial: "coplan/plans/toolbar", locals: { plan: @plan, my_placement: my_placement } %> diff --git a/spec/models/plan_spec.rb b/spec/models/plan_spec.rb index b6fcc23e..d02040fb 100644 --- a/spec/models/plan_spec.rb +++ b/spec/models/plan_spec.rb @@ -51,6 +51,18 @@ expect(plan.current_content).to include("Plan Content") end + it "uses the author's own library placement as its containing location" do + author = create(:coplan_user) + viewer = create(:coplan_user) + plan = create(:plan, :published, created_by_user: author) + author_folder = create(:folder, created_by_user: author) + viewer_folder = create(:folder, created_by_user: viewer) + create(:plan_placement, plan: plan, folder: viewer_folder) + author_placement = create(:plan_placement, plan: plan, folder: author_folder) + + expect(plan.author_placement).to eq(author_placement) + end + # THE discovery predicate (mirrored by PlanPolicy#listed?). Everything a # user can be shown in a list routes through one of these two scopes. describe ".visible_to" do diff --git a/spec/requests/libraries_spec.rb b/spec/requests/libraries_spec.rb index 019dec7c..6023efc5 100644 --- a/spec/requests/libraries_spec.rb +++ b/spec/requests/libraries_spec.rb @@ -1,26 +1,59 @@ require "rails_helper" -# Libraries are a data concept, not a destination: these routes exist so -# old links keep working, and both redirect to the owner's profile — the -# one place a person's library is browsed. RSpec.describe "Libraries", type: :request do let(:alice) { create(:coplan_user, username: "alice") } before { sign_in_as(alice) } describe "GET /library" do - it "redirects to the current user's profile by username" do + it "redirects to the current user's editable workspace" do get my_library_path - expect(response).to redirect_to(profile_path("alice")) + expect(response).to redirect_to(plans_path) end - end describe "GET /libraries/:id" do - it "redirects a user-owned library to its owner's profile" do + it "redirects the owner to the matching folder in their workspace" do + folder = create(:folder, created_by_user: alice) + + get library_path(alice.library, folder: folder.id) + + expect(response).to redirect_to(plans_path(folder: folder.id)) + end + + it "browses another user's library one folder level at a time" do bob = create(:coplan_user, username: "bob") - get library_path(CoPlan::Library.for(bob)) - expect(response).to redirect_to(profile_path("bob")) + projects = create(:folder, name: "Projects", created_by_user: bob) + launches = create(:folder, name: "Launches", parent: projects, created_by_user: bob) + filed = create(:plan, :published, title: "Filed launch", created_by_user: bob) + loose = create(:plan, :published, title: "Loose plan", created_by_user: bob) + private_plan = create(:plan, :draft, title: "Private draft", created_by_user: bob) + create(:plan_placement, plan: filed, folder: launches) + create(:plan_placement, plan: private_plan, folder: launches) + + get library_path(bob.library) + expect(response).to have_http_status(:ok) + expect(response.body).to include("Projects", "Loose plan") + expect(response.body).not_to include("Filed launch", "Private draft") + + get library_path(bob.library, folder: projects.id) + expect(response).to have_http_status(:ok) + expect(response.body).to include("Projects", "Launches") + expect(response.body).not_to include("Filed launch", "Loose plan") + + get library_path(bob.library, folder: launches.id) + expect(response).to have_http_status(:ok) + expect(response.body).to include("Projects", "Launches", "Filed launch") + expect(response.body).not_to include("Loose plan", "Private draft") + end + + it "returns to the library root when the requested folder is gone" do + bob = create(:coplan_user) + + get library_path(bob.library, folder: "missing") + + expect(response).to redirect_to(library_path(bob.library)) + expect(flash[:alert]).to eq("That folder no longer exists.") end end end diff --git a/spec/system/folders_workspace_spec.rb b/spec/system/folders_workspace_spec.rb index 226f7256..8582a43e 100644 --- a/spec/system/folders_workspace_spec.rb +++ b/spec/system/folders_workspace_spec.rb @@ -73,6 +73,49 @@ def sign_in(user) expect(page).to have_current_path(plans_path(folder: q3.id)) end + it "goes up to the plan's containing folder from the masthead and sticky nav" do + foldered_plan.current_plan_version.update!( + content_markdown: (1..30).map { |n| "## Section #{n}\n\nEnough content to scroll past the masthead." }.join("\n\n") + ) + location_path = library_path(author.library, folder: q3.id) + workspace_destination = plans_path(folder: q3.id) + + visit plan_path(foldered_plan) + masthead_location = find(".plan-location-link--masthead") + expect(masthead_location[:href]).to end_with(location_path) + expect(masthead_location["aria-label"]).to eq("Up to containing folder — Team EBT/Q3") + expect(masthead_location["data-turbo-prefetch"]).to eq("true") + masthead_location.click + expect(page).to have_current_path(workspace_destination) + + visit plan_path(foldered_plan) + page.execute_script("window.scrollTo(0, document.body.scrollHeight)") + expect(page).to have_css(".site-nav__plan-context--visible") + sticky_location = find(".plan-location-link--nav") + expect(sticky_location[:href]).to end_with(location_path) + expect(sticky_location["aria-label"]).to eq("Up to containing folder — Team EBT/Q3") + expect(sticky_location["data-turbo-prefetch"]).to eq("true") + sticky_location.click + expect(page).to have_current_path(workspace_destination) + end + + it "opens the author's navigable folder when viewing someone else's plan" do + saved = create(:folder, name: "Saved by me", created_by_user: other) + CoPlan::Plans::Place.call(plan: foldered_plan, folder: saved, actor: other) + sign_in(other) + destination = library_path(author.library, folder: q3.id) + + visit plan_path(foldered_plan) + location = find(".plan-location-link--masthead") + expect(location[:href]).to end_with(destination) + expect(location["aria-label"]).to eq("Up to containing folder — Team EBT/Q3") + location.click + + expect(page).to have_current_path(destination) + expect(page).to have_css(".workspace-crumbs__crumb--current", text: "Q3") + expect(page).to have_link("Q3 Launch Plan") + end + it "quietly flags private plans in the level view" do visit plans_path row = find(".plan-row[data-plan-id='#{brainstorm_plan.id}']") From 0f53de977768d3576d6198d4997ea645912793a0 Mon Sep 17 00:00:00 2001 From: Hampton Lintorn-Catlin Date: Mon, 17 Aug 2026 14:53:58 -0500 Subject: [PATCH 2/3] Cover archived plan navigation Amp-Thread-ID: https://ampcode.com/threads/T-01a01147-8d44-7468-ac98-bf40b04e3cee Co-authored-by: Amp --- spec/requests/plans_spec.rb | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/spec/requests/plans_spec.rb b/spec/requests/plans_spec.rb index d12af3bb..7aa0bf02 100644 --- a/spec/requests/plans_spec.rb +++ b/spec/requests/plans_spec.rb @@ -79,6 +79,15 @@ expect(response.body).to include("plan-layout__content") end + it "does not link archived plans to a library that omits them" do + archived_plan = create(:plan, :archived, created_by_user: alice) + + get plan_path(archived_plan) + + expect(response).to have_http_status(:success) + expect(response.body).not_to include("plan-location-link") + end + it "scopes comment footnote ids so they can't collide with the plan body's" do thread = create(:comment_thread, :with_anchor, plan: plan, plan_version: plan.current_plan_version, created_by_user: alice) comment = create(:comment, comment_thread: thread, author_type: "human", author_id: alice.id, From 043c05b59650dccb47fd7a8171ea776a9259c1fa Mon Sep 17 00:00:00 2001 From: Hampton Lintorn-Catlin Date: Mon, 17 Aug 2026 14:56:17 -0500 Subject: [PATCH 3/3] Keep plan navigation state current Amp-Thread-ID: https://ampcode.com/threads/T-01a01147-8d44-7468-ac98-bf40b04e3cee Co-authored-by: Amp --- engine/app/controllers/coplan/plans_controller.rb | 2 ++ engine/app/views/coplan/plans/_nav_context.html.erb | 8 ++++++++ engine/app/views/coplan/plans/show.html.erb | 8 +------- spec/system/human_editing_spec.rb | 3 +++ 4 files changed, 14 insertions(+), 7 deletions(-) create mode 100644 engine/app/views/coplan/plans/_nav_context.html.erb diff --git a/engine/app/controllers/coplan/plans_controller.rb b/engine/app/controllers/coplan/plans_controller.rb index aefed257..ca143371 100644 --- a/engine/app/controllers/coplan/plans_controller.rb +++ b/engine/app/controllers/coplan/plans_controller.rb @@ -687,6 +687,7 @@ def set_plan def broadcast_plan_update(plan) Broadcaster.replace_to(plan, target: "plan-header", partial: "coplan/plans/header", locals: { plan: plan }) + Broadcaster.replace_to(plan, target: "plan-nav-context", partial: "coplan/plans/nav_context", locals: { plan: plan }) end # Turbo Streams for a visibility change: re-render the header (the @@ -708,6 +709,7 @@ def visibility_streams(message) def archive_streams(message) [ turbo_stream.replace("plan-header", partial: "coplan/plans/header", locals: { plan: @plan }), + turbo_stream.replace("plan-nav-context", partial: "coplan/plans/nav_context", locals: { plan: @plan }), turbo_stream.replace("plan-banner-slot", partial: "coplan/plans/banner", locals: { plan: @plan }), turbo_stream.replace("plan-toolbar", partial: "coplan/plans/toolbar", locals: { plan: @plan }), toast_stream(message, "notice") diff --git a/engine/app/views/coplan/plans/_nav_context.html.erb b/engine/app/views/coplan/plans/_nav_context.html.erb new file mode 100644 index 00000000..88d37690 --- /dev/null +++ b/engine/app/views/coplan/plans/_nav_context.html.erb @@ -0,0 +1,8 @@ +<% author_placement = local_assigns.fetch(:author_placement) { plan.author_placement } %> + diff --git a/engine/app/views/coplan/plans/show.html.erb b/engine/app/views/coplan/plans/show.html.erb index a4b74067..ee3bffdd 100644 --- a/engine/app/views/coplan/plans/show.html.erb +++ b/engine/app/views/coplan/plans/show.html.erb @@ -16,13 +16,7 @@ containing-folder control, icon, and title mirror the masthead; coplan--nav-title fades them in once #plan-header scrolls behind the bar. %> <% content_for :nav_title do %> - + <%= render "coplan/plans/nav_context", plan: @plan, author_placement: @author_placement %> <% end %> <%= turbo_stream_from @plan %> diff --git a/spec/system/human_editing_spec.rb b/spec/system/human_editing_spec.rb index 95dec52d..7be64e40 100644 --- a/spec/system/human_editing_spec.rb +++ b/spec/system/human_editing_spec.rb @@ -109,6 +109,7 @@ def open_plan_menu it "archives and restores the plan in place" do visit plan_path(plan) + expect(page).to have_css(".plan-location-link--nav", visible: :all) open_plan_menu within("#plan-menu") { click_button "Archive plan" } @@ -117,6 +118,7 @@ def open_plan_menu # undo, no navigation away from the document. expect(page).to have_css(".plan-banner--archived", text: "hidden from lists") expect(page).to have_content("Editable Plan") + expect(page).not_to have_css(".plan-location-link", visible: :all) expect(plan.reload.archived?).to be(true) # Archive leaves the menu while archived. @@ -126,6 +128,7 @@ def open_plan_menu within(".plan-banner--archived") { click_button "Restore" } expect(page).not_to have_css(".plan-banner--archived") + expect(page).to have_css(".plan-location-link--nav", visible: :all) expect(plan.reload.archived?).to be(false) end