From b8f1b20895549edc52c12f450d721e63c0c12ae1 Mon Sep 17 00:00:00 2001 From: David Grochowski Date: Sun, 8 Nov 2026 20:52:19 -0500 Subject: [PATCH 1/4] Make the document outline collapsible by subtree Long outlines (more than 12 entries) nest by heading level and fold each section behind a chevron, except where the reader is. Scrolling and #section links unfold; expand/collapse all, Left/Right keys, and per-plan sessionStorage persistence; state survives live updates and the editor. Folded sections total their sub-sections' discussion counts. Short outlines are unchanged. Adds the nested-outline demo. --- docs/DEMO_LIBRARY.md | 2 + .../stylesheets/coplan/document-outline.css | 84 ++++- .../coplan/content_nav_controller.js | 303 +++++++++++++++--- .../views/coplan/plan_versions/show.html.erb | 5 +- .../plans/_outline_tree_controls.html.erb | 8 + engine/app/views/coplan/plans/show.html.erb | 8 +- engine/db/demo/content/nested-outline.md | 171 ++++++++++ engine/lib/coplan/development_seed.rb | 16 + spec/lib/development_seed_spec.rb | 19 ++ spec/system/outline_collapse_spec.rb | 253 +++++++++++++++ 10 files changed, 802 insertions(+), 67 deletions(-) create mode 100644 engine/app/views/coplan/plans/_outline_tree_controls.html.erb create mode 100644 engine/db/demo/content/nested-outline.md create mode 100644 spec/system/outline_collapse_spec.rb diff --git a/docs/DEMO_LIBRARY.md b/docs/DEMO_LIBRARY.md index 4b0a7c82..b533a8c0 100644 --- a/docs/DEMO_LIBRARY.md +++ b/docs/DEMO_LIBRARY.md @@ -64,6 +64,7 @@ shipped types, including Scratchpad, PRD, Test Plan, and Handoff. | reading-specimen | A realistic design doc for judging reading in both modes: prose, headings, lists, tasks, quote, table, footnote, image, SQL/Ruby/diff/YAML code, a long scrolling line, flowchart and sequence diagrams, plus open, agent-answered, resolved, diagram-node, table-cell, and general discussions | | document-title | One editable title; autosave keeps the URL stable until Done; title boundaries and empty-title behavior | | image-resizing | Resize and restore image widths, wrap prose from the top, Shift+Enter beside, Enter below | +| nested-outline | Ten numbered sections with sub-sections. The outline starts folded except where you are reading; chevrons, Left/Right, and expand/collapse all toggle it; scrolling or a `#section` link unfolds; folded sections total their discussions; toggles survive reload | Flowcharts currently support source-backed node and edge comments. Other Mermaid types support whole-diagram comments. The demos label this boundary; @@ -99,6 +100,7 @@ look up this key rather than copying generated UUIDs. | experiment-results | Wide research table with cohort comparisons | | code-walkthrough | Dense code, inline discussion, and related-reading target | | long-mobile-toc | More than 30 meaningful sections for navigation checks | +| nested-outline | 10 numbered sections, 37 sub-sections, four open discussions in sub-sections 4.2, 4.3, and 7.1 | | draft-notes | Unlisted author draft; its original title URL redirects after a retitle | | archived-proposal | Archived document available through a direct link | | spanish-brief / japanese-roadmap / arabic-research | Multilingual and right-to-left content | diff --git a/engine/app/assets/stylesheets/coplan/document-outline.css b/engine/app/assets/stylesheets/coplan/document-outline.css index 8129ddd1..b82b4d1f 100644 --- a/engine/app/assets/stylesheets/coplan/document-outline.css +++ b/engine/app/assets/stylesheets/coplan/document-outline.css @@ -24,15 +24,22 @@ margin-right: 0; } -/* No label anymore — the lone collapse control floats into the top-right - corner so the outline starts at the very top (no empty header band where - "Contents" used to sit) and the first item's text flows beside it. */ +/* No label anymore — the controls float into the top-right corner so the + outline starts at the very top (no empty header band where "Contents" + used to sit) and the first item's text flows beside them. */ .content-nav__header { float: right; + display: flex; + align-items: center; margin-left: var(--space-xs); } -.content-nav__toggle { +.content-nav__tree-controls:not([hidden]) { + display: inline-flex; +} + +.content-nav__toggle, +.content-nav__tree-control { background: none; border: none; cursor: pointer; @@ -44,11 +51,17 @@ transition: color 0.15s; } -.content-nav__toggle:hover { +.content-nav__tree-control { + display: inline-flex; +} + +.content-nav__toggle:hover, +.content-nav__tree-control:hover { color: var(--color-text); } -.content-nav__list { +.content-nav__list, +.content-nav__children { list-style: none; padding: 0; margin: 0; @@ -60,7 +73,7 @@ margin: 0; } -.content-nav__item--remote-change .content-nav__link { +.content-nav__item--remote-change > .content-nav__row > .content-nav__link { animation: content-nav-remote-change 2.6s ease-out; } @@ -70,22 +83,69 @@ } @media (prefers-reduced-motion: reduce) { - .content-nav__item--remote-change .content-nav__link { animation: none; } + .content-nav__item--remote-change > .content-nav__row > .content-nav__link { animation: none; } } -.content-nav__item--h1 { - /* top level — no indent */ +/* Entries nest by heading, so indent the row rather than the item: a + nested item's padding would add to its parent's. */ +.content-nav__row { + display: flex; + align-items: flex-start; } -.content-nav__item--h2 { +.content-nav__item--h2 > .content-nav__row { padding-left: var(--space-md); } -.content-nav__item--h3 { +.content-nav__item--h3 > .content-nav__row { padding-left: var(--space-xl); } +/* Long outlines only: a chevron slot on every row keeps labels aligned. */ +.content-nav__disclosure, +.content-nav__disclosure-spacer { + flex-shrink: 0; + width: 16px; + height: calc(0.8rem * 1.4 + 6px); +} + +.content-nav__disclosure { + display: inline-flex; + align-items: center; + justify-content: center; + padding: 0; + background: none; + border: none; + border-radius: var(--radius); + color: var(--color-text-muted); + cursor: pointer; + transition: color 0.15s; +} + +.content-nav__disclosure:hover { + color: var(--color-text); +} + +.content-nav__disclosure:focus-visible { + outline: 2px solid var(--color-primary); + outline-offset: -2px; +} + +.content-nav__disclosure svg { + transition: transform 0.15s; +} + +.content-nav__disclosure[aria-expanded="true"] svg { + transform: rotate(90deg); +} + +@media (prefers-reduced-motion: reduce) { + .content-nav__disclosure svg { transition: none; } +} + .content-nav__link { + flex: 1; + min-width: 0; display: flex; align-items: center; gap: var(--space-xs); diff --git a/engine/app/javascript/controllers/coplan/content_nav_controller.js b/engine/app/javascript/controllers/coplan/content_nav_controller.js index 139a5858..e20033c8 100644 --- a/engine/app/javascript/controllers/coplan/content_nav_controller.js +++ b/engine/app/javascript/controllers/coplan/content_nav_controller.js @@ -2,10 +2,15 @@ import { Controller } from "@hotwired/stimulus" import { registerShortcuts, commandFor } from "coplan/shortcuts" const STORAGE_KEY = "coplan:content-nav-visible" +const OUTLINE_STATE_PREFIX = "coplan:outline-expanded:" +// Outlines with more entries than this open with their subtrees folded. +// Shorter ones render flat, exactly as before collapsing existed. +const COLLAPSE_THRESHOLD = 12 +const CHEVRON = '' export default class extends Controller { - static targets = ["sidebar", "list", "content", "toggleBtn", "showBtn", "editorToggleBtn", "commentsCount"] - static values = { visible: { type: Boolean, default: true } } + static targets = ["sidebar", "list", "content", "toggleBtn", "showBtn", "editorToggleBtn", "commentsCount", "treeControls"] + static values = { visible: { type: Boolean, default: true }, planId: String } connect() { const stored = localStorage.getItem(STORAGE_KEY) @@ -14,6 +19,7 @@ export default class extends Controller { } this.buildToc() + this.revealHashTarget() this.setupScrollTracking() this.releaseShortcuts = registerShortcuts(this, "outline", event => this.handleKeydown(event)) @@ -45,7 +51,6 @@ export default class extends Controller { const documentRoot = this.contentTarget if (!documentRoot.querySelector(".markdown-rendered") && !documentRoot.classList.contains("markdown-rendered")) return - this.listTarget.innerHTML = "" this._itemsById = new Map() // A document renders one .markdown-rendered; a deck renders one per // slide. Scan the whole content column either way — scanning the first @@ -55,6 +60,7 @@ export default class extends Controller { this._headings = headings if (headings.length === 0) { + this.assembleOutline([]) this.sidebarTarget.style.display = "none" if (this.hasShowBtnTarget) this.showBtnTarget.style.display = "none" return @@ -84,6 +90,7 @@ export default class extends Controller { ) const pageUrl = new URL(window.location.href) pageUrl.search = "" + const entries = [] this._headings.forEach((heading, index) => { heading.querySelector(":scope > .section-permalink")?.remove() @@ -120,25 +127,10 @@ export default class extends Controller { permalink.innerHTML = '' heading.appendChild(permalink) - const li = document.createElement("li") - li.className = `content-nav__item content-nav__item--${heading.tagName.toLowerCase()}` - li.dataset.headingId = id - - const a = document.createElement("a") - a.className = "content-nav__link" - a.href = `#${id}` - a.addEventListener("click", (e) => this.handleLinkClick(e, heading)) - - const text = document.createElement("span") - text.className = "content-nav__link-text" - text.textContent = headingText - a.appendChild(text) - - li.appendChild(a) - this.listTarget.appendChild(li) - this._itemsById.set(id, li) + entries.push(this.outlineEntry(id, heading.tagName, headingText, event => this.handleLinkClick(event, heading))) }) + this.assembleOutline(entries) this.updateCommentBadges() } @@ -155,7 +147,6 @@ export default class extends Controller { this._editorHeadingSignature = this.editorHeadingSignature(this._headings) this._outlineIds = [] this._itemsById = new Map() - this.listTarget.replaceChildren() this.sidebarTarget.style.display = this._editorSidebarDisplayed ? "" : "none" // The toolbar owns this action while editing. The reader's floating // button would cover formatting when an empty notice no longer reserves @@ -165,6 +156,7 @@ export default class extends Controller { this.updateEditorToggle() const used = new Set() + const entries = [] this._headings.forEach((heading, index) => { const title = heading.textContent.trim().replace(/\s+/g, " ") const base = this.slugify(title) || `section-${index + 1}` @@ -173,24 +165,217 @@ export default class extends Controller { used.add(id) this._outlineIds.push(id) - const item = document.createElement("li") - item.className = `content-nav__item content-nav__item--${heading.tagName.toLowerCase()}` - item.dataset.headingId = id - const link = document.createElement("a") - link.className = "content-nav__link" - link.href = `#${id}` - const label = document.createElement("span") - label.className = "content-nav__link-text" - label.textContent = title - link.append(label) - link.addEventListener("click", event => this.handleLinkClick(event, this.editorHeadings()[index], id)) - item.append(link) - this.listTarget.append(item) - this._itemsById.set(id, item) + entries.push(this.outlineEntry(id, heading.tagName, title, event => this.handleLinkClick(event, this.editorHeadings()[index], id))) }) + this.assembleOutline(entries) this.updateCommentBadges() } + outlineEntry(id, tagName, text, onClick) { + const item = document.createElement("li") + item.className = `content-nav__item content-nav__item--${tagName.toLowerCase()}` + item.dataset.headingId = id + const row = document.createElement("div") + row.className = "content-nav__row" + const link = document.createElement("a") + link.className = "content-nav__link" + link.href = `#${id}` + const label = document.createElement("span") + label.className = "content-nav__link-text" + label.textContent = text + link.append(label) + link.addEventListener("click", onClick) + row.append(link) + item.append(row) + this._itemsById.set(id, item) + return { id, level: Number(tagName[1]), text, item, row, children: [] } + } + + // Nest each heading under the nearest shallower one before it. Rebuilds + // (live updates, editor typing) carry each subtree's state over by id, + // the reader's remembered toggle, heading text, then outline position — + // typing in a heading changes both its editor id and its text. + assembleOutline(entries) { + const previousById = this._expanded || new Map() + const previousByText = new Map() + const previousByPosition = new Map() + previousById.forEach((expanded, id) => { + previousByText.set(this._textById.get(id), expanded) + previousByPosition.set(this._positionById.get(id), expanded) + }) + const stored = this.storedOutlineState() + this._expanded = new Map() + this._textById = new Map() + this._positionById = new Map() + this._parentById = new Map() + this._childIds = new Map() + + const roots = [] + const stack = [] + entries.forEach((entry, index) => { + this._textById.set(entry.id, entry.text) + this._positionById.set(entry.id, `${index}:${entry.level}`) + while (stack.length && stack.at(-1).level >= entry.level) stack.pop() + const parent = stack.at(-1) + if (parent) { + parent.children.push(entry) + this._parentById.set(entry.id, parent.id) + } else roots.push(entry) + stack.push(entry) + }) + + // A lone root (usually the document title) holds the whole outline; + // folding it would leave a single line. + const collapsible = entries.length > COLLAPSE_THRESHOLD + const foldable = entry => collapsible && entry.children.length > 0 && !(roots.length === 1 && entry === roots[0]) + const anyFoldable = entries.some(foldable) + // A section that first becomes foldable in a rebuild (say the outline + // grows past the threshold) must not fold away the reader's position. + const activeAncestors = new Set() + for (let parent = this._parentById.get(this._revealedActiveId); parent; parent = this._parentById.get(parent)) activeAncestors.add(parent) + + const place = (entry, list) => { + list.append(entry.item) + this._childIds.set(entry.id, entry.children.map(child => child.id)) + if (!entry.children.length) { + if (anyFoldable) entry.row.prepend(Object.assign(document.createElement("span"), { className: "content-nav__disclosure-spacer" })) + return + } + const group = document.createElement("ul") + group.className = "content-nav__children" + group.id = `content-nav-group-${entry.id}` + entry.item.append(group) + entry.children.forEach(child => place(child, group)) + if (!foldable(entry)) return + + const button = document.createElement("button") + button.type = "button" + button.className = "content-nav__disclosure" + button.setAttribute("aria-controls", group.id) + button.setAttribute("aria-label", `${entry.text} sub-sections`) + button.dataset.action = "coplan--content-nav#toggleSection" + button.innerHTML = CHEVRON + entry.row.prepend(button) + const remembered = typeof stored[entry.id] === "boolean" ? stored[entry.id] : undefined + this._expanded.set(entry.id, previousById.get(entry.id) ?? remembered ?? previousByText.get(entry.text) ?? + previousByPosition.get(this._positionById.get(entry.id)) ?? activeAncestors.has(entry.id)) + this.renderExpanded(entry.id) + } + this.listTarget.replaceChildren() + roots.forEach(root => place(root, this.listTarget)) + if (this.hasTreeControlsTarget) this.treeControlsTarget.hidden = this._expanded.size === 0 + } + + renderExpanded(id) { + const item = this._itemsById.get(id) + const expanded = this._expanded.get(id) + const button = item.querySelector(":scope > .content-nav__row > .content-nav__disclosure") + button.setAttribute("aria-expanded", String(expanded)) + button.title = expanded ? "Collapse" : "Expand" + item.querySelector(":scope > .content-nav__children").hidden = !expanded + } + + // Only `remember` (a reader's own toggle) reaches sessionStorage; + // following the scroll position or a link is not a preference. + setExpanded(ids, expanded, { remember = false } = {}) { + if (!this._expanded) return + const state = remember ? this.storedOutlineState() : null + let changed = false + ids.forEach(id => { + if (!this._expanded.has(id)) return + if (state) state[id] = expanded + if (this._expanded.get(id) === expanded) return + this._expanded.set(id, expanded) + this.renderExpanded(id) + changed = true + }) + if (state) this.storeOutlineState(state) + if (changed) this.renderBadges() + } + + revealEntry(id, { includeSelf = false } = {}) { + const ids = includeSelf ? [ id ] : [] + for (let parent = this._parentById?.get(id); parent; parent = this._parentById.get(parent)) ids.push(parent) + this.setExpanded(ids, true) + } + + revealHashTarget() { + let id + try { id = decodeURIComponent(window.location.hash.slice(1)) } catch { return } + if (id && this._itemsById?.has(id)) this.revealEntry(id, { includeSelf: true }) + } + + toggleSection(event) { + const id = event.currentTarget.closest(".content-nav__item").dataset.headingId + this.setExpanded([ id ], !this._expanded.get(id), { remember: true }) + } + + expandAll() { + this.setExpanded([ ...this._expanded.keys() ], true, { remember: true }) + } + + collapseAll() { + this.setExpanded([ ...this._expanded.keys() ], false, { remember: true }) + } + + // Tree-view keys on a focused entry of a collapsible outline: Right opens + // a folded subtree, then moves into it; Left folds an open one, then + // moves to the parent. + outlineKeydown(event) { + if (!["ArrowLeft", "ArrowRight"].includes(event.key) || event.altKey || event.ctrlKey || event.metaKey || event.shiftKey) return + if (!this._expanded?.size) return + const id = event.target.closest(".content-nav__item")?.dataset.headingId + if (!id || !this._itemsById.has(id)) return + const expanded = this._expanded.get(id) + const children = this._childIds.get(id) || [] + let focusId = null + if (event.key === "ArrowRight") { + if (expanded === false) this.setExpanded([ id ], true, { remember: true }) + else if (children.length) focusId = children[0] + else return + } else if (expanded === true) { + this.setExpanded([ id ], false, { remember: true }) + } else { + focusId = this._parentById.get(id) + if (!focusId) return + } + event.preventDefault() + event.stopPropagation() + if (focusId) this._itemsById.get(focusId).querySelector(":scope > .content-nav__row > .content-nav__link").focus() + } + + hashChanged() { + this.revealHashTarget() + } + + outlineStateKey() { + return OUTLINE_STATE_PREFIX + (this.planIdValue || window.location.pathname) + } + + // Keys for headings that no longer exist are simply never looked up. + storedOutlineState() { + try { + const state = JSON.parse(sessionStorage.getItem(this.outlineStateKey())) + return state && typeof state === "object" && !Array.isArray(state) ? state : {} + } catch { + return {} + } + } + + storeOutlineState(state) { + try { sessionStorage.setItem(this.outlineStateKey(), JSON.stringify(state)) } catch { /* storage unavailable */ } + } + + // The entry a reader can see for `id`: itself, or the outermost folded + // ancestor hiding it. + visibleItem(id) { + let visible = id + for (let parent = this._parentById?.get(id); parent; parent = this._parentById.get(parent)) { + if (this._expanded.get(parent) === false) visible = parent + } + return this._itemsById?.get(visible) + } + editorHeadings() { return Array.from(this._editorRoot?.querySelectorAll(":scope > h1, :scope > h2, :scope > h3") || []) } @@ -261,7 +446,8 @@ export default class extends Controller { remoteChange(event) { for (const key of event.detail.keys || []) { - const item = this._itemsById?.get(key) + // A change inside a folded subtree pulses the entry that hides it. + const item = this.visibleItem(key) if (!item) continue item.classList.remove("content-nav__item--remote-change") // Restart the pulse when consecutive revisions touch one section. @@ -295,6 +481,12 @@ export default class extends Controller { const id = this._editorRoot ? this._outlineIds[Math.max(index, 0)] : active?.id || headings[0]?.id if (id && id !== this._activeHeadingId) { this._activeHeadingId = id + // Unfold the active entry's ancestors once per section reached. A + // rebuild must not reopen a subtree the reader folded around it. + if (id !== this._revealedActiveId) { + this._revealedActiveId = id + this.revealEntry(id) + } this._setActiveLink(id) } } @@ -474,25 +666,36 @@ export default class extends Controller { // headings live in different slides, so the span between two of them // crosses .markdown-rendered blocks. const headings = this._editorRoot ? this.editorHeadings() : this._headings + this._ownCounts = new Map() headings.forEach((heading, index) => { - const nextHeading = headings[index + 1] - const count = this.countOpenThreadsBetween(heading, nextHeading, container) - - const item = this._itemsById?.get(this._editorRoot ? this._outlineIds[index] : heading.id) - if (!item) return - - const existing = item.querySelector(".content-nav__badge") - if (existing) existing.remove() + const id = this._editorRoot ? this._outlineIds[index] : heading.id + this._ownCounts.set(id, this.countOpenThreadsBetween(heading, headings[index + 1], container)) + }) + this.renderBadges() + } - if (count > 0) { - const badge = document.createElement("span") - badge.className = "content-nav__badge content-nav__badge--open" - badge.textContent = count - item.querySelector(".content-nav__link").appendChild(badge) - } + // A folded entry adds its hidden sub-sections' counts to its own. + renderBadges() { + if (!this._ownCounts || !this._itemsById) return + this._itemsById.forEach((item, id) => { + const link = item.querySelector(":scope > .content-nav__row > .content-nav__link") + link.querySelector(".content-nav__badge")?.remove() + const hidden = this._expanded?.get(id) === false ? this.descendantCount(id) : 0 + const count = (this._ownCounts.get(id) || 0) + hidden + if (count === 0) return + + const badge = document.createElement("span") + badge.className = "content-nav__badge content-nav__badge--open" + badge.textContent = count + if (hidden) badge.title = `${count} open discussion${count === 1 ? "" : "s"}, including collapsed sub-sections` + link.appendChild(badge) }) } + descendantCount(id) { + return (this._childIds.get(id) || []).reduce((sum, child) => sum + (this._ownCounts.get(child) || 0) + this.descendantCount(child), 0) + } + countOpenThreadsBetween(startHeading, endHeading, container) { const seen = new Set() let collecting = false diff --git a/engine/app/views/coplan/plan_versions/show.html.erb b/engine/app/views/coplan/plan_versions/show.html.erb index 34ad8206..d249d36e 100644 --- a/engine/app/views/coplan/plan_versions/show.html.erb +++ b/engine/app/views/coplan/plan_versions/show.html.erb @@ -29,13 +29,14 @@ <% end %> -
+
diff --git a/engine/app/views/coplan/plans/_outline_tree_controls.html.erb b/engine/app/views/coplan/plans/_outline_tree_controls.html.erb new file mode 100644 index 00000000..09309737 --- /dev/null +++ b/engine/app/views/coplan/plans/_outline_tree_controls.html.erb @@ -0,0 +1,8 @@ + diff --git a/engine/app/views/coplan/plans/show.html.erb b/engine/app/views/coplan/plans/show.html.erb index 9daf4ec3..aca337ba 100644 --- a/engine/app/views/coplan/plans/show.html.erb +++ b/engine/app/views/coplan/plans/show.html.erb @@ -63,14 +63,16 @@ <% update_details = (@section_updates || {}).transform_values { |version| section_update_details(version) } %>
-
+