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