From 40c45e12833b384bb91cf2ac312a78ef1d39c096 Mon Sep 17 00:00:00 2001 From: Hampton Lintorn-Catlin Date: Thu, 20 Aug 2026 15:51:51 -0500 Subject: [PATCH 1/3] Slideshow foundation: plan-type behavior, deck split, per-slide rendering MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 1 of markdown slideshows (design: CoPlan plan 01a01696-aad6, rev 4). A deck is a rendering convention over the plan's single markdown blob — nothing new is persisted, so versioning, diffs, and comment anchors keep working unchanged. - PlanType gains a behavior column (document/slideshow); Plan#slideshow? delegates to it, so retyping a plan converts it. Ships a Slideshow default type (installable via coplan:plan_types:install_defaults) whose template teaches the conventions, plus a seeded showcase deck. - Slideshows::Split turns markdown into slides at top-level `---` breaks (AST-driven: fences, blockquotes, setext, `***` never split), extracts speaker notes, and gathers footnote/link-reference definitions as positioned blocks so slides can render in isolation. Candidate definitions are validated by a parser round-trip, so prose lookalikes are never hoisted onto other slides. - render_slideshow renders each slide through the standard document pipeline (same sanitization, mentions, checkboxes) inside section.deck-slide wrappers. Definitions are prepended per slide (skipping footnote keys the slide defines — commonmarker swallows fragments containing duplicated footnote definitions), footnote marks are renumbered to match the document-wide References back matter, and checkbox data-line stays document-absolute via render_markdown's new line_offset. Verified with two adversarial review workflows; every confirmed finding is pinned by a regression spec. Co-Authored-By: Claude Fable 5 --- app/admin/plan_types.rb | 6 +- ...d_behavior_to_coplan_plan_types.co_plan.rb | 9 + db/schema.rb | 3 +- db/seeds/development.rb | 61 ++++- engine/app/helpers/coplan/markdown_helper.rb | 16 +- engine/app/helpers/coplan/plans_helper.rb | 3 +- .../app/helpers/coplan/slideshows_helper.rb | 108 ++++++++ engine/app/models/coplan/plan.rb | 7 + engine/app/models/coplan/plan_type.rb | 13 +- .../coplan/plan_types/install_defaults.rb | 3 + .../app/services/coplan/slideshows/split.rb | 246 ++++++++++++++++++ engine/db/default_plan_types/slideshow.md | 35 +++ ...00000_add_behavior_to_coplan_plan_types.rb | 8 + spec/helpers/markdown_helper_spec.rb | 17 ++ spec/helpers/slideshows_helper_spec.rb | 184 +++++++++++++ spec/models/plan_spec.rb | 15 ++ spec/models/plan_type_spec.rb | 17 ++ .../plan_types/install_defaults_spec.rb | 11 +- spec/services/slideshows/split_spec.rb | 246 ++++++++++++++++++ 19 files changed, 996 insertions(+), 12 deletions(-) create mode 100644 db/migrate/20260820000001_add_behavior_to_coplan_plan_types.co_plan.rb create mode 100644 engine/app/helpers/coplan/slideshows_helper.rb create mode 100644 engine/app/services/coplan/slideshows/split.rb create mode 100644 engine/db/default_plan_types/slideshow.md create mode 100644 engine/db/migrate/20260820000000_add_behavior_to_coplan_plan_types.rb create mode 100644 spec/helpers/slideshows_helper_spec.rb create mode 100644 spec/services/slideshows/split_spec.rb 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 847d2021..8c89f6b9 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[8.1].define(version: 2026_08_19_144056) do +ActiveRecord::Schema[8.1].define(version: 2026_08_20_000001) do create_table "active_admin_comments", id: { type: :string, limit: 36 }, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| t.bigint "author_id" t.string "author_type" @@ -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..a3bc7ff9 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: "Slideshow", icon: "presentation", behavior: "slideshow", 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: "Slideshow", 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/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 d9da7ca0..178229ff 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..4c762e14 --- /dev/null +++ b/engine/app/helpers/coplan/slideshows_helper.rb @@ -0,0 +1,108 @@ +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 + + tag.div(renumber_deck_footnotes(safe_join(sections), content), 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. Slides render + # in isolation — numbering restarts at 1 and repeat references reuse + # commonmarker's per-fragment ids — so this pass rewrites both from the + # back matter itself: 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(html, content) + ordinals = {} + back_matter = render_markdown(content, interactive: false, footnotes: :only) + # 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. + Nokogiri::HTML::DocumentFragment.parse(back_matter).css(%(section[data-footnotes] > ol > li[id^="fn-"])).each do |li| + ordinals[li["id"]] ||= ordinals.size + 1 + end + + doc = Nokogiri::HTML::DocumentFragment.parse(html) + occurrences = Hash.new(0) + + doc.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. + 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 + + # Slides render in isolation, so cross-slide id collisions are still + # possible (identical heading anchors); browsers resolve a fragment to + # its first occurrence, so only the first keeps the id. + seen_ids = Set.new + doc.css("[id]").each do |el| + el.remove_attribute("id") unless seen_ids.add?(el["id"]) + end + + doc.to_html.html_safe + end + end +end diff --git a/engine/app/models/coplan/plan.rb b/engine/app/models/coplan/plan.rb index 520dae93..afe87d85 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 slideshow? + plan_type&.slideshow? || 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..7bd03c87 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; "slideshow" 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 slideshow].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 slideshow? + behavior == "slideshow" + 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/db/default_plan_types/slideshow.md b/engine/db/default_plan_types/slideshow.md new file mode 100644 index 00000000..060b6c19 --- /dev/null +++ b/engine/db/default_plan_types/slideshow.md @@ -0,0 +1,35 @@ +--- +name: Slideshow +icon: presentation +behavior: slideshow +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..859a6a7d --- /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 "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/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..f4fe59ca --- /dev/null +++ b/spec/helpers/slideshows_helper_spec.rb @@ -0,0 +1,184 @@ +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
  1. 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]:
    1. decoy
    2. \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 "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..e3f335c3 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 "#slideshow?" do + it "reflects the plan type's behavior" do + deck_type = create(:plan_type, name: "Slideshow", behavior: "slideshow") + expect(create(:plan, plan_type: deck_type).slideshow?).to be(true) + expect(create(:plan).slideshow?).to be(false) + end + + it "changes when the plan is retyped" do + deck_type = create(:plan_type, name: "Slideshow", behavior: "slideshow") + plan = create(:plan) + plan.update!(plan_type: deck_type) + expect(plan.slideshow?).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..86e356d2 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 slideshow? from the behavior column" do + expect(build(:plan_type, behavior: "slideshow").slideshow?).to be(true) + expect(build(:plan_type, behavior: "document").slideshow?).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..7f450f61 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", "Slideshow" ) 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 + + slideshow = CoPlan::PlanType.find_by_name("Slideshow") + expect(slideshow.behavior).to eq("slideshow") + expect(slideshow.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..a7c9bf93 --- /dev/null +++ b/spec/services/slideshows/split_spec.rb @@ -0,0 +1,246 @@ +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 + 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 From 5ae3084dcd1ddcd524b1eb3e4895464346299987 Mon Sep 17 00:00:00 2001 From: Hampton Lintorn-Catlin Date: Thu, 20 Aug 2026 18:00:35 -0500 Subject: [PATCH 2/3] Deck ids and section links now match document mode MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Codex review of #189 flagged that per-slide rendering restarts both id generators — comrak heading anchors (intro, intro-1, …) and numbered section ids (section-1, section-1-2 via unique_dom_id) — so links written against document mode went dead in the deck. The document-mode render the deck already uses for footnote ordinals is now the ground truth for everything numbered per document: - align_heading_ids copies each body anchor's id+href and each heading's id positionally from the document render, gated on equal counts and pairwise content fingerprints (text, image sources, link destinations, checkbox states) so author HTML left open across a slide break — which parses differently per slide than in the document (foster-parenting, swallowed siblings) — can only skip the pass, never misassign an id. - mirror_section_link_enhancement makes section preview affordances match document mode in both directions: cross-slide links gain them, stale per-slide ones (target id lost to an earlier claimant) lose them. - drop_misleading_ids backstops the skips: any deck id whose document-mode owner shows different content is dropped rather than left pointing at the wrong thing (anchors validate by the heading they mark). - renumber_deck_footnotes no longer counts author elements claiming to be a heading anchor and a footnote ref at once, so impostors can't shift real references off their back-matter backrefs. Also pins the other Codex finding as specs: thematic breaks nested in list items never split (doc.each only walks top-level nodes). Verified with three adversarial review workflow rounds (9 agents); all 12 confirmed breaks fixed and spec-pinned, and the final round's 33-document mechanical parity sweep found deck output id-for-id identical to document mode on legitimate content. Co-Authored-By: Claude Fable 5 --- .../app/helpers/coplan/slideshows_helper.rb | 196 ++++++++++++++++-- spec/helpers/slideshows_helper_spec.rb | 157 ++++++++++++++ spec/services/slideshows/split_spec.rb | 14 ++ 3 files changed, 346 insertions(+), 21 deletions(-) diff --git a/engine/app/helpers/coplan/slideshows_helper.rb b/engine/app/helpers/coplan/slideshows_helper.rb index 4c762e14..c885ee84 100644 --- a/engine/app/helpers/coplan/slideshows_helper.rb +++ b/engine/app/helpers/coplan/slideshows_helper.rb @@ -28,7 +28,19 @@ def render_slideshow(content, interactive: true) tag.section(inner, class: "deck-slide", data: { slide: slide.index }) end - tag.div(renumber_deck_footnotes(safe_join(sections), content), class: "deck") + # 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 @@ -56,31 +68,31 @@ def deck_preamble(blocks, slide) end # The deck must show the same footnote numbers as the plan's References - # back matter, which renders once over the whole document. Slides render - # in isolation — numbering restarts at 1 and repeat references reuse - # commonmarker's per-fragment ids — so this pass rewrites both from the - # back matter itself: 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(html, content) + # 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 = {} - back_matter = render_markdown(content, interactive: false, footnotes: :only) # 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. - Nokogiri::HTML::DocumentFragment.parse(back_matter).css(%(section[data-footnotes] > ol > li[id^="fn-"])).each do |li| + document.css(%(section[data-footnotes] > ol > li[id^="fn-"])).each do |li| ordinals[li["id"]] ||= ordinals.size + 1 end - doc = Nokogiri::HTML::DocumentFragment.parse(html) occurrences = Hash.new(0) - - doc.css("a[data-footnote-ref]").each do |anchor| + 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. + # 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("#") @@ -93,16 +105,158 @@ def renumber_deck_footnotes(html, content) 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 - # Slides render in isolation, so cross-slide id collisions are still - # possible (identical heading anchors); browsers resolve a fragment to - # its first occurrence, so only the first keeps the id. + 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 - doc.css("[id]").each do |el| + deck.css("[id]").each do |el| el.remove_attribute("id") unless seen_ids.add?(el["id"]) end + end - doc.to_html.html_safe + # 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/spec/helpers/slideshows_helper_spec.rb b/spec/helpers/slideshows_helper_spec.rb index f4fe59ca..aca754ec 100644 --- a/spec/helpers/slideshows_helper_spec.rb +++ b/spec/helpers/slideshows_helper_spec.rb @@ -167,6 +167,163 @@ def deck(content, **options) 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---\n\n# 2. Xray\n\n
        \n\n# 1. Alpha\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---\n\n# 1. go\n\n
        \n\n# 1. go\n\n
        \n\n# 2. Beta" + doc = deck(content) + + expect(doc.at_css("#section-1").at_css("a[data-footnote-ref]")["href"]).to eq("https://one.example") + end + + it "does not swap ids between same-text headings whose images differ" do + content = "\n\n---\n\n# 1. ![chart B](https://img.example/b.png)\n\n
        \n\n# 1. ![chart A](https://img.example/a.png)\n\n
        \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---\n\n# 3. Xray\n\n
        \n\n# 2. Alpha\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) diff --git a/spec/services/slideshows/split_spec.rb b/spec/services/slideshows/split_spec.rb index a7c9bf93..57eae402 100644 --- a/spec/services/slideshows/split_spec.rb +++ b/spec/services/slideshows/split_spec.rb @@ -99,6 +99,20 @@ def split(content) 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 From 04d96b359a6d89f72ceb4b78011663370c441b57 Mon Sep 17 00:00:00 2001 From: Hampton Lintorn-Catlin Date: Fri, 21 Aug 2026 13:41:28 -0500 Subject: [PATCH 3/3] Rename the deck plan type to Presentation and wire up the deck view The user-facing type is "Presentation" (behavior enum, default template, seeds); the internal mechanism keeps its slideshow naming. Plan show now branches on presentation? with behavior in the fragment-cache key, the slideshows helper is registered on the engine controller, and a provisional deck stylesheet renders slides as 16:9 cards pending the real design system. Co-Authored-By: Claude Fable 5 --- db/seeds/development.rb | 4 +- engine/app/assets/stylesheets/coplan/deck.css | 60 +++++++++++++++++++ .../coplan/application_controller.rb | 1 + engine/app/models/coplan/plan.rb | 4 +- engine/app/models/coplan/plan_type.rb | 12 ++-- .../views/coplan/plans/_content_body.html.erb | 10 +++- .../views/layouts/coplan/application.html.erb | 2 +- .../{slideshow.md => presentation.md} | 6 +- ...00000_add_behavior_to_coplan_plan_types.rb | 4 +- spec/models/plan_spec.rb | 12 ++-- spec/models/plan_type_spec.rb | 6 +- .../plan_types/install_defaults_spec.rb | 8 +-- 12 files changed, 98 insertions(+), 31 deletions(-) create mode 100644 engine/app/assets/stylesheets/coplan/deck.css rename engine/db/default_plan_types/{slideshow.md => presentation.md} (90%) diff --git a/db/seeds/development.rb b/db/seeds/development.rb index a3bc7ff9..7f21ed16 100644 --- a/db/seeds/development.rb +++ b/db/seeds/development.rb @@ -25,7 +25,7 @@ module DevelopmentSeed { 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: "Slideshow", icon: "presentation", behavior: "slideshow", description: "A markdown slide deck — `---` starts a new slide", default_tags: [] } + { name: "Presentation", icon: "presentation", behavior: "presentation", description: "A markdown slide deck — `---` starts a new slide", default_tags: [] } ].freeze DOCUMENTS = [ @@ -91,7 +91,7 @@ module DevelopmentSeed tags: %w[search performance caching], visibility: "published", folder: "Engineering/Active projects", fixture: :collab_showcase }, { - key: "launch-deck", author: "priya", type: "Slideshow", title: "Shared workspaces launch — readout deck", + 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 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/models/coplan/plan.rb b/engine/app/models/coplan/plan.rb index afe87d85..0659db5d 100644 --- a/engine/app/models/coplan/plan.rb +++ b/engine/app/models/coplan/plan.rb @@ -232,8 +232,8 @@ def current_content # 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 slideshow? - plan_type&.slideshow? || false + def presentation? + plan_type&.presentation? || false end # Memoized stripped-markdown + position map for the current content. diff --git a/engine/app/models/coplan/plan_type.rb b/engine/app/models/coplan/plan_type.rb index 7bd03c87..bcf2c488 100644 --- a/engine/app/models/coplan/plan_type.rb +++ b/engine/app/models/coplan/plan_type.rb @@ -3,10 +3,10 @@ class PlanType < ApplicationRecord GENERAL_NAME = "General" # How plans of this type render and behave. "document" is the classic - # prose reading view; "slideshow" 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 slideshow].freeze + # 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. @@ -22,8 +22,8 @@ class PlanType < ApplicationRecord validates :name, presence: true, uniqueness: { case_sensitive: false } validates :behavior, presence: true, inclusion: { in: BEHAVIORS } - def slideshow? - behavior == "slideshow" + def presentation? + behavior == "presentation" end # Case-insensitive, adapter-independent name lookup. MySQL's default 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/slideshow.md b/engine/db/default_plan_types/presentation.md similarity index 90% rename from engine/db/default_plan_types/slideshow.md rename to engine/db/default_plan_types/presentation.md index 060b6c19..fddd002d 100644 --- a/engine/db/default_plan_types/slideshow.md +++ b/engine/db/default_plan_types/presentation.md @@ -1,13 +1,13 @@ --- -name: Slideshow +name: Presentation icon: presentation -behavior: slideshow +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. --- -