Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
75 changes: 75 additions & 0 deletions engine/app/assets/stylesheets/coplan/application.css
Original file line number Diff line number Diff line change
Expand Up @@ -1645,6 +1645,81 @@ img.avatar {
.markdown-rendered h2:first-child,
.markdown-rendered h3:first-child { margin-top: 0; }

.section-heading {
display: flex;
align-items: center;
Comment on lines +1649 to +1650

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Keep the title-slide accent above the heading

For presentation plans with a title slide, buildToc() adds this class to the slide's heading, so display: flex turns the h1::before accent bar defined in deck.css into a horizontal flex item beside the title instead of the block above it. This visibly breaks the title-slide composition—and can reflow it when Stimulus connects—in both the embedded deck and presentation mode; scope the row layout away from deck title headings or explicitly preserve their vertical accent layout.

AGENTS.md reference: AGENTS.md:L87-L89

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Updated by Amp 🤖 — title-slide headings now use a two-column grid with the accent spanning the full first row, preserving the accent above the title while centering the shortcut beside it. Added browser coverage for both the accent placement and title/link centerlines.

}

.section-heading__title {
min-width: 0;
}

.section-permalink {
display: flex;
flex: 0 0 1.5em;
align-items: center;
justify-content: center;
height: 1.5em;
margin-left: var(--space-xs);
border: 0;
color: var(--color-text-muted);
opacity: 0;
position: relative;
transition: opacity 120ms ease, color 120ms ease;
}

.markdown-rendered h1:hover > .section-permalink,
.markdown-rendered h2:hover > .section-permalink,
.markdown-rendered h3:hover > .section-permalink,
.section-permalink:focus-visible {
opacity: 1;
}

.section-permalink:hover,
.section-permalink:focus-visible {
color: var(--color-primary);
}

.section-permalink[data-copy-state="copied"] {
color: var(--color-success);
opacity: 1;
}

.section-permalink[data-copy-state="failed"] {
color: var(--color-danger);
opacity: 1;
}

.section-permalink[data-copy-state]::after {
content: attr(data-copy-message);
position: absolute;
left: calc(100% + var(--space-xs));
Comment on lines +1693 to +1696

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Keep copy feedback inside the viewport

On a narrow viewport with a long heading, the flex layout places the shortcut at the card's right edge, and this absolute pseudo-element always opens to its right. The “Copied!”/failure bubble consequently extends beyond the viewport and creates horizontal overflow or partially hidden feedback; position it inward at phone widths or otherwise clamp it to the viewport.

AGENTS.md reference: AGENTS.md:L87-L89

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Updated by Amp 🤖 — at phone widths the feedback bubble is right-aligned to the shortcut so it opens inward. Browser coverage clicks it at 390px and verifies both the right edge and absence of horizontal overflow.

top: 50%;
transform: translateY(-50%);
padding: 0.25rem 0.45rem;
border: 1px solid var(--color-border);
border-radius: var(--radius);
background: var(--color-surface);
box-shadow: var(--shadow-pop);
color: var(--color-text);
font-size: 0.75rem;
font-weight: 500;
line-height: 1;
white-space: nowrap;
z-index: 2;
}

@media (hover: none) {
.section-permalink { opacity: 1; }
}

@media (max-width: 640px) {
.section-permalink[data-copy-state]::after {
left: auto;
right: 0;
}
}

.markdown-rendered p {
margin-bottom: var(--space-md);
line-height: 1.9;
Expand Down
5 changes: 5 additions & 0 deletions engine/app/assets/stylesheets/coplan/deck.css
Original file line number Diff line number Diff line change
Expand Up @@ -241,6 +241,10 @@
.deck-slide--title .deck-content h1,
.deck-slide--title .deck-content h2,
.deck-slide--title .deck-content h3 {
display: grid;
grid-template-columns: minmax(0, auto) auto;
align-items: center;
justify-content: start;
font-size: 2.8em;
line-height: 1.1;
letter-spacing: -0.02em;
Expand All @@ -256,6 +260,7 @@
display: block;
width: 1.6em;
height: 0.16em;
grid-column: 1 / -1;
border-radius: 0.08em;
background: var(--deck-title-accent, var(--deck-accent));
margin-bottom: 0.55em;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -52,18 +52,64 @@ export default class extends Controller {
this.sidebarTarget.style.display = ""
if (this.hasShowBtnTarget) this.showBtnTarget.style.display = ""

const usedIds = new Set()
// Commonmarker supplies an empty self-link inside each heading. Promote
// its generated fragment to the heading itself when that heading does
// not already own a different server-assigned id. Visible or non-self
// links are authored content and stay untouched.
const promotedAnchors = new Map()
this._headings.forEach(heading => {
const anchor = Array.from(heading.querySelectorAll("a.anchor[id]"))
.find(candidate => candidate.textContent.trim() === "" &&
candidate.getAttribute("href") === `#${candidate.id}` &&
(!heading.id || heading.id === candidate.id))
if (anchor) promotedAnchors.set(heading, anchor)
})

const headingSet = new Set(this._headings)
const promotedAnchorSet = new Set(promotedAnchors.values())
const usedIds = new Set(
Array.from(document.querySelectorAll("[id]"))
.filter(element => !headingSet.has(element) && !promotedAnchorSet.has(element))
.map(element => element.id)
)
const pageUrl = new URL(window.location.href)
pageUrl.search = ""

this._headings.forEach((heading, index) => {
let baseId = heading.id || this.slugify(heading.textContent) || `section-${index + 1}`
heading.querySelector(":scope > .section-permalink")?.remove()
const promotedAnchor = promotedAnchors.get(heading)
const headingText = heading.textContent.trim().replace(/\s+/g, " ")
let baseId = heading.id || promotedAnchor?.id || this.slugify(headingText) || `section-${index + 1}`
let id = baseId
let suffix = 2
while (usedIds.has(id)) {
id = `${baseId}-${suffix++}`
}
heading.id = id
promotedAnchor?.remove()
heading.classList.add("section-heading")
usedIds.add(id)

let title = heading.querySelector(":scope > .section-heading__title")
if (!title) {
title = document.createElement("span")
title.className = "section-heading__title"
while (heading.firstChild) title.appendChild(heading.firstChild)
heading.appendChild(title)
}

const permalink = document.createElement("a")
const sectionUrl = new URL(pageUrl)
sectionUrl.hash = id
permalink.className = "section-permalink"
Comment on lines +101 to +104

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Rebuild section links after live content swaps

When an open plan receives a live revision, live_update_controller.js replaces the children of #plan-content-body, but this ancestor controller remains connected and buildToc() is not called again. The replacement headings therefore never pass through this permalink creation block, so every section shortcut disappears until a full reload; listen for the in-place content replacement and rebuild the heading state rather than relying solely on connect().

AGENTS.md reference: AGENTS.md:L76-L80

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Updated by Amp 🤖 — live body replacements now dispatch a bubbling content-updated event; the content-nav action rebuilds headings, shortcut links, the outline, and scroll tracking. The checkbox live-update system spec verifies the rebuilt link.

permalink.href = sectionUrl.href
Comment on lines +102 to +105

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Prevent section shortcuts from reusing existing IDs

When another element outside the H1–H3 outline already owns this generated ID, the shortcut copies a fragment that resolves to that earlier element rather than this heading. For example, the Markdown renderer assigns section-1 to a numbered H4 (markdown_helper.rb:119-125), but a later H2 titled “Section 1” also receives section-1 because usedIds only tracks the selected H1–H3 headings; its copied shortcut therefore opens the H4. Reserve all existing document IDs before choosing the shortcut target ID.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Updated by Amp 🤖 — generated Commonmarker self-anchors are replaced by the shortcut, and every remaining document ID is reserved before heading IDs are assigned. Browser coverage injects an existing fragment and verifies the heading receives a unique suffixed target.

permalink.dataset.action = "click->coplan--content-nav#copySectionLink"
permalink.dataset.sectionTitle = headingText
permalink.setAttribute("aria-label", `Copy link to ${headingText}`)
permalink.title = "Copy link to this section"
permalink.innerHTML = '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/></svg>'
heading.appendChild(permalink)

const li = document.createElement("li")
li.className = `content-nav__item content-nav__item--${heading.tagName.toLowerCase()}`
li.dataset.headingId = id
Expand All @@ -75,7 +121,7 @@ export default class extends Controller {

const text = document.createElement("span")
text.className = "content-nav__link-text"
text.textContent = heading.textContent
text.textContent = headingText
a.appendChild(text)

li.appendChild(a)
Expand All @@ -96,6 +142,9 @@ export default class extends Controller {
}

setupScrollTracking() {
if (this._scrollHandler) {
window.removeEventListener("scroll", this._scrollHandler)
}
if (!this._headings || this._headings.length === 0) return

this._scrollHandler = () => {
Expand All @@ -118,6 +167,12 @@ export default class extends Controller {
this._updateActiveFromScroll()
}

contentUpdated() {
this._activeHeadingId = null
this.buildToc()
this.setupScrollTracking()
}

_updateActiveFromScroll() {
const threshold = 100
let active = null
Expand Down Expand Up @@ -147,6 +202,35 @@ export default class extends Controller {
heading.scrollIntoView({ behavior: "smooth", block: "start" })
}

async copySectionLink(event) {
if (event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return

event.preventDefault()
const link = event.currentTarget

try {
await navigator.clipboard.writeText(link.href)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Remove thread parameters from copied section URLs

When the plan was opened from a notification using ?thread=<id>, the fragment-only href resolves against that full URL, so copying link.href retains the thread parameter. Opening the resulting section link causes text_selection_controller to auto-open and scroll to that comment thread, overriding the section destination; construct the copied URL from the canonical plan URL or remove transient query parameters before writing it.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Updated by Amp 🤖 — shortcut hrefs now start from the canonical plan URL and strip query parameters before adding the section fragment. Coverage opens through a transient thread query and verifies it is absent from the shortcut.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Follow-up by Amp 🤖 — this now derives from the current page URL (so historical versions retain their own path), strips its query, and adds only the section fragment.

this.flashSectionLink(link, "copied", "Copied link to section")
} catch {
this.flashSectionLink(link, "failed", "Copy failed")
}
}

flashSectionLink(link, state, label) {
link.dataset.copyState = state
link.dataset.copyMessage = state === "copied" ? "Copied!" : "Copy failed"
link.setAttribute("aria-label", label)
link.title = label

clearTimeout(link._copyResetTimer)
link._copyResetTimer = setTimeout(() => {
link.removeAttribute("data-copy-state")
link.removeAttribute("data-copy-message")
link.setAttribute("aria-label", `Copy link to ${link.dataset.sectionTitle}`)
link.title = "Copy link to this section"
}, 2000)
}

// The back-matter links (References, Attachments) jump the same way the
// outline above does. Turbo counts a same-page fragment link as a full
// visit — it refetches and re-renders the page — so the bare anchor read
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@ export default class extends Controller {
if (incomingRevision) {
target.setAttribute("data-coplan--live-update-revision-value", String(incomingRevision))
}
target.dispatchEvent(new CustomEvent("coplan:content-updated", { bubbles: true }))
clearStaleBanner()
}
}
Expand Down
2 changes: 1 addition & 1 deletion engine/app/views/coplan/plans/show.html.erb
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@

<div class="plan-document card">
<% if @plan.current_content.present? %>
<div class="plan-layout" data-controller="coplan--text-selection coplan--content-nav coplan--checkbox coplan--changed-sections coplan--reference-preview" data-coplan--text-selection-focus-thread-value="<%= params[:thread] %>" data-action="keydown.esc@document->coplan--text-selection#dismiss keydown.esc@document->coplan--reference-preview#dismiss click@document->coplan--reference-preview#dismissFromOutside scroll@window->coplan--reference-preview#reposition resize@window->coplan--reference-preview#reposition coplan:open-thread@document->coplan--text-selection#openThread" data-coplan--checkbox-revision-value="<%= @plan.current_revision %>" data-coplan--checkbox-toggle-url-value="<%= toggle_checkbox_plan_path(@plan) %>" data-coplan--changed-sections-keys-value="<%= @changed_sections.keys.to_json %>" data-coplan--changed-sections-rewritten-value="<%= @changed_sections.rewritten? %>" data-coplan--changed-sections-history-url-value="<%= plan_history_browse_path(@plan) %>">
<div class="plan-layout" data-controller="coplan--text-selection coplan--content-nav coplan--checkbox coplan--changed-sections coplan--reference-preview" data-coplan--text-selection-focus-thread-value="<%= params[:thread] %>" data-action="keydown.esc@document->coplan--text-selection#dismiss keydown.esc@document->coplan--reference-preview#dismiss click@document->coplan--reference-preview#dismissFromOutside scroll@window->coplan--reference-preview#reposition resize@window->coplan--reference-preview#reposition coplan:open-thread@document->coplan--text-selection#openThread coplan:content-updated->coplan--content-nav#contentUpdated" data-coplan--checkbox-revision-value="<%= @plan.current_revision %>" data-coplan--checkbox-toggle-url-value="<%= toggle_checkbox_plan_path(@plan) %>" data-coplan--changed-sections-keys-value="<%= @changed_sections.keys.to_json %>" data-coplan--changed-sections-rewritten-value="<%= @changed_sections.rewritten? %>" data-coplan--changed-sections-history-url-value="<%= plan_history_browse_path(@plan) %>">
<nav class="content-nav" data-coplan--content-nav-target="sidebar" aria-label="Document outline">
<%# No "Contents" label — the outline speaks for itself, and the plan
title now lives in the sticky nav. Just the collapse control. %>
Expand Down
1 change: 1 addition & 0 deletions spec/system/checkbox_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,7 @@ def sign_in(user)
# second checkbox only after the swap lands, or the handle can detach
# between find and click.
wait_for_content_refresh(2)
expect(page).to have_css("h1 .section-permalink", visible: :all)

second_cb = all('input[type="checkbox"]')[1]
second_cb.click
Expand Down
80 changes: 80 additions & 0 deletions spec/system/comment_ux_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,86 @@ def create_anchored_thread(plan:, anchor_text:, body:, user:)
expect(page).to have_content("microservices architecture")
end

it "copies a shortcut link to a section" do
visit plan_page_path(plan, thread: "transient-comment-id")

shortcut = find("h1 .section-permalink", visible: :all)
expect(shortcut[:href]).to end_with("#{plan_page_path(plan)}#architecture-overview")
expect(shortcut[:href]).not_to include("thread=")
expect(shortcut["aria-label"]).to eq("Copy link to Architecture Overview")
if page.evaluate_script("matchMedia('(hover: hover)').matches")
expect(page).to have_no_css("h1 .section-permalink", visible: true)
end

find(".markdown-rendered h1").hover
shortcut = find("h1 .section-permalink", visible: true)
center_delta = page.evaluate_script(<<~JS)
(() => {
const title = document.querySelector("h1 .section-heading__title").getBoundingClientRect()
const link = document.querySelector("h1 .section-permalink").getBoundingClientRect()
return Math.abs((title.top + title.bottom - link.top - link.bottom) / 2)
})()
JS
expect(center_delta).to be < 1

page.driver.browser.manage.window.resize_to(390, 844)
begin
find(".markdown-rendered h1").hover
shortcut = find("h1 .section-permalink", visible: true)
shortcut.click
expect(page).to have_css("h1 .section-permalink[data-copy-state='copied']", visible: :all)
expect(find("h1 .section-permalink", visible: :all)["aria-label"]).to eq("Copied link to section")

viewport = page.evaluate_script(<<~JS)
(() => ({
contentWidth: document.documentElement.scrollWidth,
viewportWidth: window.innerWidth,
feedbackRight: getComputedStyle(document.querySelector("h1 .section-permalink"), "::after").right
}))()
JS
expect(viewport["contentWidth"]).to be <= viewport["viewportWidth"]
expect(viewport["feedbackRight"]).to eq("0px")
ensure
page.driver.browser.manage.window.resize_to(1400, 900)
end
end

it "does not reuse an existing document fragment for a section shortcut" do
visit plan_page_path(plan)
page.execute_script(<<~JS)
const content = document.querySelector(".markdown-rendered")
const existing = document.createElement("span")
existing.id = "existing-fragment"
content.prepend(existing)
const heading = content.querySelector("h2")
heading.removeAttribute("id")
heading.textContent = "Existing Fragment"
content.dispatchEvent(new CustomEvent("coplan:content-updated", { bubbles: true }))
JS

heading = find(".markdown-rendered h2", text: "Existing Fragment", exact_text: true)
expect(heading[:id]).to eq("existing-fragment-2")
expect(heading.find(".section-permalink", visible: :all)[:href]).to end_with("#existing-fragment-2")
end

it "preserves generated duplicate fragments and authored heading links" do
plan.current_plan_version.update!(content_markdown: "## Intro\n\nFirst\n\n## Intro\n\nSecond")
visit plan_page_path(plan)

expect(all(".markdown-rendered h2").map { |heading| heading[:id] }).to eq(%w[intro intro-1])

page.execute_script(<<~JS)
const content = document.querySelector(".markdown-rendered")
const heading = content.querySelector("h2")
heading.removeAttribute("id")
heading.innerHTML = '<a class="anchor" id="details" href="/details">Details</a>'
content.dispatchEvent(new CustomEvent("coplan:content-updated", { bubbles: true }))
JS

expect(find(".markdown-rendered h2", text: "Details", exact_text: true)[:id]).to eq("details-2")
expect(find(".markdown-rendered h2 a#details", text: "Details")[:href]).to end_with("/details")
end

it "renders Mermaid fences as diagrams" do
plan.current_plan_version.update!(content_markdown: <<~MARKDOWN)
# Request flow
Expand Down
22 changes: 22 additions & 0 deletions spec/system/deck_ux_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,28 @@ def attachments_on_screen?
JS
end

it "keeps the title-slide accent above its aligned section shortcut" do
visit plan_page_path(plan)
expect(page).to have_css(".deck-slide--title .section-permalink", visible: :all)

layout = page.evaluate_script(<<~JS)
(() => {
const heading = document.querySelector(".deck-slide--title .section-heading")
const title = heading.querySelector(".section-heading__title").getBoundingClientRect()
const link = heading.querySelector(".section-permalink").getBoundingClientRect()
const accent = getComputedStyle(heading, "::before")
return {
display: getComputedStyle(heading).display,
accentColumn: `${accent.gridColumnStart} / ${accent.gridColumnEnd}`,
centerDelta: Math.abs((title.top + title.bottom - link.top - link.bottom) / 2)
}
})()
JS

expect(layout).to include("display" => "grid", "accentColumn" => "1 / -1")
expect(layout["centerDelta"]).to be < 1
end

describe "present mode" do
it "treats a drag as a highlight and a bare click as the next slide" do
visit plan_page_path(plan)
Expand Down
Loading