Skip to content

Commit 190c4b8

Browse files
HamptonMakesclaude
andauthored
Markdown slideshows phase 1: plan-type behavior, deck split, per-slide rendering (#189)
* Slideshow foundation: plan-type behavior, deck split, per-slide rendering 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 <!-- notes: --> 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 <noreply@anthropic.com> * Deck ids and section links now match document mode 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 <noreply@anthropic.com> * 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 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1 parent 4f40912 commit 190c4b8

23 files changed

Lines changed: 1390 additions & 14 deletions

File tree

app/admin/plan_types.rb

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
ActiveAdmin.register CoPlan::PlanType, as: "PlanType" do
2-
permit_params :name, :description, :icon, :template_content
2+
permit_params :name, :description, :icon, :behavior, :template_content
33

44
index do
55
selectable_column
@@ -22,6 +22,9 @@
2222
f.input :icon, as: :select,
2323
collection: CoPlan::PlansHelper::PLAN_TYPE_ICONS.keys,
2424
include_blank: "(default document icon)"
25+
f.input :behavior, as: :select,
26+
collection: CoPlan::PlanType::BEHAVIORS,
27+
include_blank: false
2528
f.input :template_content, as: :text
2629
end
2730
f.actions
@@ -32,6 +35,7 @@
3235
row :id
3336
row :name
3437
row :icon
38+
row :behavior
3539
row :description
3640
row :default_tags
3741
row :template_content
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
# This migration comes from co_plan (originally 20260820000000)
2+
class AddBehaviorToCoplanPlanTypes < ActiveRecord::Migration[8.1]
3+
# Behavior is a column rather than a name match: type names are
4+
# host-editable data, and renaming "Slideshow" must not strip a deck of
5+
# its deck rendering.
6+
def change
7+
add_column :coplan_plan_types, :behavior, :string, limit: 20, null: false, default: "document"
8+
end
9+
end

db/schema.rb

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

db/seeds/development.rb

Lines changed: 59 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,8 @@ module DevelopmentSeed
2424
{ name: "Product Brief", icon: "lightbulb", description: "Product context, goals, and measures of success", default_tags: [ "product" ] },
2525
{ name: "Runbook", icon: "wrench", description: "Operational diagnosis and recovery steps", default_tags: [ "operations" ] },
2626
{ name: "Research Note", icon: "flask", description: "Findings, evidence, and open questions", default_tags: [ "research" ] },
27-
{ name: "Roadmap", icon: "map", description: "Sequenced outcomes and milestones", default_tags: [ "roadmap" ] }
27+
{ name: "Roadmap", icon: "map", description: "Sequenced outcomes and milestones", default_tags: [ "roadmap" ] },
28+
{ name: "Presentation", icon: "presentation", behavior: "presentation", description: "A markdown slide deck — `---` starts a new slide", default_tags: [] }
2829
].freeze
2930

3031
DOCUMENTS = [
@@ -88,6 +89,10 @@ module DevelopmentSeed
8889
{
8990
key: "collab-showcase", author: "priya", type: "Design Doc", title: "Search latency: cutting p95 with a two-tier cache",
9091
tags: %w[search performance caching], visibility: "published", folder: "Engineering/Active projects", fixture: :collab_showcase
92+
},
93+
{
94+
key: "launch-deck", author: "priya", type: "Presentation", title: "Shared workspaces launch — readout deck",
95+
tags: %w[collaboration launch], visibility: "published", folder: "Product/Launches/Shared workspace", fixture: :slideshow_deck
9196
}
9297
].freeze
9398

@@ -295,6 +300,58 @@ def fetch(key, &compute)
295300
[^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.
296301
[^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.
297302
MARKDOWN
303+
# Showcases the slideshow conventions end-to-end: `---` slide breaks,
304+
# speaker-note comments, a visible `***` rule (not a break), checkboxes
305+
# on a later slide (absolute line numbers), and footnote/link-reference
306+
# definitions that live on a different slide than their references.
307+
slideshow_deck: <<~'MARKDOWN',
308+
Q3 launch readout, presented at the product review. A `---` on its own line starts a new slide.
309+
310+
<!-- notes: Open with the one-number summary — adoption doubled. -->
311+
312+
---
313+
314+
## What shipped
315+
316+
- Shared workspaces on web, iOS, and Android
317+
- Folder-level permissions with inherited defaults
318+
- Real-time presence in every document[^presence]
319+
320+
***
321+
322+
Rules like the one above stay visible — only `---` starts a new slide.
323+
324+
---
325+
326+
## Rollout checklist
327+
328+
- [x] Beta cohort (12 teams)
329+
- [x] Pricing page update
330+
- [ ] Follow-up survey to beta admins
331+
332+
<!-- notes: The survey ships Friday; results feed the next readout. -->
333+
334+
---
335+
336+
## How it went
337+
338+
```text
339+
week 1 ████████ 41%
340+
week 2 ██████████████ 72%
341+
week 4 ████████████████ 89%
342+
```
343+
344+
Weekly active teams, per the [launch dashboard][dash].
345+
346+
---
347+
348+
## Ask
349+
350+
Approve headcount for the sync-conflicts workstream.
351+
352+
[dash]: https://observability.example.com/d/workspace-adoption
353+
[^presence]: Presence reuses the comment-notification channel, so it ships with no new infrastructure.
354+
MARKDOWN
298355
spanish: "## Problema\n\nLas personas nuevas necesitan saber qué paso completar.\n\n## Resultado\n\nUna lista breve muestra el siguiente paso.",
299356
japanese: "## 目標\n\n障害の影響を小さくし、復旧までの時間を短縮します。\n\n## 次のステップ\n\n復旧手順を自動で検証します。",
300357
arabic: "## الملخص\n\nتقارن هذه المذكرة بين الجلسات قصيرة العمر وتدوير الرموز.\n\n## الخطوة التالية\n\nتشغيل تجربة محكومة لقياس الأمان."
@@ -604,7 +661,7 @@ def document_content(definition)
604661
parts = [ "# #{definition.fetch(:title)}" ]
605662

606663
# Fixtures that are complete document bodies — no lorem filler around them.
607-
if %i[spanish japanese arabic code_walkthrough collab_showcase].include?(definition[:fixture])
664+
if %i[spanish japanese arabic code_walkthrough collab_showcase slideshow_deck].include?(definition[:fixture])
608665
parts << fixture
609666
return parts.join("\n\n")
610667
end
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
/* Deck rendering for presentation-behavior plans.
2+
3+
Provisional review-view styling only: the real slide design system —
4+
layout classifier, type scale steps, themes — arrives with SLIDE_SPEC.md
5+
and replaces most of this file. Two rules are load-bearing already:
6+
everything deck-scoped lives under .deck / .deck-* (so the design system
7+
can be extracted as a standalone stylesheet later), and slide chrome is
8+
structure around the content, never text inside it (comment anchors
9+
count visible-text occurrences). */
10+
11+
.deck {
12+
display: flex;
13+
flex-direction: column;
14+
gap: 28px;
15+
}
16+
17+
.deck-slide {
18+
position: relative;
19+
display: flex;
20+
flex-direction: column;
21+
justify-content: center;
22+
aspect-ratio: 16 / 9;
23+
padding: 40px 56px;
24+
background: var(--color-surface);
25+
border: 1px solid var(--color-border);
26+
border-radius: 12px;
27+
box-shadow: 0 1px 2px rgb(0 0 0 / 0.04);
28+
/* Content that doesn't fit scrolls rather than clips — the visible cue
29+
the fit report will later formalize. */
30+
overflow-y: auto;
31+
}
32+
33+
.deck-slide::after {
34+
content: attr(data-slide);
35+
position: absolute;
36+
right: 16px;
37+
bottom: 10px;
38+
font-size: 12px;
39+
color: var(--color-text-muted);
40+
}
41+
42+
/* A slide reads from across the room, not at document scale. Flat bumps
43+
until the classifier assigns real type-scale steps per layout. */
44+
.deck-slide .markdown-rendered h1 {
45+
font-size: 2.2em;
46+
line-height: 1.15;
47+
border: none;
48+
}
49+
50+
.deck-slide .markdown-rendered h2 {
51+
font-size: 1.6em;
52+
line-height: 1.2;
53+
border: none;
54+
}
55+
56+
.deck-slide .markdown-rendered p,
57+
.deck-slide .markdown-rendered li {
58+
font-size: 1.12em;
59+
line-height: 1.5;
60+
}

engine/app/controllers/coplan/application_controller.rb

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ def self.controller_path
1010
helper CoPlan::ApplicationHelper
1111
helper CoPlan::PlansHelper
1212
helper CoPlan::MarkdownHelper
13+
helper CoPlan::SlideshowsHelper
1314
helper CoPlan::CommentsHelper
1415
helper CoPlan::ReferencesHelper
1516
helper CoPlan::PlanEventsHelper

engine/app/helpers/coplan/markdown_helper.rb

Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,12 @@ module MarkdownHelper
4646
# one markdown fragment (e.g. each comment) — commonmarker numbers
4747
# footnote ids from #fn-1 per document, so unprefixed fragments collide
4848
# and reference/backref links jump to the wrong footnote.
49-
def render_markdown(content, interactive: true, footnote_prefix: nil, footnotes: :inline)
49+
#
50+
# line_offset: pass the fragment's 0-based starting line in the full
51+
# document when rendering a slice of a larger plan (slideshow slides).
52+
# Checkbox toggles write to source lines by number, so their data-line
53+
# must stay document-absolute even when the render sees only a fragment.
54+
def render_markdown(content, interactive: true, footnote_prefix: nil, footnotes: :inline, line_offset: 0)
5055
render_options = { unsafe: true }
5156
# Sourcepos is only needed to wire checkboxes to their source lines;
5257
# make_checkboxes_interactive strips it from the final output.
@@ -55,7 +60,7 @@ def render_markdown(content, interactive: true, footnote_prefix: nil, footnotes:
5560
with_chips = transform_mention_anchors(html)
5661
with_references = transform_reference_anchors(with_chips, numbered_sections: footnote_prefix.nil?)
5762
sanitized = sanitize(with_references, tags: ALLOWED_TAGS, attributes: ALLOWED_ATTRIBUTES)
58-
result = interactive ? make_checkboxes_interactive(sanitized, content) : sanitized
63+
result = interactive ? make_checkboxes_interactive(sanitized, content, line_offset: line_offset) : sanitized
5964
result = scope_footnote_ids(result, footnote_prefix) if footnote_prefix
6065
result = select_footnotes(result, footnotes)
6166
return result.html_safe if footnotes == :only
@@ -188,8 +193,9 @@ def select_footnotes(html, mode)
188193
# sourcepos metadata, so the parser that decides what renders as a
189194
# checkbox is also the authority on which line it came from. A checkbox
190195
# only becomes interactive when its own source line matches
191-
# TASK_LINE_PATTERN.
192-
def make_checkboxes_interactive(html, content)
196+
# TASK_LINE_PATTERN. Sourcepos lines are fragment-relative; line_offset
197+
# shifts the emitted data-line back to document coordinates.
198+
def make_checkboxes_interactive(html, content, line_offset: 0)
193199
doc = Nokogiri::HTML::DocumentFragment.parse(html)
194200
source_lines = content.to_s.each_line.map(&:rstrip)
195201

@@ -204,7 +210,7 @@ def make_checkboxes_interactive(html, content)
204210
cb.remove_attribute("disabled")
205211
cb["data-action"] = "coplan--checkbox#toggle"
206212
cb["data-line-text"] = line_text
207-
cb["data-line"] = line_number.to_s
213+
cb["data-line"] = (line_number + line_offset).to_s
208214

209215
li.add_class("task-list-item")
210216

engine/app/helpers/coplan/plans_helper.rb

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -61,7 +61,8 @@ def hidden_state_flag(label, title)
6161
"map" => %(<path d="M14.106 5.553a2 2 0 0 0 1.788 0l3.659-1.83A1 1 0 0 1 21 4.619v12.764a1 1 0 0 1-.553.894l-4.553 2.277a2 2 0 0 1-1.788 0l-4.212-2.106a2 2 0 0 0-1.788 0l-3.659 1.83A1 1 0 0 1 3 19.381V6.618a1 1 0 0 1 .553-.894l4.553-2.277a2 2 0 0 1 1.788 0z"/><path d="M15 5.764v15"/><path d="M9 3.236v15"/>),
6262
"flask" => %(<path d="M10 2v7.527a2 2 0 0 1-.211.896L4.72 20.55a1 1 0 0 0 .9 1.45h12.76a1 1 0 0 0 .9-1.45l-5.069-10.127A2 2 0 0 1 14 9.527V2"/><path d="M8.5 2h7"/><path d="M7 16h10"/>),
6363
"shield" => %(<path d="M20 13c0 5-3.5 7.5-7.66 8.95a1 1 0 0 1-.67-.01C7.5 20.5 4 18 4 13V6a1 1 0 0 1 1-1c2 0 4.5-1.2 6.24-2.72a1.17 1.17 0 0 1 1.52 0C14.51 3.81 17 5 19 5a1 1 0 0 1 1 1z"/>),
64-
"wrench" => %(<path d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z"/>)
64+
"wrench" => %(<path d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z"/>),
65+
"presentation" => %(<path d="M2 3h20"/><path d="M21 3v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V3"/><path d="m7 21 5-5 5 5"/>)
6566
}.freeze
6667

6768
# How many tint classes exist in CSS (.plan-type-icon--0 … --N-1).

0 commit comments

Comments
 (0)