diff --git a/app/admin/plan_types.rb b/app/admin/plan_types.rb
index 2f4d8474..2c79a2a0 100644
--- a/app/admin/plan_types.rb
+++ b/app/admin/plan_types.rb
@@ -1,5 +1,5 @@
ActiveAdmin.register CoPlan::PlanType, as: "PlanType" do
- permit_params :name, :description, :icon, :template_content
+ permit_params :name, :description, :icon, :behavior, :template_content
index do
selectable_column
@@ -22,6 +22,9 @@
f.input :icon, as: :select,
collection: CoPlan::PlansHelper::PLAN_TYPE_ICONS.keys,
include_blank: "(default document icon)"
+ f.input :behavior, as: :select,
+ collection: CoPlan::PlanType::BEHAVIORS,
+ include_blank: false
f.input :template_content, as: :text
end
f.actions
@@ -32,6 +35,7 @@
row :id
row :name
row :icon
+ row :behavior
row :description
row :default_tags
row :template_content
diff --git a/db/migrate/20260820000001_add_behavior_to_coplan_plan_types.co_plan.rb b/db/migrate/20260820000001_add_behavior_to_coplan_plan_types.co_plan.rb
new file mode 100644
index 00000000..f43ffbb5
--- /dev/null
+++ b/db/migrate/20260820000001_add_behavior_to_coplan_plan_types.co_plan.rb
@@ -0,0 +1,9 @@
+# This migration comes from co_plan (originally 20260820000000)
+class AddBehaviorToCoplanPlanTypes < ActiveRecord::Migration[8.1]
+ # Behavior is a column rather than a name match: type names are
+ # host-editable data, and renaming "Slideshow" must not strip a deck of
+ # its deck rendering.
+ def change
+ add_column :coplan_plan_types, :behavior, :string, limit: 20, null: false, default: "document"
+ end
+end
diff --git a/db/schema.rb b/db/schema.rb
index 897163a2..c88391df 100644
--- a/db/schema.rb
+++ b/db/schema.rb
@@ -285,6 +285,7 @@
end
create_table "coplan_plan_types", id: { type: :string, limit: 36 }, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
+ t.string "behavior", limit: 20, default: "document", null: false
t.datetime "created_at", null: false
t.json "default_tags"
t.text "description"
diff --git a/db/seeds/development.rb b/db/seeds/development.rb
index 9901dff9..7f21ed16 100644
--- a/db/seeds/development.rb
+++ b/db/seeds/development.rb
@@ -24,7 +24,8 @@ module DevelopmentSeed
{ name: "Product Brief", icon: "lightbulb", description: "Product context, goals, and measures of success", default_tags: [ "product" ] },
{ name: "Runbook", icon: "wrench", description: "Operational diagnosis and recovery steps", default_tags: [ "operations" ] },
{ name: "Research Note", icon: "flask", description: "Findings, evidence, and open questions", default_tags: [ "research" ] },
- { name: "Roadmap", icon: "map", description: "Sequenced outcomes and milestones", default_tags: [ "roadmap" ] }
+ { name: "Roadmap", icon: "map", description: "Sequenced outcomes and milestones", default_tags: [ "roadmap" ] },
+ { name: "Presentation", icon: "presentation", behavior: "presentation", description: "A markdown slide deck — `---` starts a new slide", default_tags: [] }
].freeze
DOCUMENTS = [
@@ -88,6 +89,10 @@ module DevelopmentSeed
{
key: "collab-showcase", author: "priya", type: "Design Doc", title: "Search latency: cutting p95 with a two-tier cache",
tags: %w[search performance caching], visibility: "published", folder: "Engineering/Active projects", fixture: :collab_showcase
+ },
+ {
+ key: "launch-deck", author: "priya", type: "Presentation", title: "Shared workspaces launch — readout deck",
+ tags: %w[collaboration launch], visibility: "published", folder: "Product/Launches/Shared workspace", fixture: :slideshow_deck
}
].freeze
@@ -295,6 +300,58 @@ def fetch(key, &compute)
[^p95-baseline]: [Q2 search latency review](https://observability.example.com/d/search-latency) — trailing 30 days: p95 840 ms, p50 118 ms, with fan-out retries accounting for 62% of tail samples.
[^redis-eviction]: [Redis key eviction](https://redis.io/docs/latest/develop/reference/eviction/) — `allkeys-lru` approximates LRU across the whole keyspace, which fits a cache-only tier.
MARKDOWN
+ # Showcases the slideshow conventions end-to-end: `---` slide breaks,
+ # speaker-note comments, a visible `***` rule (not a break), checkboxes
+ # on a later slide (absolute line numbers), and footnote/link-reference
+ # definitions that live on a different slide than their references.
+ slideshow_deck: <<~'MARKDOWN',
+ Q3 launch readout, presented at the product review. A `---` on its own line starts a new slide.
+
+
+
+ ---
+
+ ## What shipped
+
+ - Shared workspaces on web, iOS, and Android
+ - Folder-level permissions with inherited defaults
+ - Real-time presence in every document[^presence]
+
+ ***
+
+ Rules like the one above stay visible — only `---` starts a new slide.
+
+ ---
+
+ ## Rollout checklist
+
+ - [x] Beta cohort (12 teams)
+ - [x] Pricing page update
+ - [ ] Follow-up survey to beta admins
+
+
+
+ ---
+
+ ## How it went
+
+ ```text
+ week 1 ████████ 41%
+ week 2 ██████████████ 72%
+ week 4 ████████████████ 89%
+ ```
+
+ Weekly active teams, per the [launch dashboard][dash].
+
+ ---
+
+ ## Ask
+
+ Approve headcount for the sync-conflicts workstream.
+
+ [dash]: https://observability.example.com/d/workspace-adoption
+ [^presence]: Presence reuses the comment-notification channel, so it ships with no new infrastructure.
+ MARKDOWN
spanish: "## Problema\n\nLas personas nuevas necesitan saber qué paso completar.\n\n## Resultado\n\nUna lista breve muestra el siguiente paso.",
japanese: "## 目標\n\n障害の影響を小さくし、復旧までの時間を短縮します。\n\n## 次のステップ\n\n復旧手順を自動で検証します。",
arabic: "## الملخص\n\nتقارن هذه المذكرة بين الجلسات قصيرة العمر وتدوير الرموز.\n\n## الخطوة التالية\n\nتشغيل تجربة محكومة لقياس الأمان."
@@ -604,7 +661,7 @@ def document_content(definition)
parts = [ "# #{definition.fetch(:title)}" ]
# Fixtures that are complete document bodies — no lorem filler around them.
- if %i[spanish japanese arabic code_walkthrough collab_showcase].include?(definition[:fixture])
+ if %i[spanish japanese arabic code_walkthrough collab_showcase slideshow_deck].include?(definition[:fixture])
parts << fixture
return parts.join("\n\n")
end
diff --git a/engine/app/assets/stylesheets/coplan/deck.css b/engine/app/assets/stylesheets/coplan/deck.css
new file mode 100644
index 00000000..06f3c51e
--- /dev/null
+++ b/engine/app/assets/stylesheets/coplan/deck.css
@@ -0,0 +1,60 @@
+/* Deck rendering for presentation-behavior plans.
+
+ Provisional review-view styling only: the real slide design system —
+ layout classifier, type scale steps, themes — arrives with SLIDE_SPEC.md
+ and replaces most of this file. Two rules are load-bearing already:
+ everything deck-scoped lives under .deck / .deck-* (so the design system
+ can be extracted as a standalone stylesheet later), and slide chrome is
+ structure around the content, never text inside it (comment anchors
+ count visible-text occurrences). */
+
+.deck {
+ display: flex;
+ flex-direction: column;
+ gap: 28px;
+}
+
+.deck-slide {
+ position: relative;
+ display: flex;
+ flex-direction: column;
+ justify-content: center;
+ aspect-ratio: 16 / 9;
+ padding: 40px 56px;
+ background: var(--color-surface);
+ border: 1px solid var(--color-border);
+ border-radius: 12px;
+ box-shadow: 0 1px 2px rgb(0 0 0 / 0.04);
+ /* Content that doesn't fit scrolls rather than clips — the visible cue
+ the fit report will later formalize. */
+ overflow-y: auto;
+}
+
+.deck-slide::after {
+ content: attr(data-slide);
+ position: absolute;
+ right: 16px;
+ bottom: 10px;
+ font-size: 12px;
+ color: var(--color-text-muted);
+}
+
+/* A slide reads from across the room, not at document scale. Flat bumps
+ until the classifier assigns real type-scale steps per layout. */
+.deck-slide .markdown-rendered h1 {
+ font-size: 2.2em;
+ line-height: 1.15;
+ border: none;
+}
+
+.deck-slide .markdown-rendered h2 {
+ font-size: 1.6em;
+ line-height: 1.2;
+ border: none;
+}
+
+.deck-slide .markdown-rendered p,
+.deck-slide .markdown-rendered li {
+ font-size: 1.12em;
+ line-height: 1.5;
+}
diff --git a/engine/app/controllers/coplan/application_controller.rb b/engine/app/controllers/coplan/application_controller.rb
index eb040950..911cd6c7 100644
--- a/engine/app/controllers/coplan/application_controller.rb
+++ b/engine/app/controllers/coplan/application_controller.rb
@@ -10,6 +10,7 @@ def self.controller_path
helper CoPlan::ApplicationHelper
helper CoPlan::PlansHelper
helper CoPlan::MarkdownHelper
+ helper CoPlan::SlideshowsHelper
helper CoPlan::CommentsHelper
helper CoPlan::ReferencesHelper
helper CoPlan::PlanEventsHelper
diff --git a/engine/app/helpers/coplan/markdown_helper.rb b/engine/app/helpers/coplan/markdown_helper.rb
index 8c9c98f1..62379539 100644
--- a/engine/app/helpers/coplan/markdown_helper.rb
+++ b/engine/app/helpers/coplan/markdown_helper.rb
@@ -46,7 +46,12 @@ module MarkdownHelper
# one markdown fragment (e.g. each comment) — commonmarker numbers
# footnote ids from #fn-1 per document, so unprefixed fragments collide
# and reference/backref links jump to the wrong footnote.
- def render_markdown(content, interactive: true, footnote_prefix: nil, footnotes: :inline)
+ #
+ # line_offset: pass the fragment's 0-based starting line in the full
+ # document when rendering a slice of a larger plan (slideshow slides).
+ # Checkbox toggles write to source lines by number, so their data-line
+ # must stay document-absolute even when the render sees only a fragment.
+ def render_markdown(content, interactive: true, footnote_prefix: nil, footnotes: :inline, line_offset: 0)
render_options = { unsafe: true }
# Sourcepos is only needed to wire checkboxes to their source lines;
# make_checkboxes_interactive strips it from the final output.
@@ -55,7 +60,7 @@ def render_markdown(content, interactive: true, footnote_prefix: nil, footnotes:
with_chips = transform_mention_anchors(html)
with_references = transform_reference_anchors(with_chips, numbered_sections: footnote_prefix.nil?)
sanitized = sanitize(with_references, tags: ALLOWED_TAGS, attributes: ALLOWED_ATTRIBUTES)
- result = interactive ? make_checkboxes_interactive(sanitized, content) : sanitized
+ result = interactive ? make_checkboxes_interactive(sanitized, content, line_offset: line_offset) : sanitized
result = scope_footnote_ids(result, footnote_prefix) if footnote_prefix
result = select_footnotes(result, footnotes)
return result.html_safe if footnotes == :only
@@ -188,8 +193,9 @@ def select_footnotes(html, mode)
# sourcepos metadata, so the parser that decides what renders as a
# checkbox is also the authority on which line it came from. A checkbox
# only becomes interactive when its own source line matches
- # TASK_LINE_PATTERN.
- def make_checkboxes_interactive(html, content)
+ # TASK_LINE_PATTERN. Sourcepos lines are fragment-relative; line_offset
+ # shifts the emitted data-line back to document coordinates.
+ def make_checkboxes_interactive(html, content, line_offset: 0)
doc = Nokogiri::HTML::DocumentFragment.parse(html)
source_lines = content.to_s.each_line.map(&:rstrip)
@@ -204,7 +210,7 @@ def make_checkboxes_interactive(html, content)
cb.remove_attribute("disabled")
cb["data-action"] = "coplan--checkbox#toggle"
cb["data-line-text"] = line_text
- cb["data-line"] = line_number.to_s
+ cb["data-line"] = (line_number + line_offset).to_s
li.add_class("task-list-item")
diff --git a/engine/app/helpers/coplan/plans_helper.rb b/engine/app/helpers/coplan/plans_helper.rb
index 5a43459d..a7cf2944 100644
--- a/engine/app/helpers/coplan/plans_helper.rb
+++ b/engine/app/helpers/coplan/plans_helper.rb
@@ -61,7 +61,8 @@ def hidden_state_flag(label, title)
"map" => %(),
"flask" => %(),
"shield" => %(),
- "wrench" => %()
+ "wrench" => %(),
+ "presentation" => %()
}.freeze
# How many tint classes exist in CSS (.plan-type-icon--0 … --N-1).
diff --git a/engine/app/helpers/coplan/slideshows_helper.rb b/engine/app/helpers/coplan/slideshows_helper.rb
new file mode 100644
index 00000000..c885ee84
--- /dev/null
+++ b/engine/app/helpers/coplan/slideshows_helper.rb
@@ -0,0 +1,262 @@
+module CoPlan
+ # Deck rendering. Sits beside MarkdownHelper rather than inside it on
+ # purpose: everything deck-specific lives in the deck namespace (this
+ # helper, the Slideshows::* services, the deck-* classes) with no hooks
+ # into document rendering, so the layout engine can be extracted as a
+ # standalone spec + stylesheet later.
+ module SlideshowsHelper
+ include MarkdownHelper
+
+ # Renders a slideshow plan's markdown as a stack of slide sections.
+ # Each slide renders through the same pipeline documents use — same
+ # sanitization, mentions, interactive checkboxes — so review features
+ # keep working. The deck adds structure *around* the content, never
+ # visible text inside it: comment anchors count visible-text
+ # occurrences, and slide wrappers must not change the count.
+ #
+ # Footnote sections are excluded per slide (shared definitions are added
+ # to every fragment so references still resolve); footnotes render once,
+ # document-wide, in the plan's References back matter, exactly as they
+ # do for documents.
+ def render_slideshow(content, interactive: true)
+ result = Slideshows::Split.call(content)
+
+ sections = result.slides.map do |slide|
+ preamble = deck_preamble(result.definition_blocks, slide)
+ inner = render_markdown(preamble + slide.source, interactive:, footnotes: :exclude,
+ line_offset: slide.start_line - 1 - preamble.count("\n"))
+ tag.section(inner, class: "deck-slide", data: { slide: slide.index })
+ end
+
+ # Slides render in isolation, so anything numbered per document —
+ # footnote marks, heading-anchor ids, section-N heading ids — restarts
+ # on every slide. The document-mode render is the ground truth readers
+ # and agents link against; these passes rewrite the deck to match it.
+ deck = Nokogiri::HTML::DocumentFragment.parse(safe_join(sections))
+ document = Nokogiri::HTML::DocumentFragment.parse(render_markdown(content, interactive: false))
+ renumber_deck_footnotes(deck, document)
+ align_heading_ids(deck, document)
+ mirror_section_link_enhancement(deck, document)
+ drop_misleading_ids(deck, document)
+ strip_duplicate_ids(deck)
+
+ tag.div(deck.to_html.html_safe, class: "deck")
+ end
+
+ private
+
+ # Definitions are prepended, not appended: a slide ending in an unclosed
+ # code fence would swallow an appended block into visible text, and
+ # prepending lets the document's first definition of a duplicated key
+ # win on every slide, as it does in document mode. Footnote keys the
+ # slide defines itself are skipped — commonmarker reacts to a duplicated
+ # footnote definition by swallowing the whole fragment into the
+ # footnotes section (which per-slide rendering then excludes). Link
+ # definitions are inert as duplicates and always prepend. The comment
+ # sentinel closes a trailing footnote definition so it can't absorb
+ # indented slide content as a continuation; sanitize strips it from the
+ # output.
+ def deck_preamble(blocks, slide)
+ slide_range = slide.start_line..slide.end_line
+ local_footnotes = blocks.select { |b| b.kind == :footnote && slide_range.cover?(b.start_line) }.map(&:key).to_set
+
+ seen = Set.new
+ chosen = blocks.select { |b| !local_footnotes.include?(b.key) && seen.add?(b.key) }
+ return "" if chosen.empty?
+
+ "#{chosen.map(&:text).join("\n\n")}\n\n\n\n"
+ end
+
+ # The deck must show the same footnote numbers as the plan's References
+ # back matter, which renders once over the whole document: mark N is the
+ # Nth item in the document-wide footnotes section (which also counts
+ # footnotes referenced only inside other definitions). Anchors merely
+ # dressed up as footnote refs (author HTML carrying data-footnote-ref)
+ # point at no known definition and pass through untouched, exactly as
+ # they do in document mode.
+ def renumber_deck_footnotes(deck, document)
+ ordinals = {}
+ # Direct
children only — the visible back-matter numbering is the
+ # list position, and a decoy li[id="fn-…"] smuggled inside a definition
+ # body must not claim an ordinal slot.
+ document.css(%(section[data-footnotes] > ol > li[id^="fn-"])).each do |li|
+ ordinals[li["id"]] ||= ordinals.size + 1
+ end
+
+ occurrences = Hash.new(0)
+ deck.css("a[data-footnote-ref]").each do |anchor|
+ # Genuine commonmarker refs carry an #fnref-… id and a fragment
+ # href; author HTML that merely wears data-footnote-ref keeps its
+ # own text and id, exactly as it does in document mode. An element
+ # claiming to be a heading anchor and a footnote ref at once is
+ # author HTML too — comrak never emits both — and counting it here
+ # would shift every later real reference off its back-matter
+ # backref.
+ next if anchor.classes.include?("anchor")
+ next unless anchor["href"].to_s.start_with?("#") && anchor["id"].to_s.start_with?("fnref-")
+
+ name = anchor["href"].delete_prefix("#")
+ next unless ordinals.key?(name)
+
+ anchor.content = ordinals[name].to_s
+ # Reproduce document-mode reference ids (fnref-a, fnref-a-2, ...) so
+ # every ↩ backref in the back matter lands on a slide.
+ occurrences[name] += 1
+ suffix = occurrences[name] == 1 ? "" : "-#{occurrences[name]}"
+ anchor["id"] = "fnref-#{name.delete_prefix("fn-")}#{suffix}"
+ end
+ end
+
+ # Heading ids come from two generators, and both dedupe repeats against
+ # a single render: comrak names anchor ids intro, intro-1, … and
+ # numbered headings get section-N ids suffixed by unique_dom_id. Slide
+ # sources are exact line slices of the document cut only at top-level
+ # nodes, so the deck's body anchors and headings normally appear in the
+ # same order as the document render's — each deck element takes its
+ # document-mode attributes positionally, which is what keeps links
+ # written against document mode ([jump](#section-1-2)) alive in the
+ # deck.
+ #
+ # Positional copying is only safe when the sequences really are the
+ # same elements: author raw HTML left open across a slide break parses
+ # differently per slide than in the document (force-closed at the
+ # boundary vs foster-parented or swallowed with full context), which
+ # can reorder the document sequence. So every pair must also agree on
+ # a content fingerprint — a swap of same-content elements is the only
+ # kind the check lets through, and copying between elements with
+ # identical content is harmless. On any drift, per-slide ids are left
+ # alone; strip_duplicate_ids still guarantees an unambiguous fragment
+ # target. Footnote-ref anchors are renumber_deck_footnotes' territory
+ # and are excluded on both sides.
+ def align_heading_ids(deck, document)
+ anchor_fingerprint = ->(el) { content_fingerprint(el.parent) }
+ heading_fingerprint = ->(el) { content_fingerprint(el) }
+
+ [
+ [ "a.anchor", %w[id href], anchor_fingerprint ],
+ [ "h1, h2, h3, h4, h5, h6", %w[id], heading_fingerprint ]
+ ].each do |selector, attributes, fingerprint|
+ document_elements = outside_footnotes(document, selector)
+ deck_elements = outside_footnotes(deck, selector)
+ next unless document_elements.size == deck_elements.size
+
+ pairs = deck_elements.zip(document_elements)
+ next unless pairs.all? { |deck_el, doc_el| fingerprint.call(deck_el) == fingerprint.call(doc_el) }
+
+ pairs.each do |deck_element, document_element|
+ attributes.each do |attribute|
+ if document_element[attribute]
+ deck_element[attribute] = document_element[attribute]
+ else
+ deck_element.remove_attribute(attribute)
+ end
+ end
+ end
+ end
+ end
+
+ # Section-preview affordances must match document mode exactly, and an
+ # isolated slide render gets them wrong in both directions: it can't
+ # see that a #section-… link's target heading lives on another slide
+ # (so it misses the enhancement), and it can't see that its own heading
+ # loses the section-N id to an earlier claimant elsewhere in the
+ # document (so it keeps a stale one). The document render already made
+ # every judgment; mirror it — enhance deck links whose target it
+ # enhanced, strip the affordance from links it left plain. Author HTML
+ # that hand-writes the enhancement attributes can still drift from
+ # document mode here; the link navigates either way, only the preview
+ # affordance differs.
+ def mirror_section_link_enhancement(deck, document)
+ section_targets = document.css("a.reference-anchor--section")
+ .map { |anchor| anchor["href"].to_s.delete_prefix("#") }.to_set
+
+ deck.css(%(a[href^="#"])).each do |anchor|
+ next if anchor["data-footnote-ref"] || anchor["data-footnote-backref"]
+
+ if section_targets.include?(anchor["href"].delete_prefix("#"))
+ enhance_reference_anchor(anchor, type: "section") unless anchor["aria-haspopup"] == "dialog"
+ elsif anchor.classes.include?("reference-anchor--section")
+ strip_section_enhancement(anchor)
+ end
+ end
+ end
+
+ def strip_section_enhancement(anchor)
+ anchor.remove_class("reference-anchor--section")
+ anchor.remove_class("reference-anchor")
+ anchor.remove_attribute("class") if anchor["class"].to_s.empty?
+ anchor.remove_attribute("aria-haspopup")
+ anchor.remove_attribute("aria-expanded")
+ actions = anchor["data-action"].to_s.sub(MarkdownHelper::REFERENCE_PREVIEW_ACTIONS, "").strip
+ actions.empty? ? anchor.remove_attribute("data-action") : anchor["data-action"] = actions
+ end
+
+ # What a reader sees or gets at an element: its visible text plus the
+ # descendant attributes text is blind to (image sources, link
+ # destinations, checkbox states). Heading-anchor hrefs are the one
+ # exclusion — they carry the per-render dedup suffix (#intro vs
+ # #intro-1), so including them would keep legitimate duplicate headings
+ # from ever comparing equal across renders. Footnote-ref hrefs stay in:
+ # a genuine ref's #fn-… href is identical in both renders, and an
+ # author link merely wearing the attribute is exactly the kind of
+ # difference this fingerprint exists to catch.
+ def content_fingerprint(el)
+ return [] if el.nil?
+
+ links = el.css("a").reject { |a| a.classes.include?("anchor") }
+ [ el.name, el.text.squish,
+ el.css("img").map { |img| [ img["src"], img["alt"] ] },
+ links.map { |a| a["href"] },
+ el.css("input").map { |input| input["checked"] } ]
+ end
+
+ # Author HTML spanning a slide break can force an alignment skip, or
+ # per-slide numbering that happens to coincide with a document id owned
+ # by other content. Whatever ids survive to here, a reader following a
+ # fragment link must land on what document mode shows: any deck id
+ # whose document-mode owner reads differently is dropped rather than
+ # left pointing at the wrong thing. Ids the document doesn't have at
+ # all are kept — no document-mode link can be betrayed by them.
+ def drop_misleading_ids(deck, document)
+ owners = {}
+ document.css("[id]").each { |el| owners[el["id"]] ||= id_owner_fingerprint(el) }
+
+ deck.css("[id]").each do |el|
+ expected = owners[el["id"]]
+ el.remove_attribute("id") if expected && expected != id_owner_fingerprint(el)
+ end
+ end
+
+ # Heading anchors are empty elements — their identity is the heading
+ # they mark — so they validate by their parent's content instead of
+ # their own.
+ def id_owner_fingerprint(el)
+ if el.name == "a" && el.classes.include?("anchor")
+ content_fingerprint(el.parent)
+ else
+ content_fingerprint(el)
+ end
+ end
+
+ # Ids still duplicated after alignment (author-written repeats, or
+ # generated ids left per-slide by an alignment drift) resolve to their
+ # first occurrence in a browser; strip the later copies so every
+ # fragment target is unambiguous.
+ def strip_duplicate_ids(deck)
+ seen_ids = Set.new
+ deck.css("[id]").each do |el|
+ el.remove_attribute("id") unless seen_ids.add?(el["id"])
+ end
+ end
+
+ # The document render's elements outside its footnotes section — the
+ # part of the document that deck slides actually show (per-slide
+ # rendering excludes footnote sections; their content renders in the
+ # plan's References back matter instead).
+ def outside_footnotes(fragment, selector)
+ fragment.css(selector).reject do |el|
+ el["data-footnote-ref"] || el.ancestors("section").any? { |section| section["data-footnotes"] }
+ end
+ end
+ end
+end
diff --git a/engine/app/models/coplan/plan.rb b/engine/app/models/coplan/plan.rb
index 520dae93..0659db5d 100644
--- a/engine/app/models/coplan/plan.rb
+++ b/engine/app/models/coplan/plan.rb
@@ -229,6 +229,13 @@ def current_content
current_plan_version&.content_markdown
end
+ # Deck-ness is the plan type's behavior, not the plan's own state —
+ # retyping a plan (already supported via the API) is what converts a
+ # document into a deck and back.
+ def presentation?
+ plan_type&.presentation? || false
+ end
+
# Memoized stripped-markdown + position map for the current content.
# Reused by multiple CommentThread#anchor_occurrence_index calls within
# the same request to avoid re-parsing the full plan for each thread.
diff --git a/engine/app/models/coplan/plan_type.rb b/engine/app/models/coplan/plan_type.rb
index b0da3117..bcf2c488 100644
--- a/engine/app/models/coplan/plan_type.rb
+++ b/engine/app/models/coplan/plan_type.rb
@@ -2,6 +2,12 @@ module CoPlan
class PlanType < ApplicationRecord
GENERAL_NAME = "General"
+ # How plans of this type render and behave. "document" is the classic
+ # prose reading view; "presentation" renders the same markdown as a
+ # slide deck (slides split on `---`). Behavior lives on the type row —
+ # not on the type's name — so hosts can rename types freely.
+ BEHAVIORS = %w[document presentation].freeze
+
# Every plan must have a type, so a type with plans can't be deleted —
# nullify would mint invalid (and, at the DB level, unstorable) plans.
# Reassign the plans first, then delete.
@@ -14,6 +20,11 @@ class PlanType < ApplicationRecord
# name lookups are case-insensitive (see find_by_name), so two types
# differing only by case would be indistinguishable through the API.
validates :name, presence: true, uniqueness: { case_sensitive: false }
+ validates :behavior, presence: true, inclusion: { in: BEHAVIORS }
+
+ def presentation?
+ behavior == "presentation"
+ end
# Case-insensitive, adapter-independent name lookup. MySQL's default
# collations compare case-insensitively but PostgreSQL's don't, so a
@@ -35,7 +46,7 @@ def self.general
end
def self.ransackable_attributes(auth_object = nil)
- %w[id name description icon template_content created_at updated_at]
+ %w[id name description icon behavior template_content created_at updated_at]
end
def self.ransackable_associations(auth_object = nil)
diff --git a/engine/app/services/coplan/plan_types/install_defaults.rb b/engine/app/services/coplan/plan_types/install_defaults.rb
index 31aaacd2..6d4d79cd 100644
--- a/engine/app/services/coplan/plan_types/install_defaults.rb
+++ b/engine/app/services/coplan/plan_types/install_defaults.rb
@@ -61,6 +61,9 @@ def parse(path)
name: name,
description: meta["description"].to_s.strip.presence,
icon: meta["icon"].to_s.strip.presence,
+ # Explicit "document" rather than nil: create! with an explicitly
+ # nil attribute bypasses the column default and violates NOT NULL.
+ behavior: meta["behavior"].to_s.strip.presence || "document",
default_tags: Array(meta["default_tags"]).map(&:to_s),
template_content: match[:body].strip.presence
}
diff --git a/engine/app/services/coplan/slideshows/split.rb b/engine/app/services/coplan/slideshows/split.rb
new file mode 100644
index 00000000..498a44b5
--- /dev/null
+++ b/engine/app/services/coplan/slideshows/split.rb
@@ -0,0 +1,246 @@
+module CoPlan
+ module Slideshows
+ # Splits a plan's markdown into slides. Slides are a rendering convention
+ # over the document — nothing here is persisted — so this must agree with
+ # what the reader sees: boundaries come from the Commonmarker AST, never
+ # from regexes over raw lines, so a `---` inside a code fence or under a
+ # setext heading never splits.
+ #
+ # The contract (documented for authors in agent-instructions):
+ # - a top-level thematic break written with dashes (`---`) starts a new
+ # slide; `***`/`___` breaks stay visible rules inside a slide
+ # - HTML comments starting with the word "notes" are speaker notes
+ # - slides with no content (leading `---`, consecutive `---`) are dropped
+ #
+ # Returns Result:
+ # slides — [Slide(index:, start_line:, end_line:, source:, notes:)]
+ # line numbers are 1-based into the original content,
+ # so per-slide renders can keep checkbox source lines
+ # document-absolute (render_markdown line_offset:)
+ # definition_blocks — [DefinitionBlock] for every footnote and
+ # link-reference definition, in document order.
+ # Definitions are consumed at parse time, so a slide
+ # rendered in isolation can't see definitions that
+ # live on another slide; the renderer prepends the
+ # blocks a slide needs (SlideshowsHelper#deck_preamble)
+ # shared_definitions — the blocks joined as one markdown string, keeping
+ # only the document's first definition of each key
+ # (matching CommonMark, where the first definition
+ # wins and later ones are ignored)
+ class Split
+ Slide = Struct.new(:index, :start_line, :end_line, :source, :notes, keyword_init: true)
+ DefinitionBlock = Struct.new(:start_line, :end_line, :text, :kind, :key, keyword_init: true)
+ Result = Struct.new(:slides, :shared_definitions, :definition_blocks, keyword_init: true)
+
+ # Only contiguous dashes split slides — the one convention every
+ # markdown deck tool shares. Spaced forms (`- - -`) and other break
+ # characters render as rules within the slide.
+ DASH_BREAK = /\A {0,3}-{3,}[ \t]*\z/
+
+ # `` or ``, possibly multi-line.
+ # The keyword must be followed by a colon, whitespace, or the comment's
+ # end — `` is somebody's comment, not a speaker
+ # note with the body "que aside".
+ NOTES_COMMENT = /\A))(?.*?)\s*-->\s*\z/m
+
+ # Loose gate for a line that could open a link-reference definition.
+ # Deliberately permissive (CommonMark allows `[a]:/url` with no space
+ # and escaped brackets in labels) — the parser round-trip in
+ # pure_definitions? is the real validator.
+ LINK_DEFINITION_OPENER = /\A {0,3}\[/
+
+ def self.call(content)
+ new(content).call
+ end
+
+ def initialize(content)
+ # \r is stripped to match the plan write paths (Plans::Create,
+ # Plans::ReplaceContent), but raw API text can arrive unnormalized —
+ # and a "---\r" break line must still split.
+ @content = content.to_s.encode("UTF-8").delete("\r")
+ end
+
+ def call
+ return Result.new(slides: [], shared_definitions: "", definition_blocks: []) if @content.strip.empty?
+
+ doc = Commonmarker.parse(@content, options: { extension: MarkdownHelper::EXTENSION_OPTIONS })
+ lines = @content.split("\n", -1)
+
+ break_lines = []
+ footnote_ranges = []
+ comment_ranges = []
+ paragraph_starts = []
+ claimed = Array.new(lines.length + 1, false)
+
+ doc.each do |node|
+ pos = node.source_position
+ range = (pos[:start_line]..pos[:end_line])
+ range.each { |line| claimed[line] = true if line <= lines.length }
+
+ case node.type
+ when :thematic_break
+ break_lines << pos[:start_line] if lines[pos[:start_line] - 1]&.match?(DASH_BREAK)
+ when :footnote_definition
+ footnote_ranges << range
+ when :html_block
+ comment_ranges << range
+ when :paragraph
+ paragraph_starts << pos[:start_line]
+ end
+ end
+
+ blocks = definition_blocks(lines, footnote_ranges, claimed, paragraph_starts)
+
+ Result.new(
+ slides: build_slides(lines, break_lines, comment_ranges),
+ shared_definitions: first_definitions(blocks).map(&:text).join("\n\n"),
+ definition_blocks: blocks
+ )
+ end
+
+ private
+
+ # The document's first definition of each key, in document order —
+ # matching how CommonMark resolves duplicated keys.
+ def first_definitions(blocks)
+ seen = Set.new
+ blocks.select { |block| seen.add?(block.key) }
+ end
+
+ def build_slides(lines, break_lines, comment_ranges)
+ boundaries = [ 0, *break_lines, lines.length + 1 ]
+ slides = boundaries.each_cons(2).map do |after, before|
+ start_line = after + 1
+ end_line = before - 1
+
+ # Trim blank edge lines so slide sources are clean fragments; the
+ # line numbers move with the trim, keeping start_line valid as a
+ # render offset into the original document.
+ start_line += 1 while start_line <= end_line && lines[start_line - 1].strip.empty?
+ end_line -= 1 while end_line >= start_line && lines[end_line - 1].strip.empty?
+ next if end_line < start_line
+
+ source = lines[(start_line - 1)..(end_line - 1)].join("\n")
+ Slide.new(start_line:, end_line:, source:, notes: notes_for(lines, comment_ranges, start_line..end_line))
+ end.compact
+
+ slides.each_with_index { |slide, i| slide.index = i + 1 }
+ slides
+ end
+
+ # Speaker notes are block-level HTML comments (their own lines) whose
+ # text starts with "notes". Inline comments inside a paragraph are not
+ # scanned — notes are per-slide stage direction, not annotations.
+ def notes_for(lines, comment_ranges, slide_range)
+ comment_ranges.filter_map do |range|
+ next unless slide_range.cover?(range.first) && slide_range.cover?(range.last)
+
+ raw = lines[(range.first - 1)..(range.last - 1)].join("\n")
+ match = NOTES_COMMENT.match(raw)
+ match && match[:body].strip
+ end.reject(&:empty?)
+ end
+
+ # Footnote definitions are AST nodes with source positions. Link
+ # reference definitions are consumed by the parser: standalone ones
+ # appear as runs of lines no top-level node claims, and one written
+ # directly above its paragraph is stripped but its line stays inside
+ # the paragraph's source span — so paragraph leading lines are checked
+ # too.
+ def definition_blocks(lines, footnote_ranges, claimed, paragraph_starts)
+ blocks = footnote_ranges.map do |range|
+ text = lines[(range.first - 1)..(range.last - 1)].join("\n").rstrip
+ DefinitionBlock.new(start_line: range.first, end_line: range.last, text: text,
+ kind: :footnote, key: definition_key(:footnote, text))
+ end
+ blocks.concat(link_definition_blocks(lines, claimed, paragraph_starts))
+ blocks.sort_by(&:start_line)
+ end
+
+ def link_definition_blocks(lines, claimed, paragraph_starts)
+ blocks = []
+
+ # Standalone definitions: runs of unclaimed, non-blank lines. Each
+ # block is the longest slice from the current position the parser
+ # consumes whole — segments can't simply be cut at `[`-opening lines
+ # because a definition title can span lines and its continuation can
+ # itself open with `[` (cutting there would fabricate a phantom
+ # definition out of the title and lose the real one).
+ unclaimed_runs(lines, claimed).each do |first, last|
+ position = first
+ while position <= last
+ fit = last.downto(position).find { |stop| pure_definitions?(lines[(position - 1)..(stop - 1)].join("\n")) }
+ unless fit
+ position += 1
+ next
+ end
+
+ text = lines[(position - 1)..(fit - 1)].join("\n")
+ blocks << DefinitionBlock.new(start_line: position, end_line: fit, text: text,
+ kind: :link, key: definition_key(:link, text))
+ position = fit + 1
+ end
+ end
+
+ # Definitions glued to the top of a paragraph (no blank line between
+ # definition and text). Only single-line definitions are recognized
+ # here; a two-line definition glued to text stays with its home slide.
+ paragraph_starts.each do |start|
+ line_number = start
+ while lines[line_number - 1]&.match?(LINK_DEFINITION_OPENER) && pure_definitions?(lines[line_number - 1])
+ text = lines[line_number - 1]
+ blocks << DefinitionBlock.new(start_line: line_number, end_line: line_number, text: text,
+ kind: :link, key: definition_key(:link, text))
+ line_number += 1
+ end
+ end
+
+ blocks
+ end
+
+ def unclaimed_runs(lines, claimed)
+ runs = []
+ run_start = nil
+ (1..lines.length + 1).each do |line_number|
+ line = lines[line_number - 1]
+ if line && !claimed[line_number] && !line.strip.empty?
+ run_start ||= line_number
+ elsif run_start
+ runs << [ run_start, line_number - 1 ]
+ run_start = nil
+ end
+ end
+ runs
+ end
+
+ # A candidate block is gathered only if the parser consumes it entirely
+ # when parsed on its own — that is what keeps `[looks]: like-a-definition`
+ # prose (which CommonMark rejects, e.g. unquoted trailing words) from
+ # being hoisted onto every slide as visible text.
+ #
+ # Footnote-shaped lines are refused outright: an UNREFERENCED footnote
+ # definition is pruned by the parser both in the document (leaving its
+ # lines unclaimed) and here (parsing to an empty AST), so without this
+ # guard it would masquerade as a link block — and prepending it back
+ # onto its own slide duplicates the definition, which commonmarker
+ # punishes by swallowing the whole fragment into the footnotes section.
+ def pure_definitions?(text)
+ return false unless text.lstrip.start_with?("[")
+ return false if text.each_line.any? { |line| line.lstrip.start_with?("[^") }
+
+ Commonmarker.parse(text, options: { extension: MarkdownHelper::EXTENSION_OPTIONS }).first_child.nil?
+ end
+
+ # Keys namespace footnotes apart from link references and normalize the
+ # label the way CommonMark matches them: collapsed whitespace and
+ # Unicode case folding — plain downcase would give `[^straße]` and
+ # `[^STRASSE]` different keys while the parser treats them as the same
+ # footnote.
+ def definition_key(kind, text)
+ label = text[/\A {0,3}\[\^?([^\]]+)\]:/, 1].to_s.squish.downcase(:fold)
+ label = text if label.empty?
+ "#{kind}:#{label}"
+ end
+ end
+ end
+end
diff --git a/engine/app/views/coplan/plans/_content_body.html.erb b/engine/app/views/coplan/plans/_content_body.html.erb
index 9fc70614..758b4d0b 100644
--- a/engine/app/views/coplan/plans/_content_body.html.erb
+++ b/engine/app/views/coplan/plans/_content_body.html.erb
@@ -7,6 +7,12 @@
content-mutation broadcast. skip_digest avoids the template-digest cost
on write paths; the explicit RENDER_CACHE_VERSION key handles renderer
changes instead. -%>
-<% cache ["coplan/plan-content-body", CoPlan::MarkdownHelper::RENDER_CACHE_VERSION, plan.id, plan.current_plan_version&.content_sha256 || plan.current_revision], skip_digest: true do %>
- <%= render_markdown(plan.current_content, footnotes: :exclude) %>
+<%# Behavior is part of the key: retyping a plan (document ↔ presentation)
+ changes the rendering without touching the content SHA. -%>
+<% cache ["coplan/plan-content-body", CoPlan::MarkdownHelper::RENDER_CACHE_VERSION, plan.id, plan.plan_type&.behavior, plan.current_plan_version&.content_sha256 || plan.current_revision], skip_digest: true do %>
+ <% if plan.presentation? %>
+ <%= render_slideshow(plan.current_content) %>
+ <% else %>
+ <%= render_markdown(plan.current_content, footnotes: :exclude) %>
+ <% end %>
<% end %>
diff --git a/engine/app/views/layouts/coplan/application.html.erb b/engine/app/views/layouts/coplan/application.html.erb
index d669b62f..490b7598 100644
--- a/engine/app/views/layouts/coplan/application.html.erb
+++ b/engine/app/views/layouts/coplan/application.html.erb
@@ -20,7 +20,7 @@
<%# Serves the Hack code font, highlight.js grammars, and Mermaid %>
- <%= stylesheet_link_tag "coplan/application", "data-turbo-track": "reload" %>
+ <%= stylesheet_link_tag "coplan/application", "coplan/deck", "data-turbo-track": "reload" %>
<%= javascript_importmap_tags %>
diff --git a/engine/db/default_plan_types/presentation.md b/engine/db/default_plan_types/presentation.md
new file mode 100644
index 00000000..fddd002d
--- /dev/null
+++ b/engine/db/default_plan_types/presentation.md
@@ -0,0 +1,35 @@
+---
+name: Presentation
+icon: presentation
+behavior: presentation
+description: >-
+ A slide deck written as plain markdown — every `---` starts a new slide,
+ layout is inferred from the content, and the deck is reviewed, versioned,
+ and presented from the same plan. One idea per slide.
+---
+
+
+# Deck title
+
+A one-line subtitle: what this deck asks its audience to decide or take away.
+
+---
+
+## The headline of your first point
+
+- Say the point in the heading, not "Background"
+- Keep bullets short; the deck view is for review, the presenter is for the room
+
+
+
+---
+
+## What we're asking for
+
+End on the decision or next step you want from the audience.
diff --git a/engine/db/migrate/20260820000000_add_behavior_to_coplan_plan_types.rb b/engine/db/migrate/20260820000000_add_behavior_to_coplan_plan_types.rb
new file mode 100644
index 00000000..40fce18e
--- /dev/null
+++ b/engine/db/migrate/20260820000000_add_behavior_to_coplan_plan_types.rb
@@ -0,0 +1,8 @@
+class AddBehaviorToCoplanPlanTypes < ActiveRecord::Migration[8.1]
+ # Behavior is a column rather than a name match: type names are
+ # host-editable data, and renaming "Presentation" must not strip a deck
+ # of its deck rendering.
+ def change
+ add_column :coplan_plan_types, :behavior, :string, limit: 20, null: false, default: "document"
+ end
+end
diff --git a/spec/helpers/markdown_helper_spec.rb b/spec/helpers/markdown_helper_spec.rb
index 7a2cab9a..abeb2ab0 100644
--- a/spec/helpers/markdown_helper_spec.rb
+++ b/spec/helpers/markdown_helper_spec.rb
@@ -195,6 +195,23 @@
end
end
+ describe "line_offset" do
+ it "shifts checkbox data-line back to document coordinates" do
+ # The fragment is lines 6-8 of some larger document.
+ html = helper.render_markdown("intro\n\n- [ ] task", line_offset: 5)
+ doc = Nokogiri::HTML::DocumentFragment.parse(html)
+
+ cb = doc.at_css('input[type="checkbox"]')
+ expect(cb["data-line"]).to eq("8")
+ expect(cb["data-line-text"]).to eq("- [ ] task")
+ end
+
+ it "defaults to unshifted lines" do
+ html = helper.render_markdown("- [ ] task")
+ expect(html).to include('data-line="1"')
+ end
+ end
+
describe "@-mention rendering" do
it "renders [@username](mention:username) as a styled chip" do
html = helper.render_markdown("Hey [@hampton](mention:hampton), please look")
diff --git a/spec/helpers/slideshows_helper_spec.rb b/spec/helpers/slideshows_helper_spec.rb
new file mode 100644
index 00000000..aca754ec
--- /dev/null
+++ b/spec/helpers/slideshows_helper_spec.rb
@@ -0,0 +1,341 @@
+require "rails_helper"
+
+RSpec.describe CoPlan::SlideshowsHelper, type: :helper do
+ def deck(content, **options)
+ Nokogiri::HTML::DocumentFragment.parse(helper.render_slideshow(content, **options))
+ end
+
+ describe "#render_slideshow" do
+ it "wraps each slide in a deck section with its 1-based index" do
+ doc = deck("# One\n\n---\n\n# Two")
+
+ sections = doc.css(".deck > section.deck-slide")
+ expect(sections.map { |s| s["data-slide"] }).to eq(%w[1 2])
+ expect(sections.first.css("h1").text.strip).to eq("One")
+ expect(sections.last.css("h1").text.strip).to eq("Two")
+ end
+
+ it "renders slide content through the standard markdown pipeline" do
+ doc = deck("# Slide\n\n**bold** and ")
+
+ expect(doc.css(".deck-slide .markdown-rendered")).to be_present
+ expect(doc.css("strong").text).to eq("bold")
+ expect(doc.css("script")).to be_empty
+ end
+
+ it "keeps checkbox source lines document-absolute on later slides" do
+ content = "# One\n\n- [ ] first task\n\n---\n\n# Two\n\n- [ ] second task"
+ doc = deck(content)
+
+ lines = doc.css('input[type="checkbox"]').map { |cb| cb["data-line"] }
+ expect(lines).to eq(%w[3 9])
+ end
+
+ it "renders non-interactive checkboxes when interactive: false" do
+ doc = deck("- [ ] task", interactive: false)
+
+ cb = doc.at_css('input[type="checkbox"]')
+ expect(cb["disabled"]).to be_present
+ expect(cb["data-line"]).to be_nil
+ end
+
+ it "resolves link-reference definitions across slide boundaries" do
+ content = "See [the docs][docs]\n\n---\n\nslide two\n\n[docs]: https://example.com"
+ doc = deck(content)
+
+ link = doc.at_css('.deck-slide[data-slide="1"] a[href="https://example.com"]')
+ expect(link.text).to eq("the docs")
+ end
+
+ it "resolves footnote references on other slides than their definition" do
+ content = "Claim[^src]\n\n---\n\nmore\n\n[^src]: the source"
+ doc = deck(content)
+
+ ref = doc.at_css('.deck-slide[data-slide="1"] a[data-footnote-ref]')
+ expect(ref).to be_present
+ expect(ref["href"]).to eq("#fn-src")
+ end
+
+ it "excludes footnote definition sections from every slide" do
+ content = "Claim[^src]\n\n[^src]: lives in back matter\n\n---\n\nAnother[^src]"
+ doc = deck(content)
+
+ expect(doc.css("section[data-footnotes]")).to be_empty
+ expect(doc.text).not_to include("lives in back matter")
+ end
+
+ it "numbers footnote marks document-wide, not per slide" do
+ content = <<~MD
+ First[^a] and second[^b]
+
+ ---
+
+ Third[^c] and first again[^a]
+
+ [^a]: A
+ [^b]: B
+ [^c]: C
+ MD
+ doc = deck(content)
+
+ marks = doc.css("a[data-footnote-ref]").map { |a| [ a["href"], a.text ] }
+ expect(marks).to eq([ [ "#fn-a", "1" ], [ "#fn-b", "2" ], [ "#fn-c", "3" ], [ "#fn-a", "1" ] ])
+ end
+
+ it "renames repeat references to document-mode ids so backrefs resolve" do
+ content = "First[^a]\n\n---\n\nAgain[^a]\n\n[^a]: A"
+ doc = deck(content)
+
+ ids = doc.css("[id]").map { |el| el["id"] }
+ expect(ids).to eq(ids.uniq)
+ # The back matter emits one ↩ backref per reference (#fnref-a,
+ # #fnref-a-2, ...); every one must land on a slide.
+ expect(doc.css("#fnref-a").size).to eq(1)
+ expect(doc.css("#fnref-a-2").size).to eq(1)
+ end
+
+ it "matches back-matter numbering when a footnote is referenced only inside another definition" do
+ content = "Main[^a]\n\n[^a]: see [^hidden]\n[^hidden]: secret\n\n---\n\nNext[^b]\n\n[^b]: b note"
+ doc = deck(content)
+
+ marks = doc.css("a[data-footnote-ref]").map(&:text)
+ # The back matter lists fn-a, fn-hidden, fn-b — so [^b] is 3, not 2.
+ expect(marks).to eq(%w[1 3])
+ end
+
+ it "leaves author-supplied decoy footnote anchors alone" do
+ content = "Decoy 9 here\n\nReal[^a]\n\n[^a]: note\n\n---\n\nSecond[^b]\n\n[^b]: another"
+ doc = deck(content)
+
+ marks = doc.css("a[data-footnote-ref]").map { |a| [ a["href"], a.text ] }
+ expect(marks).to eq([ [ "#fn-zzz", "9" ], [ "#fn-a", "1" ], [ "#fn-b", "2" ] ])
+ end
+
+ it "never leaks shared definitions into visible text via an unclosed fence" do
+ content = "Ref[^a]\n\n[^a]: the definition\n\n---\n\n# Last\n\n```\nunclosed code\n"
+ doc = deck(content)
+
+ expect(doc.text).not_to include("the definition")
+ end
+
+ it "resolves duplicate-key references the way document mode does (first definition wins)" do
+ content = "[docs]: https://first.example\n\nSee [the docs][docs]\n\n---\n\n[docs]: https://second.example\n\nSee [the docs][docs] again"
+ doc = deck(content)
+
+ hrefs = doc.css("a[href*=example]").map { |a| a["href"] }.uniq
+ expect(hrefs).to eq([ "https://first.example" ])
+ end
+
+ it "renders a slide holding an unreferenced footnote definition instead of blanking it" do
+ content = "# One\n\ntext one\n\n---\n\n# Two\n\ntext two\n\n[^wip]: draft note not referenced yet"
+ doc = deck(content)
+
+ expect(doc.css(".deck-slide").last.text).to include("text two")
+ end
+
+ it "renders slides whose footnote labels are unicode fold-equal" do
+ content = "S1[^straße]\n\n[^straße]: eszett\n\n---\n\nS2[^STRASSE]\n\n[^STRASSE]: caps"
+ doc = deck(content)
+
+ text = doc.css(".deck-slide").map(&:text).join
+ expect(text).to include("S1")
+ expect(text).to include("S2")
+ end
+
+ it "numbers marks by the back matter's visible list positions, decoys included" do
+ # A raw
smuggled into a definition body gets hoisted to a direct
+ # child by HTML parsing — browsers show it as a numbered item, so
+ # the real footnote after it is visibly item 3 and the deck must say 3.
+ content = "First[^x] and second[^a]\n\n[^x]:
decoy
\n\n[^a]: real"
+ doc = deck(content)
+
+ expect(doc.css("a[data-footnote-ref]").map(&:text)).to eq(%w[1 3])
+ end
+
+ it "ignores decoy list items that stay nested inside a definition body" do
+ content = "First[^x] and second[^a]\n\n[^x]:
decoy
\n\n[^a]: real"
+ doc = deck(content)
+
+ expect(doc.css("a[data-footnote-ref]").map(&:text)).to eq(%w[1 2])
+ end
+
+ it "leaves decoy anchors targeting a real definition untouched" do
+ content = %(Intro boo decoy.\n\nReal ref[^a]\n\n[^a]: definition)
+ doc = deck(content)
+
+ anchors = doc.css("a[data-footnote-ref]").map { |a| [ a.text, a["id"] ] }
+ expect(anchors).to eq([ [ "boo", nil ], [ "1", "fnref-a" ] ])
+ end
+
+ it "suffixes repeated heading anchors the way document mode does" do
+ content = "# Intro\n\none\n\n---\n\n# Intro\n\ntwo"
+ doc = deck(content)
+
+ anchors = doc.css("a.anchor").map { |a| [ a["id"], a["href"] ] }
+ expect(anchors).to eq([ [ "intro", "#intro" ], [ "intro-1", "#intro-1" ] ])
+ end
+
+ it "suffixes repeated heading anchors across a slide holding its own repeat" do
+ content = "# Intro\n\n# Intro\n\n---\n\n# Intro"
+ doc = deck(content)
+
+ expect(doc.css("a.anchor").map { |a| a["id"] }).to eq(%w[intro intro-1 intro-2])
+ end
+
+ it "gives repeated numbered headings their document-mode section ids" do
+ content = "# 1. Goals\n\ngoals\n\n---\n\n# 1. Goals\n\nrevisited, see [above](#section-1)"
+ doc = deck(content)
+
+ expect(doc.css("h1").map { |h| h["id"] }).to eq(%w[section-1 section-1-2])
+ end
+
+ it "keeps slide heading ids stable when a footnote definition holds a heading" do
+ # The definition's heading renders only in the References back matter,
+ # so it must not shift the ids of headings the slides actually show.
+ content = "Ref[^a]\n\n[^a]: note\n\n # Intro\n\n---\n\n# Intro\n\nslide two"
+ doc = deck(content)
+
+ expect(doc.css("a.anchor").map { |a| a["id"] }).to eq(%w[intro])
+ end
+
+ it "enhances section links whose target heading lives on another slide" do
+ content = "See [goals](#section-2)\n\n---\n\n# 2. Goals\n\nthe goals"
+ doc = deck(content)
+
+ link = doc.at_css('a[href="#section-2"]')
+ expect(link["class"]).to include("reference-anchor--section")
+ expect(doc.at_css("#section-2")).to be_present
+ end
+
+ it "skips id alignment instead of swapping ids when raw HTML reorders content between renders" do
+ # An unclosed
spanning the break foster-parents its heading
+ # differently in document mode than in the isolated slide renders;
+ # misassigning another heading's id would be worse than keeping
+ # per-slide ids.
+ content = "
\n\n# 1. Alpha\n\n
\n\n---\n\n# 2. Xray\n\n
\n\n# 3. Beta"
+ doc = deck(content)
+
+ expect(doc.at_css("#section-1").text).to include("Alpha")
+ expect(doc.at_css("#section-2").text).to include("Xray")
+ end
+
+ it "keeps deck ids unique when an author footnote-section fake spans a slide break" do
+ content = "## Intro\n\n\n\n---\n\n## Intro\n\n"
+ doc = deck(content)
+
+ ids = doc.css("[id]").map { |el| el["id"] }
+ expect(ids).to eq(ids.uniq)
+ end
+
+ it "keeps real refs on their backref ids when an author anchor impersonates a footnote ref" do
+ # comrak never emits class="anchor" together with data-footnote-ref;
+ # the contradiction marks author HTML, which must neither shift real
+ # references off their back-matter backrefs nor get renumbered itself.
+ content = "First[^a] and fake\n\n---\n\nSecond[^a]\n\n[^a]: the definition"
+ doc = deck(content)
+
+ real = doc.css("a[data-footnote-ref]").reject { |a| a.text == "fake" }
+ expect(real.map { |a| a["id"] }).to eq(%w[fnref-a fnref-a-2])
+ expect(doc.css("a[data-footnote-ref]").map(&:text)).to include("fake")
+ ids = doc.css("[id]").map { |el| el["id"] }
+ expect(ids).to eq(ids.uniq)
+ end
+
+ it "strips a per-slide section enhancement document mode does not give" do
+ # Slide 2's isolated render thinks its heading owns #section-1, but in
+ # the document an author element claimed it first — the link is plain
+ # in document mode.
+ content = "
decoy
\n\n---\n\n# 1. Real\n\n[jump](#section-1)"
+ doc = deck(content)
+
+ link = doc.css('a[href="#section-1"]').find { |a| a.text == "jump" }
+ expect(link["class"]).to be_nil
+ expect(link["data-action"]).to be_nil
+ expect(link["aria-haspopup"]).to be_nil
+ end
+
+ it "strips the stale enhancement when a per-slide section id lost to an earlier claimant" do
+ # "Section 1" slugs its comrak anchor to section-1, so the numbered
+ # heading is section-1-2 document-wide and #section-1 is not a section
+ # target there.
+ content = "## Section 1\n\nx\n\n---\n\n## 1. Numbered\n\n[num](#section-1)"
+ doc = deck(content)
+
+ link = doc.css('a[href="#section-1"]').find { |a| a.text == "num" }
+ expect(link["class"]).to be_nil
+ expect(doc.at_css("#section-1-2")).to be_present
+ end
+
+ it "enhances an author-classed cross-slide section link the way document mode does" do
+ content = "# 1. Intro\n\nHello\n\n---\n\nGo jump now"
+ doc = deck(content)
+
+ link = doc.css('a[href="#section-1"]').find { |a| a.text == "jump" }
+ expect(link["aria-haspopup"]).to eq("dialog")
+ expect(link["data-action"]).to include("reference-preview")
+ end
+
+ it "drops a surviving per-slide id whose document-mode owner shows different content" do
+ # An author footnote-section fake spanning the break forces the
+ # alignment skip; slide 2's isolated numbering then mints section-1-2
+ # for Gamma while document mode's section-1-2 is Beta. A link written
+ # against the document must never land on different content.
+ content = "# 1. Alpha\n\n\n\n---\n\n# 1. Beta\n\n# 1. Gamma\n\n"
+ doc = deck(content)
+
+ expect(doc.at_css("#section-1").text).to include("Alpha")
+ expect(doc.at_css("#section-1-2")).to be_nil
+ end
+
+ it "validates heading anchors by the heading they mark" do
+ # Anchors are empty elements, so a stale per-slide anchor id can only
+ # be caught by comparing its parent heading against the document-mode
+ # owner's.
+ content = "# Foo Bar\n\n\n\n---\n\n# Foo-Bar\n\n# Foo Bar\n\n"
+ doc = deck(content)
+
+ # Document mode's #foo-bar-1 marks "Foo-Bar"; the deck's surviving
+ # anchors must not offer that id on a "Foo Bar" heading.
+ expect(doc.at_css("#foo-bar-1")).to be_nil
+ expect(doc.at_css("#foo-bar").parent.text.squish).to eq("Foo Bar")
+ end
+
+ it "does not swap ids between same-text headings whose link destinations differ" do
+ # data-footnote-ref on an author link must not hide its href from the
+ # alignment gate — genuine refs keep identical #fn-… hrefs in both
+ # renders, so only impostors can differ here.
+ content = "
\n\n# 2. Beta"
+ doc = deck(content)
+
+ expect(doc.at_css("#section-1").at_css("img")["src"]).to eq("https://img.example/a.png")
+ end
+
+ it "matches document mode when an author id claims a heading's number under an alignment skip" do
+ content = "# 1. Real\n\n
\n\n# 2. Alpha\n\n
\n\n---\n\n# 3. Xray\n\n
\n\n
decoy
"
+ doc = deck(content)
+
+ expect(doc.at_css("#section-1").text).to eq("decoy")
+ end
+
+ it "keeps checkbox lines document-absolute when definitions are prepended" do
+ content = "Intro[^a]\n\n[^a]: note\n\n---\n\n- [ ] task on line seven"
+ doc = deck(content)
+
+ expect(doc.at_css('input[type="checkbox"]')["data-line"]).to eq("7")
+ end
+
+ it "renders an empty deck for blank content" do
+ doc = deck("")
+
+ expect(doc.at_css(".deck")).to be_present
+ expect(doc.css(".deck-slide")).to be_empty
+ end
+ end
+end
diff --git a/spec/models/plan_spec.rb b/spec/models/plan_spec.rb
index d02040fb..d6aadcac 100644
--- a/spec/models/plan_spec.rb
+++ b/spec/models/plan_spec.rb
@@ -65,6 +65,21 @@
# THE discovery predicate (mirrored by PlanPolicy#listed?). Everything a
# user can be shown in a list routes through one of these two scopes.
+ describe "#presentation?" do
+ it "reflects the plan type's behavior" do
+ deck_type = create(:plan_type, name: "Presentation", behavior: "presentation")
+ expect(create(:plan, plan_type: deck_type).presentation?).to be(true)
+ expect(create(:plan).presentation?).to be(false)
+ end
+
+ it "changes when the plan is retyped" do
+ deck_type = create(:plan_type, name: "Presentation", behavior: "presentation")
+ plan = create(:plan)
+ plan.update!(plan_type: deck_type)
+ expect(plan.presentation?).to be(true)
+ end
+ end
+
describe ".visible_to" do
let(:author) { create(:coplan_user) }
let(:viewer) { create(:coplan_user) }
diff --git a/spec/models/plan_type_spec.rb b/spec/models/plan_type_spec.rb
index 6a29e582..e3e79acf 100644
--- a/spec/models/plan_type_spec.rb
+++ b/spec/models/plan_type_spec.rb
@@ -70,6 +70,23 @@
expect { plan_type.destroy! }.to change(CoPlan::PlanType, :count).by(-1)
end
+ describe "behavior" do
+ it "defaults to document" do
+ expect(create(:plan_type).behavior).to eq("document")
+ end
+
+ it "rejects behaviors outside the known set" do
+ plan_type = build(:plan_type, behavior: "spreadsheet")
+ expect(plan_type).not_to be_valid
+ expect(plan_type.errors[:behavior]).to be_present
+ end
+
+ it "answers presentation? from the behavior column" do
+ expect(build(:plan_type, behavior: "presentation").presentation?).to be(true)
+ expect(build(:plan_type, behavior: "document").presentation?).to be(false)
+ end
+ end
+
describe ".general" do
it "returns the existing General type, matched case-insensitively" do
existing = create(:plan_type, name: "general")
diff --git a/spec/services/plan_types/install_defaults_spec.rb b/spec/services/plan_types/install_defaults_spec.rb
index fcfeb23f..63756e89 100644
--- a/spec/services/plan_types/install_defaults_spec.rb
+++ b/spec/services/plan_types/install_defaults_spec.rb
@@ -14,7 +14,7 @@
expect(result.created).to include(
"Engineering Design", "Exploration", "PRD", "Project 1-Pager",
"Research", "Technical Documentation", "Implementation Plan",
- "Test Plan", "Handoff", "Scratchpad", "General"
+ "Test Plan", "Handoff", "Scratchpad", "General", "Presentation"
)
expect(CoPlan::PlanType.count).to eq(result.created.size)
@@ -28,6 +28,15 @@
expect(CoPlan::PlanType.find_by_name("General").template_content).to be_nil
end
+ it "installs behavior from front matter, defaulting to document" do
+ described_class.call
+
+ presentation = CoPlan::PlanType.find_by_name("Presentation")
+ expect(presentation.behavior).to eq("presentation")
+ expect(presentation.template_content).to include("---")
+ expect(CoPlan::PlanType.find_by_name("Research").behavior).to eq("document")
+ end
+
it "is idempotent" do
described_class.call
result = described_class.call
diff --git a/spec/services/slideshows/split_spec.rb b/spec/services/slideshows/split_spec.rb
new file mode 100644
index 00000000..57eae402
--- /dev/null
+++ b/spec/services/slideshows/split_spec.rb
@@ -0,0 +1,260 @@
+require "rails_helper"
+
+RSpec.describe CoPlan::Slideshows::Split do
+ def split(content)
+ described_class.call(content)
+ end
+
+ describe "slide boundaries" do
+ it "splits on top-level --- thematic breaks" do
+ result = split("# One\n\nfirst\n\n---\n\n# Two\n\nsecond")
+
+ expect(result.slides.map(&:index)).to eq([ 1, 2 ])
+ expect(result.slides.first.source).to include("# One")
+ expect(result.slides.first.source).not_to include("Two")
+ expect(result.slides.second.source).to include("# Two")
+ end
+
+ it "returns the whole document as one slide when there are no breaks" do
+ result = split("# Only\n\nslide")
+
+ expect(result.slides.size).to eq(1)
+ expect(result.slides.first.source).to eq("# Only\n\nslide")
+ end
+
+ it "records 1-based start/end lines into the original document, blank edges trimmed" do
+ result = split("# One\n\nfirst\n\n---\n\n# Two")
+
+ one, two = result.slides
+ expect(one.start_line).to eq(1)
+ expect(one.end_line).to eq(3)
+ expect(two.start_line).to eq(7)
+ expect(two.end_line).to eq(7)
+ end
+
+ it "accepts more than three dashes and up to three leading spaces" do
+ result = split("a\n\n----------\n\nb\n\n ---\n\nc")
+
+ expect(result.slides.map(&:source)).to eq(%w[a b c])
+ end
+
+ it "drops the empty slide from a leading ---" do
+ result = split("---\n\n# Deck starts here")
+
+ expect(result.slides.size).to eq(1)
+ expect(result.slides.first.index).to eq(1)
+ expect(result.slides.first.source).to include("Deck starts here")
+ end
+
+ it "collapses consecutive breaks instead of emitting empty slides" do
+ result = split("a\n\n---\n\n---\n\n---\n\nb")
+
+ expect(result.slides.map(&:source)).to eq(%w[a b])
+ expect(result.slides.map(&:index)).to eq([ 1, 2 ])
+ end
+
+ it "splits CRLF content the same as LF content" do
+ result = split("a\r\n\r\n---\r\n\r\nb")
+
+ expect(result.slides.map(&:source)).to eq(%w[a b])
+ end
+
+ it "returns no slides for blank content" do
+ expect(split("").slides).to be_empty
+ expect(split(nil).slides).to be_empty
+ expect(split(" \n\n ").slides).to be_empty
+ end
+ end
+
+ describe "non-boundaries" do
+ it "does not split on --- inside a code fence" do
+ content = "before\n\n```\n---\n```\n\nafter"
+ result = split(content)
+
+ expect(result.slides.size).to eq(1)
+ expect(result.slides.first.source).to eq(content)
+ end
+
+ it "does not split on *** or ___ thematic breaks" do
+ result = split("a\n\n***\n\nb\n\n___\n\nc")
+
+ expect(result.slides.size).to eq(1)
+ end
+
+ it "does not split on spaced dashes (- - -)" do
+ result = split("a\n\n- - -\n\nb")
+
+ expect(result.slides.size).to eq(1)
+ end
+
+ it "treats --- under text as the setext heading it is, not a break" do
+ result = split("Heading text\n---\n\nbody")
+
+ expect(result.slides.size).to eq(1)
+ expect(result.slides.first.source).to include("Heading text")
+ end
+
+ it "does not split on --- inside a blockquote" do
+ result = split("a\n\n> quote\n> ---\n\nb")
+
+ expect(result.slides.size).to eq(1)
+ end
+
+ it "does not split on a thematic break nested inside a list item" do
+ content = "- item\n\n ---\n\n continuation"
+ result = split(content)
+
+ expect(result.slides.size).to eq(1)
+ expect(result.slides.first.source).to eq(content)
+ end
+
+ it "does not split on a thematic break nested inside an ordered list item" do
+ result = split("1. item\n\n ---\n\n continuation")
+
+ expect(result.slides.size).to eq(1)
+ end
+ end
+
+ describe "speaker notes" do
+ it "extracts a notes comment and assigns it to its slide" do
+ result = split("# One\n\n\n\n---\n\n# Two")
+
+ expect(result.slides.first.notes).to eq([ "say hi" ])
+ expect(result.slides.second.notes).to eq([])
+ end
+
+ it "handles multi-line notes and multiple notes per slide" do
+ content = <<~MD
+ # Slide
+
+
+
+
+ MD
+ result = split(content)
+
+ expect(result.slides.first.notes).to eq([ "first line\nsecond line", "also this" ])
+ end
+
+ it "ignores comments that are not notes" do
+ result = split("# Slide\n\n")
+
+ expect(result.slides.first.notes).to eq([])
+ end
+
+ it "requires notes to be a whole word, not a prefix" do
+ result = split("# Slide\n\n\n\n\n\n")
+
+ expect(result.slides.first.notes).to eq([])
+ end
+
+ it "keeps the notes comment inside the slide source" do
+ result = split("# Slide\n\n")
+
+ expect(result.slides.first.source).to include("")
+ end
+ end
+
+ describe "shared definitions" do
+ it "gathers footnote definitions from anywhere in the document" do
+ content = "First[^a]\n\n---\n\nSecond\n\n[^a]: the definition"
+ result = split(content)
+
+ expect(result.shared_definitions).to include("[^a]: the definition")
+ end
+
+ it "gathers link-reference definitions" do
+ content = "See [the docs][docs]\n\n---\n\nmore\n\n[docs]: https://example.com \"Docs\""
+ result = split(content)
+
+ expect(result.shared_definitions).to include("[docs]: https://example.com")
+ end
+
+ it "does not treat definition lookalikes inside code fences as definitions" do
+ content = "a\n\n```\n[docs]: https://example.com\n```"
+ result = split(content)
+
+ expect(result.shared_definitions).to eq("")
+ end
+
+ it "does not gather lines CommonMark rejects as definitions" do
+ # An unquoted trailing word invalidates the definition, so the whole
+ # line stays visible paragraph text — hoisting it would inject that
+ # text onto every slide.
+ result = split("[docs]: https://example.com extra words\n\n---\n\nslide two")
+
+ expect(result.shared_definitions).to eq("")
+ end
+
+ it "gathers a definition whose destination sits on the next line" do
+ content = "See [q][docs]\n\n---\n\n[docs]:\n https://example.com"
+ result = split(content)
+
+ expect(result.shared_definitions).to include("https://example.com")
+ end
+
+ it "gathers a definition glued to the top of a paragraph" do
+ content = "[docs]: https://example.com\nSee [the docs][docs]\n\n---\n\nAlso [the docs][docs]"
+ result = split(content)
+
+ expect(result.shared_definitions).to eq("[docs]: https://example.com")
+ end
+
+ it "keeps only the document's first definition of a duplicated key" do
+ content = "[docs]: https://first.example\n\na\n\n---\n\n[docs]: https://second.example\n\nb"
+ result = split(content)
+
+ expect(result.shared_definitions).to include("first.example")
+ expect(result.shared_definitions).not_to include("second.example")
+ end
+
+ it "ignores unreferenced footnote definitions instead of misclassifying them as link definitions" do
+ # The parser prunes an unreferenced footnote definition, leaving its
+ # line unclaimed — it must not be gathered (prepending it back onto its
+ # own slide would duplicate the definition and blank the slide).
+ result = split("Tail visible prose\n\n[^wip]: draft note not referenced yet")
+
+ expect(result.definition_blocks).to eq([])
+ end
+
+ it "keeps a definition whose multi-line title contains a bracket-opening line as one block" do
+ result = split("[a]: /u \"open\n[b]: /v\"\n\n---\n\nSee [b] and [a].")
+
+ expect(result.definition_blocks.map(&:key)).to eq([ "link:a" ])
+ end
+
+ it "gathers glued definitions with no space after the colon" do
+ result = split("[a]:/url\nHello\n\n---\n\nSee [a].")
+
+ expect(result.shared_definitions).to eq("[a]:/url")
+ end
+
+ it "case-folds keys so fold-equal footnote labels share one key" do
+ result = split("S1[^straße]\n\n[^straße]: eszett\n\n---\n\nS2[^STRASSE]\n\n[^STRASSE]: caps")
+
+ expect(result.definition_blocks.map(&:key).uniq.size).to eq(1)
+ end
+
+ it "returns positioned definition blocks for per-slide preambles" do
+ content = "Ref[^a]\n\n[^a]: note\n\n---\n\n[docs]: https://example.com\n\nb"
+ result = split(content)
+
+ expect(result.definition_blocks.map { |b| [ b.kind, b.key, b.start_line ] })
+ .to eq([ [ :footnote, "footnote:a", 3 ], [ :link, "link:docs", 7 ] ])
+ end
+
+ it "returns an empty string when there is nothing to share" do
+ expect(split("# Plain deck\n\n---\n\nslide two").shared_definitions).to eq("")
+ end
+
+ it "keeps multi-line footnote definitions intact" do
+ content = "Ref[^long]\n\n[^long]: first line\n continued line"
+ result = split(content)
+
+ expect(result.shared_definitions).to include("continued line")
+ end
+ end
+end