From 661be379a04b8ad10b7e4dc3af01c5267f06dca1 Mon Sep 17 00:00:00 2001 From: Hampton Lintorn-Catlin Date: Fri, 28 Aug 2026 16:02:51 -0500 Subject: [PATCH 1/7] Add section shortcut links Amp-Thread-ID: https://ampcode.com/threads/T-01a0449e-4249-720c-b618-ef3d17bd26ad Co-authored-by: Amp --- .../assets/stylesheets/coplan/application.css | 60 +++++++++++++++++++ .../coplan/content_nav_controller.js | 45 +++++++++++++- spec/system/comment_ux_spec.rb | 15 +++++ 3 files changed, 118 insertions(+), 2 deletions(-) diff --git a/engine/app/assets/stylesheets/coplan/application.css b/engine/app/assets/stylesheets/coplan/application.css index 0ded574b..7626e223 100644 --- a/engine/app/assets/stylesheets/coplan/application.css +++ b/engine/app/assets/stylesheets/coplan/application.css @@ -1645,6 +1645,66 @@ img.avatar { .markdown-rendered h2:first-child, .markdown-rendered h3:first-child { margin-top: 0; } +.section-permalink { + display: inline-flex; + align-items: center; + justify-content: center; + width: 1.5em; + height: 1.5em; + margin-left: var(--space-xs); + border: 0; + color: var(--color-text-muted); + opacity: 0; + position: relative; + vertical-align: -0.2em; + 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)); + 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; } +} + .markdown-rendered p { margin-bottom: var(--space-md); line-height: 1.9; diff --git a/engine/app/javascript/controllers/coplan/content_nav_controller.js b/engine/app/javascript/controllers/coplan/content_nav_controller.js index 578f8fa6..7e592c39 100644 --- a/engine/app/javascript/controllers/coplan/content_nav_controller.js +++ b/engine/app/javascript/controllers/coplan/content_nav_controller.js @@ -55,7 +55,9 @@ export default class extends Controller { const usedIds = new Set() this._headings.forEach((heading, index) => { - let baseId = heading.id || this.slugify(heading.textContent) || `section-${index + 1}` + heading.querySelector(":scope > .section-permalink")?.remove() + const headingText = heading.textContent.trim().replace(/\s+/g, " ") + let baseId = heading.id || this.slugify(headingText) || `section-${index + 1}` let id = baseId let suffix = 2 while (usedIds.has(id)) { @@ -64,6 +66,16 @@ export default class extends Controller { heading.id = id usedIds.add(id) + const permalink = document.createElement("a") + permalink.className = "section-permalink" + permalink.href = `#${id}` + permalink.dataset.sectionTitle = headingText + permalink.setAttribute("aria-label", `Copy link to ${headingText}`) + permalink.title = "Copy link to this section" + permalink.innerHTML = '' + permalink.addEventListener("click", event => this.copySectionLink(event)) + heading.appendChild(permalink) + const li = document.createElement("li") li.className = `content-nav__item content-nav__item--${heading.tagName.toLowerCase()}` li.dataset.headingId = id @@ -75,7 +87,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) @@ -147,6 +159,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) + 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 diff --git a/spec/system/comment_ux_spec.rb b/spec/system/comment_ux_spec.rb index 25f52874..de2ab850 100644 --- a/spec/system/comment_ux_spec.rb +++ b/spec/system/comment_ux_spec.rb @@ -72,6 +72,21 @@ 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) + + shortcut = find("h1 .section-permalink", visible: :all) + expect(shortcut[:href]).to end_with("#{plan_page_path(plan)}#architecture-overview") + expect(shortcut["aria-label"]).to eq("Copy link to Architecture Overview") + expect(page).to have_no_css("h1 .section-permalink", visible: true) + + 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") + end + it "renders Mermaid fences as diagrams" do plan.current_plan_version.update!(content_markdown: <<~MARKDOWN) # Request flow From dc03396d52dd250b1aedcefe949e0ac671ea3ed9 Mon Sep 17 00:00:00 2001 From: Hampton Lintorn-Catlin Date: Fri, 28 Aug 2026 16:12:11 -0500 Subject: [PATCH 2/7] Address section link review feedback Amp-Thread-ID: https://ampcode.com/threads/T-01a0449e-4249-720c-b618-ef3d17bd26ad Co-authored-by: Amp --- .../coplan/content_nav_controller.js | 17 +++++++++++++++-- .../coplan/live_update_controller.js | 1 + engine/app/views/coplan/plans/show.html.erb | 2 +- spec/system/checkbox_spec.rb | 1 + spec/system/comment_ux_spec.rb | 7 +++++-- 5 files changed, 23 insertions(+), 5 deletions(-) diff --git a/engine/app/javascript/controllers/coplan/content_nav_controller.js b/engine/app/javascript/controllers/coplan/content_nav_controller.js index 7e592c39..57433344 100644 --- a/engine/app/javascript/controllers/coplan/content_nav_controller.js +++ b/engine/app/javascript/controllers/coplan/content_nav_controller.js @@ -53,6 +53,7 @@ export default class extends Controller { if (this.hasShowBtnTarget) this.showBtnTarget.style.display = "" const usedIds = new Set() + const canonicalUrl = document.querySelector('link[rel="canonical"]')?.href || window.location.href this._headings.forEach((heading, index) => { heading.querySelector(":scope > .section-permalink")?.remove() @@ -67,13 +68,16 @@ export default class extends Controller { usedIds.add(id) const permalink = document.createElement("a") + const sectionUrl = new URL(canonicalUrl) + sectionUrl.search = "" + sectionUrl.hash = id permalink.className = "section-permalink" - permalink.href = `#${id}` + permalink.href = sectionUrl.href + 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 = '' - permalink.addEventListener("click", event => this.copySectionLink(event)) heading.appendChild(permalink) const li = document.createElement("li") @@ -108,6 +112,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 = () => { @@ -130,6 +137,12 @@ export default class extends Controller { this._updateActiveFromScroll() } + contentUpdated() { + this._activeHeadingId = null + this.buildToc() + this.setupScrollTracking() + } + _updateActiveFromScroll() { const threshold = 100 let active = null diff --git a/engine/app/javascript/controllers/coplan/live_update_controller.js b/engine/app/javascript/controllers/coplan/live_update_controller.js index 924cb999..79705564 100644 --- a/engine/app/javascript/controllers/coplan/live_update_controller.js +++ b/engine/app/javascript/controllers/coplan/live_update_controller.js @@ -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() } } diff --git a/engine/app/views/coplan/plans/show.html.erb b/engine/app/views/coplan/plans/show.html.erb index fa5bb637..35d9dbb3 100644 --- a/engine/app/views/coplan/plans/show.html.erb +++ b/engine/app/views/coplan/plans/show.html.erb @@ -48,7 +48,7 @@
<% if @plan.current_content.present? %> -
+