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
  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 "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) + + 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