From 9a9781ccf4967a94b14a3d5b766e5e5d7142862d Mon Sep 17 00:00:00 2001 From: Hampton Lintorn-Catlin Date: Tue, 21 Jul 2026 15:08:33 -0400 Subject: [PATCH] Workspace and plan-page polish: contrast, bylines, reachable sidebars, inline back matter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Light mode gets real definition: a faintly dark --card-edge outline plus a soft shadow on rows and cards, over a slightly darker page background — the old white-on-white glass edges disappeared entirely. Rows lose their tag chips (the sidebar already filters by tag) and their hover bookmark (drag & drop files rows; the plan page's title bookmark is the click path), and the byline pins to the card's top-right corner instead of drifting wherever the title wrap left it. "Since you last looked" gets room to breathe. Sticky sidebars taller than the viewport used to pin their overflow below the fold where no amount of scrolling could reach it — the workspace sidebar and the document outline are now their own scroll containers. References and attachments move inside the content column, closing out the document after a rule instead of sitting below both columns (where long outlines collided with them). Empty sections are one quiet line, not a padded card. The text-selection target is scoped to an inner wrapper so back matter can't take comment anchors that would never resolve. Co-Authored-By: Claude Fable 5 --- .../assets/stylesheets/coplan/application.css | 134 +++++++++--------- .../views/coplan/plans/_attachments.html.erb | 5 +- .../views/coplan/plans/_footnotes.html.erb | 32 +++++ .../app/views/coplan/plans/_plan_row.html.erb | 29 +--- .../views/coplan/plans/_references.html.erb | 5 +- engine/app/views/coplan/plans/index.html.erb | 5 +- engine/app/views/coplan/plans/show.html.erb | 65 +++------ spec/requests/plans_spec.rb | 20 +-- spec/system/folders_workspace_spec.rb | 37 ++--- spec/system/references_spec.rb | 8 +- 10 files changed, 168 insertions(+), 172 deletions(-) create mode 100644 engine/app/views/coplan/plans/_footnotes.html.erb diff --git a/engine/app/assets/stylesheets/coplan/application.css b/engine/app/assets/stylesheets/coplan/application.css index 3ee030f..5b2c8cf 100644 --- a/engine/app/assets/stylesheets/coplan/application.css +++ b/engine/app/assets/stylesheets/coplan/application.css @@ -4,7 +4,9 @@ color-scheme: light; /* Colors */ - --color-bg: #fafafa; + /* Slightly cool and dark for a page background: white cards need + something to stand out against, or light mode reads as fog. */ + --color-bg: #eef0f5; --color-bg-muted: #f3f4f6; --color-surface: #ffffff; --color-surface-muted: #f9fafb; @@ -108,7 +110,11 @@ --glass-edge: rgba(255, 255, 255, 0.75); --glass-blur: blur(16px) saturate(1.6); --shadow-glass: inset 0 1px 0 rgba(255, 255, 255, 0.7), 0 1px 2px rgba(15, 23, 42, 0.04), 0 10px 30px rgba(15, 23, 42, 0.08); - --surface-translucent: rgba(255, 255, 255, 0.6); + --surface-translucent: rgba(255, 255, 255, 0.75); + /* Card outline. The white --glass-edge reads as a specular highlight on + dark backdrops but disappears white-on-white — cards and rows use this + faintly dark line instead so light mode has real definition. */ + --card-edge: rgba(24, 33, 54, 0.10); } @media (prefers-color-scheme: dark) { @@ -184,6 +190,7 @@ --glass-blur: blur(16px) saturate(1.4); --shadow-glass: inset 0 1px 0 rgba(255, 255, 255, 0.06), 0 1px 2px rgba(0, 0, 0, 0.3), 0 12px 32px rgba(0, 0, 0, 0.4); --surface-translucent: rgba(21, 32, 51, 0.55); + --card-edge: rgba(148, 163, 184, 0.16); } } @@ -260,6 +267,7 @@ --glass-blur: blur(16px) saturate(1.4); --shadow-glass: inset 0 1px 0 rgba(255, 255, 255, 0.06), 0 1px 2px rgba(0, 0, 0, 0.3), 0 12px 32px rgba(0, 0, 0, 0.4); --surface-translucent: rgba(21, 32, 51, 0.55); + --card-edge: rgba(148, 163, 184, 0.16); } /* Forced light theme — overrides system dark preference */ @@ -669,7 +677,7 @@ img, svg { background: var(--glass-bg-strong); -webkit-backdrop-filter: var(--glass-blur); backdrop-filter: var(--glass-blur); - border: 1px solid var(--glass-edge); + border: 1px solid var(--card-edge); border-radius: var(--radius-lg); padding: var(--space-lg); box-shadow: var(--shadow-glass); @@ -2093,6 +2101,12 @@ img.avatar { width: 220px; flex-shrink: 0; max-height: calc(100vh - var(--nav-height) - var(--space-xl) * 2); + /* The nav is the scroll container — a long outline must not push the + back-matter links (References/Attachments) below the sticky bound + where no amount of page scrolling reaches them. */ + overflow-y: auto; + scrollbar-width: thin; + scrollbar-color: var(--color-border) transparent; transition: width 0.2s ease, opacity 0.2s ease; margin-right: var(--space-lg); } @@ -2141,10 +2155,8 @@ img.avatar { list-style: none; padding: 0; margin: 0; - overflow-y: auto; - max-height: calc(100vh - var(--nav-height) - 8rem); - scrollbar-width: thin; - scrollbar-color: var(--color-border) transparent; + /* No inner scroll region: the whole nav scrolls (see .content-nav), so + the outline and the back-matter links share one scroll. */ } .content-nav__item { @@ -2538,27 +2550,26 @@ img.avatar { /* Plan document + footnotes: one page, no tabs. The document is the main glass card; references and attachments follow like back matter — same page, quieter surface. */ +/* Back matter lives inside the content column, closing out the document + after a rule — no card-within-card, no space below the sidebar. Empty + sections are a single quiet line. */ .plan-footnotes { - margin-top: var(--space-xl); + margin-top: var(--space-2xl); + padding-top: var(--space-lg); + border-top: 1px solid var(--color-border); display: flex; flex-direction: column; gap: var(--space-lg); } .plan-footnote { - /* Set apart from the document proper: flatter, edge-lit, no heavy card. */ - background: var(--surface-translucent); - border: 1px solid var(--glass-edge); - border-left: 3px solid var(--color-border); - border-radius: var(--radius); - scroll-margin-top: var(--space-lg); + scroll-margin-top: calc(var(--nav-height) + var(--space-lg)); } .plan-footnote__header { display: flex; align-items: center; gap: var(--space-sm); - padding: var(--space-md) var(--space-lg) 0; } .plan-footnote__title { @@ -2737,8 +2748,17 @@ img.avatar { } /* References section */ -.references-section { - padding: var(--space-lg); +/* Section bodies hang under the header, indented past the round "+" so + content lines up with the section title. */ +.references-section, +.attachments-section { + padding: 0 0 0 calc(24px + var(--space-sm)); +} + +.plan-footnote__empty { + margin: var(--space-xs) 0 0; + font-size: var(--text-sm); + color: var(--color-text-muted); } .references-list { @@ -2942,10 +2962,6 @@ img.avatar { } /* Attachments section */ -.attachments-section { - padding: var(--space-lg); -} - .attachments-section__header { margin-bottom: var(--space-md); } @@ -4539,6 +4555,12 @@ img.avatar { top: calc(var(--nav-height) + var(--space-md)); font-size: var(--text-sm); min-width: 0; + /* A sticky sidebar taller than the viewport pins its overflow below the + fold forever — page scroll can't reach it. Scroll the sidebar itself. */ + max-height: calc(100vh - var(--nav-height) - var(--space-md) * 2); + overflow-y: auto; + scrollbar-width: thin; + scrollbar-color: var(--color-border) transparent; } .workspace__sidebar-sections { @@ -4900,17 +4922,18 @@ img.avatar { history, quieter than the warning-toned attention strip. */ .recent-updates { margin-bottom: var(--space-lg); - padding: var(--space-sm) var(--space-md); - border: 1px solid var(--glass-edge); + padding: var(--space-md); + border: 1px solid var(--card-edge); border-radius: var(--radius); background: var(--surface-translucent); + box-shadow: var(--shadow); } .recent-updates__heading { display: flex; align-items: center; gap: var(--space-xs); - margin: 0 0 var(--space-xs); + margin: 0 0 var(--space-sm); font-size: 0.8rem; font-weight: 600; text-transform: uppercase; @@ -4924,7 +4947,8 @@ img.avatar { padding: 0; display: flex; flex-direction: column; - gap: 2px; + /* Roomy enough to scan — this read as one bunched block at 2px. */ + gap: var(--space-sm); } .recent-updates__item { @@ -5014,9 +5038,10 @@ img.avatar { gap: var(--space-sm); padding: var(--space-sm) var(--space-md); background: var(--surface-translucent); - border: 1px solid var(--glass-edge); + border: 1px solid var(--card-edge); border-radius: var(--radius); margin-bottom: var(--space-xs); + box-shadow: var(--shadow); color: var(--color-text); text-decoration: none; transition: background 0.12s ease, box-shadow 0.12s ease; @@ -5058,9 +5083,10 @@ img.avatar { /* Translucent without backdrop blur — rows are numerous and per-row blur is a GPU tax; the mesh still glows through faintly. */ background: var(--surface-translucent); - border: 1px solid var(--glass-edge); + border: 1px solid var(--card-edge); border-radius: var(--radius); margin-bottom: var(--space-xs); + box-shadow: var(--shadow); transition: background 0.12s ease, box-shadow 0.12s ease, transform 0.12s ease; /* Selectable text hijacks press-and-drag: the browser starts a text selection instead of the row drag, and the mouseup then *navigates* @@ -5148,8 +5174,15 @@ img.avatar { text-decoration: none; } -.plan-row__spacer { - flex: 1; +/* Byline pinned to the card's top-right corner: it must not ride along + with wrapping titles or drift to a second line mid-card. */ +.plan-row__meta { + display: flex; + align-items: center; + gap: var(--space-sm); + margin-left: auto; + align-self: flex-start; + flex-shrink: 0; } .plan-row__time { @@ -5171,40 +5204,6 @@ img.avatar { overflow: hidden; } -/* Row bookmark — the keyboard/no-drag door into the folder navigator. */ -.plan-row__save { - display: inline-flex; - align-items: center; - padding: 4px; - background: none; - border: none; - border-radius: var(--radius); - color: var(--color-text-muted); - cursor: pointer; - opacity: 0; - transition: opacity 0.12s ease, color 0.12s ease; -} - -.plan-row:hover .plan-row__save, -.plan-row__save:focus-visible, -.plan-row__save--saved { - opacity: 1; -} - -.plan-row__save:hover { - color: var(--color-primary); - background: var(--color-bg-muted); -} - -.plan-row__save--saved { - color: var(--color-primary); -} - -/* Saved = a second click removes; hint at that on hover. */ -.plan-row__save--saved:hover { - color: var(--color-danger, #e0637a); -} - /* Toasts (drag & drop feedback) */ .toasts { position: fixed; @@ -5232,6 +5231,8 @@ img.avatar { tap to expand, out of the way otherwise. */ .workspace__sidebar { position: static; + max-height: none; + overflow: visible; } .workspace__sidebar-toggle { @@ -5326,10 +5327,13 @@ img.avatar { overflow-wrap: anywhere; } - .plan-document, + .plan-document { + padding: var(--space-md); + } + .references-section, .attachments-section { - padding: var(--space-md); + padding-left: 0; } .plan-actions { diff --git a/engine/app/views/coplan/plans/_attachments.html.erb b/engine/app/views/coplan/plans/_attachments.html.erb index 89d730d..b478702 100644 --- a/engine/app/views/coplan/plans/_attachments.html.erb +++ b/engine/app/views/coplan/plans/_attachments.html.erb @@ -72,8 +72,7 @@ <% end %> <% else %> -
-

No attachments yet.<% if can_add %> Upload files to share diagrams, screenshots, or documents alongside the plan.<% end %>

-
+ <%# One quiet line, not a padded card — empty is the common case. %> +

None yet<% if can_add %> — use the + to add screenshots, diagrams, or files<% end %>.

<% end %> diff --git a/engine/app/views/coplan/plans/_footnotes.html.erb b/engine/app/views/coplan/plans/_footnotes.html.erb new file mode 100644 index 0000000..fa1e1b7 --- /dev/null +++ b/engine/app/views/coplan/plans/_footnotes.html.erb @@ -0,0 +1,32 @@ +<%# Back matter: references and attachments close out the document inside + the content column — separated from the prose by a rule, not banished + below the sidebar where long outlines made them collide. Each section + is one quiet header line (+, title, count); empty sections stay one + line tall. %> +
+
+
+ <% if current_user && allowed_to?(plan, :contribute?) %> + + <% end %> +

References

+ <%= references.size %> +
+ <%= render partial: "coplan/plans/references", locals: { references: references, plan: plan } %> +
+ +
+
+ <% if current_user && allowed_to?(plan, :contribute?) %> + + <% end %> +

Attachments

+ <%= attachments.size %> +
+ <%= render partial: "coplan/plans/attachments", locals: { attachments: attachments, plan: plan } %> +
+
diff --git a/engine/app/views/coplan/plans/_plan_row.html.erb b/engine/app/views/coplan/plans/_plan_row.html.erb index 82e7ac5..5bab1bc 100644 --- a/engine/app/views/coplan/plans/_plan_row.html.erb +++ b/engine/app/views/coplan/plans/_plan_row.html.erb @@ -37,35 +37,18 @@ <%= folder_paths_by_id[my_folder_id] %> <% end %> <% end %> - <% plan.tags.each do |tag| %> - <%= link_to tag.name, - workspace_path(tag: tag.name), - class: "badge badge--tag #{'badge--tag-active' if params[:tag] == tag.name}", - draggable: false, - data: { turbo_frame: "_top" } %> - <% end %> - <% if unread > 0 %> "><%= unread %> <% 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, draggable: false, data: { turbo_frame: "_top" } %> - <%# Bookmark = the keyboard/no-drag path into the folder navigator. - Saved rows unsave on the second click, quietly. %> - <% if summary.present? %>

<%= summary %>

<% end %> + <%# Byline, anchored to the card's top-right corner regardless of how the + title wraps — a floating "author · when" would read as misalignment. %> +
+ <%= 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, draggable: false, data: { turbo_frame: "_top" } %> +
diff --git a/engine/app/views/coplan/plans/_references.html.erb b/engine/app/views/coplan/plans/_references.html.erb index 770d93d..1e54daf 100644 --- a/engine/app/views/coplan/plans/_references.html.erb +++ b/engine/app/views/coplan/plans/_references.html.erb @@ -55,8 +55,7 @@ <% end %> <% else %> -
-

No references yet. Links in the plan content are auto-extracted, or add one manually.

-
+ <%# One quiet line, not a padded card — empty is the common case. %> +

None yet — links in the document are picked up automatically.

<% end %> diff --git a/engine/app/views/coplan/plans/index.html.erb b/engine/app/views/coplan/plans/index.html.erb index 66feddc..601ebfd 100644 --- a/engine/app/views/coplan/plans/index.html.erb +++ b/engine/app/views/coplan/plans/index.html.erb @@ -5,9 +5,10 @@ <% end %> -
+<%# No folder picker here: rows file via drag & drop, and the plan page's + title bookmark covers the click path. %> +
<%= render "coplan/plans/sidebar" %> - <%= render partial: "coplan/shared/folder_picker", locals: { folders: @folders } %> <%# The whole pane is a drop target for the folder you're looking at — Finder semantics: drop on empty space to file right here. Specific diff --git a/engine/app/views/coplan/plans/show.html.erb b/engine/app/views/coplan/plans/show.html.erb index cbb1b63..f0c484a 100644 --- a/engine/app/views/coplan/plans/show.html.erb +++ b/engine/app/views/coplan/plans/show.html.erb @@ -121,21 +121,29 @@ re-run anchor highlighting once each diagram settles so comments anchored to diagram labels get their marks (and pending ?thread= deep links can resolve). %> -
-
- <%= render partial: "coplan/plans/content_body", locals: { plan: @plan } %> -
+
+ <%# Selection-to-comment is scoped to this inner wrapper: the back + matter below shares the column but must not offer comment + anchors — they'd never resolve against the document body. %> +
+
+ <%= render partial: "coplan/plans/content_body", locals: { plan: @plan } %> +
+ + - - <%= render partial: "coplan/comment_threads/new_comment_form", locals: { plan: @plan } %> + <%= render partial: "coplan/plans/footnotes", + locals: { plan: @plan, references: @references, attachments: @attachments } %>

This plan has no content yet.

+ <%# No content, no layout columns — back matter still belongs on the page. %> + <%= render partial: "coplan/plans/footnotes", + locals: { plan: @plan, references: @references, attachments: @attachments } %> <% end %>
-<%# Back matter: references and attachments live under the document like - footnotes — same page, quieter surface, each with a one-click "+". %> -
-
-
- <% if current_user && allowed_to?(@plan, :contribute?) %> - - <% end %> -

References

- <%= @references.size %> -
- <%= render partial: "coplan/plans/references", locals: { references: @references, plan: @plan } %> -
- -
-
- <% if current_user && allowed_to?(@plan, :contribute?) %> - - <% end %> -

Attachments

- <%= @attachments.size %> -
- <%= render partial: "coplan/plans/attachments", locals: { attachments: @attachments, plan: @plan } %> -
-
- <%= render partial: "coplan/shared/folder_picker", locals: { folders: @my_folders } %> <%# Headless comment navigation — no floating toolbar, just the keyboard: diff --git a/spec/requests/plans_spec.rb b/spec/requests/plans_spec.rb index 7a48e1d..5fec026 100644 --- a/spec/requests/plans_spec.rb +++ b/spec/requests/plans_spec.rb @@ -41,12 +41,14 @@ expect(response.body).not_to include("Other Plan") end - it "index shows tag badges on plan cards" do + it "index lists tags in the sidebar, not as chips on the rows" do plan.tag_names = [ "infra", "api" ] get plans_path - expect(response.body).to include("badge--tag") - expect(response.body).to include("infra") - expect(response.body).to include("api") + expect(response.body).to include("#infra") + expect(response.body).to include("#api") + # Row tag chips were dropped: they crowded the byline off its corner + # and the sidebar already filters by tag. + expect(response.body).not_to include("badge--tag") end it "index shows active tag filter bar" do @@ -850,7 +852,7 @@ def alice_placement }.not_to change(CoPlan::PlanEvent, :count) end - it "renders drag handles and save bookmarks on every row" do + it "renders every row draggable into the viewer's library" do plan # alice's plan bobs_plan = create(:plan, :considering, created_by_user: bob, title: "Bobs Plan") get plans_path(scope: "all") @@ -860,9 +862,11 @@ def alice_placement # Anyone can shelve any visible plan into their own library. expect(alice_row).to include('draggable="true"') expect(bob_row).to include('draggable="true"') - expect(response.body.scan("plan-row__save").size).to be >= 2 - # One shared navigator popover per page, with the folder tree inside. - expect(response.body.scan('id="folder-picker-modal"').size).to eq(1) + # No per-row bookmark (and so no navigator popover) on the workspace: + # rows file via drag & drop; the plan page's title bookmark is the + # click path. + expect(response.body).not_to include("plan-row__save") + expect(response.body).not_to include('id="folder-picker-modal"') end end diff --git a/spec/system/folders_workspace_spec.rb b/spec/system/folders_workspace_spec.rb index b899de6..01788b3 100644 --- a/spec/system/folders_workspace_spec.rb +++ b/spec/system/folders_workspace_spec.rb @@ -207,29 +207,17 @@ def sign_in(user) expect(infra.reload.parent).to eq(team) end - it "files a plan via the row bookmark's folder navigator" do - visit plans_path - - row = find(".plan-row[data-plan-id='#{developing_plan.id}']") - row.find(".plan-row__save", visible: :all).click - - within("#folder-picker-modal") do - # The tree is hierarchical: Q3 nests under Team EBT. - expect(page).to have_css(".folder-picker__tree--nested .folder-picker__name", text: "Q3") - find(".folder-picker__option", text: "Q3").click - end - - expect(page).to have_css(".flash--notice", text: "Team EBT/Q3", wait: 5) - expect(author.library.placements.find_by(plan_id: developing_plan.id).folder).to eq(q3) - end - it "saves from the plan page via the bookmark beside the title" do visit plan_path(developing_plan) # The bookmark mounts into the title row (it's stamped client-side — # the broadcast-replaced header can't render viewer state itself). + # It's also the only click path into the folder navigator: workspace + # rows file via drag & drop and carry no bookmark of their own. find(".page-header__title-row .plan-bookmark").click within("#folder-picker-modal") do + # The tree is hierarchical: Q3 nests under Team EBT. + expect(page).to have_css(".folder-picker__tree--nested .folder-picker__name", text: "Q3") find(".folder-picker__option", text: "Infra").click end @@ -241,26 +229,29 @@ def sign_in(user) it "unsaves a filed plan with a second bookmark click — no dialog, no toast" do CoPlan::Plans::Place.call(plan: developing_plan, folder: q3, actor: author) - visit plans_path(folder: q3.id) + visit plan_path(developing_plan) - row = find(".plan-row[data-plan-id='#{developing_plan.id}']") - row.find(".plan-row__save--saved", visible: :all).click + find(".page-header__title-row .plan-bookmark--saved").click # The bookmark just lets go: no navigator, no confirmation, no toast — - # the page re-renders and the plan is out of the folder. + # the page re-renders with the bookmark back in its unsaved state. expect(page).not_to have_css("#folder-picker-modal:popover-open") - expect(page).not_to have_css(".plan-row[data-plan-id='#{developing_plan.id}']", wait: 5) + expect(page).to have_css(".page-header__title-row .plan-bookmark:not(.plan-bookmark--saved)", wait: 5) expect(page).not_to have_css(".flash--notice") expect(author.library.placements.where(plan_id: developing_plan.id)).to be_empty end - it "offers save controls on other users' plans too (shelving)" do + it "offers shelving on other users' plans too" do other_plan = create(:plan, :considering, created_by_user: other, title: "Someone Elses Plan") visit plans_path(scope: "all") + # The row drags into your library like any of your own… row = find(".plan-row[data-plan-id='#{other_plan.id}']") expect(row["draggable"]).to eq("true") - expect(row).to have_css(".plan-row__save", visible: :all) + + # …and the plan page offers the bookmark. + visit plan_path(other_plan) + expect(page).to have_css(".page-header__title-row .plan-bookmark") end end diff --git a/spec/system/references_spec.rb b/spec/system/references_spec.rb index 1362262..f248e8c 100644 --- a/spec/system/references_spec.rb +++ b/spec/system/references_spec.rb @@ -31,7 +31,9 @@ def open_add_reference_modal visit plan_path(plan) expect(page).to have_content("Some content here") - expect(page).to have_content("No references yet") + # Empty state is one quiet line, scoped to the section (attachments + # has its own "None yet"). + expect(page).to have_css("#footnote-references .plan-footnote__empty", text: "None yet") expect(page).to have_css("#footnote-references .plan-footnote__title", text: /references/i) end end @@ -66,7 +68,7 @@ def open_add_reference_modal # Turbo Stream replaces the list — reference appears without navigation expect(page).to have_link("My Repo", href: "https://github.com/org/repo") - expect(page).not_to have_content("No references yet") + expect(page).not_to have_css("#footnote-references .plan-footnote__empty") # Count span updated in-place via Turbo Stream (separate stream target) expect(page).to have_css("#references-count", text: "1") @@ -119,7 +121,7 @@ def open_add_reference_modal # Turbo Stream removes the reference and updates count expect(page).not_to have_content("Doomed") expect(page).to have_css("#references-count", text: "0") - expect(page).to have_content("No references yet") + expect(page).to have_css("#footnote-references .plan-footnote__empty", text: "None yet") end end