Skip to content
Merged
1 change: 1 addition & 0 deletions docs/DEMO_LIBRARY.md
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,7 @@ shipped types, including Scratchpad, PRD, Test Plan, and Handoff.
| reading-specimen | A realistic design doc for judging reading in both modes: prose, headings, lists, tasks, quote, table, footnote, image, SQL/Ruby/diff/YAML code, a long scrolling line, flowchart and sequence diagrams, plus open, agent-answered, resolved, diagram-node, table-cell, and general discussions |
| document-title | One editable title; autosave keeps the URL stable until Done; title boundaries and empty-title behavior |
| image-resizing | Resize and restore image widths, wrap prose from the top, Shift+Enter beside, Enter below |
| callout-gallery | `> [!SUMMARY]`, `[!NOTE]`, `[!TIP]`, `[!IMPORTANT]`, `[!WARNING]`, and `[!CAUTION]` callouts, a custom label, nested list and code, plain and escaped quotes, and both deck themes; comment inside a callout, or edit one in Editor and check Raw |
| math-gallery | KaTeX math: inline, display, and fenced formulas, math in lists and tables, a six-row slide equation, dollar amounts that stay text, and invalid math shown as source; comment on the text around a formula |

Flowcharts currently support source-backed node and edge comments. Other
Expand Down
11 changes: 11 additions & 0 deletions docs/SLIDE_SPEC.md
Original file line number Diff line number Diff line change
Expand Up @@ -307,6 +307,16 @@ pattern: quote
step: 1
```

A callout (`> [!NOTE]` and the other alert types) is not a quote. It stays
in the reading flow, so the slide falls through to `content`.

```conformance
> [!NOTE]
> The default output has to be genuinely beautiful with zero effort.
.
pattern: content
```

### `table` — scaled tabular typography

One table, full width, with an optional short paragraph.
Expand Down Expand Up @@ -567,6 +577,7 @@ computed from **units** — a deterministic estimate of rendered lines:
| heading | 1 |
| paragraph | ⌈plain-text length / 60⌉, minimum 1, plus 1 per hard line break, plus 3 per image, plus its display-math rows |
| list / list item / blockquote | sum of children, minimum 1 |
| callout (`> [!NOTE]` and the other alert types) | 1 for its label, plus the sum of its children |
| code block | ⌈code lines / 1.2⌉, minimum 1 |
| media code block (mermaid) | 4 |
| math fence | 1 + its display-math rows |
Expand Down
13 changes: 13 additions & 0 deletions engine/app/assets/stylesheets/coplan/foundation.css
Original file line number Diff line number Diff line change
Expand Up @@ -143,6 +143,15 @@ body[data-writing-inspection] :is(#plan-toolbar a, .site-nav__edit) { opacity: .
--color-quote-warning-bg-subtle: rgba(255, 213, 79, 0.08);
--color-quote-info-border: rgba(108, 140, 255, 0.6);
--color-quote-info-bg: rgba(108, 140, 255, 0.05);
/* Static document emphasis must not imply interaction or unread attention.
Note and Warning stay neutral; icons and labels distinguish their meaning. */
--color-callout-note: var(--color-text-muted);
--color-callout-tip: var(--color-success);
--color-callout-important: #7c3aed;
--color-callout-warning: var(--color-text-muted);
--color-callout-caution: var(--color-danger);
/* Teal: neither interaction blue nor attention orange. */
--color-callout-summary: #0d9488;
--color-highlight-open-bg: rgba(108, 140, 255, 0.12);
--color-highlight-open-hover-bg: rgba(108, 140, 255, 0.22);
--color-highlight-open-border: rgba(108, 140, 255, 0.6);
Expand Down Expand Up @@ -270,6 +279,8 @@ body[data-writing-inspection] :is(#plan-toolbar a, .site-nav__edit) { opacity: .
--color-quote-warning-bg-subtle: rgba(245, 158, 11, 0.12);
--color-quote-info-border: rgba(96, 165, 250, 0.8);
--color-quote-info-bg: rgba(96, 165, 250, 0.12);
--color-callout-important: #a78bfa;
--color-callout-summary: #2dd4bf;
--color-highlight-open-bg: rgba(96, 165, 250, 0.18);
--color-highlight-open-hover-bg: rgba(96, 165, 250, 0.28);
--color-highlight-open-border: rgba(96, 165, 250, 0.85);
Expand Down Expand Up @@ -363,6 +374,8 @@ body[data-writing-inspection] :is(#plan-toolbar a, .site-nav__edit) { opacity: .
--color-quote-warning-bg-subtle: rgba(245, 158, 11, 0.12);
--color-quote-info-border: rgba(96, 165, 250, 0.8);
--color-quote-info-bg: rgba(96, 165, 250, 0.12);
--color-callout-important: #a78bfa;
--color-callout-summary: #2dd4bf;
--color-highlight-open-bg: rgba(96, 165, 250, 0.18);
--color-highlight-open-hover-bg: rgba(96, 165, 250, 0.28);
--color-highlight-open-border: rgba(96, 165, 250, 0.85);
Expand Down
81 changes: 81 additions & 0 deletions engine/app/assets/stylesheets/coplan/markdown.css
Original file line number Diff line number Diff line change
Expand Up @@ -489,6 +489,87 @@
margin-bottom: var(--space-md);
}

/* ── Callouts: `> [!NOTE]`, `[!TIP]`, `[!IMPORTANT]`, `[!WARNING]`,
`[!CAUTION]`, `[!SUMMARY]` ──────────────────────────────────────────────
The reader renders div.callout; the rich editor keeps a blockquote with
the same look. The label is generated content from data-callout-title,
never document text, so selections and comment anchors skip it. The icon
is a mask in ::after, painted in the type's accent. */
.markdown-rendered :is(.callout, blockquote.document-editor__callout) {
position: relative;
margin: 0 0 var(--space-md);
padding: var(--space-sm) var(--space-md);
border: 1px solid color-mix(in srgb, var(--callout-accent) 30%, transparent);
border-left: 4px solid var(--callout-accent);
border-radius: 6px;
background: color-mix(in srgb, var(--callout-accent) 8%, transparent);
color: var(--callout-ink, var(--color-text));
}

.markdown-rendered :is(.callout--note, .document-editor__callout--note) {
--callout-accent: var(--color-callout-note);
--callout-icon: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Ccircle cx='12' cy='12' r='9'/%3E%3Cpath d='M12 11v5M12 8h.01'/%3E%3C/svg%3E");
}

.markdown-rendered :is(.callout--tip, .document-editor__callout--tip) {
--callout-accent: var(--color-callout-tip);
--callout-icon: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M9 18h6M10 21h4M12 3a6 6 0 0 0-3.5 10.9c.6.5 1 1.2 1 2.1h5c0-.9.4-1.6 1-2.1A6 6 0 0 0 12 3Z'/%3E%3C/svg%3E");
}

.markdown-rendered :is(.callout--important, .document-editor__callout--important) {
--callout-accent: var(--color-callout-important);
--callout-icon: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M20 15a2 2 0 0 1-2 2H8l-4 4V5a2 2 0 0 1 2-2h12a2 2 0 0 1 2 2Z'/%3E%3Cpath d='M12 7v4M12 14h.01'/%3E%3C/svg%3E");
}

.markdown-rendered :is(.callout--warning, .document-editor__callout--warning) {
--callout-accent: var(--color-callout-warning);
--callout-icon: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M10.3 3.9 1.8 18a2 2 0 0 0 1.7 3h17a2 2 0 0 0 1.7-3L13.7 3.9a2 2 0 0 0-3.4 0Z'/%3E%3Cpath d='M12 9v4M12 17h.01'/%3E%3C/svg%3E");
}

.markdown-rendered :is(.callout--caution, .document-editor__callout--caution) {
--callout-accent: var(--color-callout-caution);
--callout-icon: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M7.9 2h8.2L22 7.9v8.2L16.1 22H7.9L2 16.1V7.9Z'/%3E%3Cpath d='M12 8v4M12 16h.01'/%3E%3C/svg%3E");
}

.markdown-rendered :is(.callout--summary, .document-editor__callout--summary) {
--callout-accent: var(--color-callout-summary);
--callout-icon: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M4 6h16M4 12h16M4 18h10'/%3E%3C/svg%3E");
}

.markdown-rendered :is(.callout, blockquote.document-editor__callout)::before {
content: attr(data-callout-title);
display: block;
margin-bottom: 0.25em;
padding-left: 1.5em;
color: color-mix(in srgb, var(--callout-accent) 78%, var(--callout-ink, var(--color-text)));
font-weight: 600;
line-height: 1.5;
user-select: none;
}

.markdown-rendered :is(.callout, blockquote.document-editor__callout)::after {
content: "";
position: absolute;
top: calc(var(--space-sm) + 0.25em);
left: var(--space-md);
width: 1em;
height: 1em;
background: var(--callout-accent);
mask: var(--callout-icon) center / contain no-repeat;
}

/* A deck is a fixed artifact, independent of the reader's app theme. */
.deck .markdown-rendered .callout {
--callout-ink: var(--deck-ink);
--callout-accent: var(--deck-muted);
}

/* The second selector outranks the reading paragraph gap above. */
.markdown-rendered :is(.callout, blockquote.document-editor__callout) > :last-child,
:is(.plan-reader, .document-editor__body) .markdown-rendered:not(.deck-content) :is(.callout, blockquote.document-editor__callout) > p:last-child {
margin-bottom: 0;
}

.markdown-rendered table {
width: 100%;
border-collapse: collapse;
Expand Down
54 changes: 48 additions & 6 deletions engine/app/helpers/coplan/markdown_helper.rb
Original file line number Diff line number Diff line change
Expand Up @@ -21,11 +21,14 @@ module MarkdownHelper

# Commonmarker extensions beyond the gem defaults (tables, tasklist,
# strikethrough, autolink stay on). Footnotes: `[^1]` in text plus a
# `[^1]: definition` block anywhere in the document. Math: `$inline$`,
# `$$display$$`, `` $`inline`$ `` and ```` ```math ```` fences render as
# elements carrying `data-math-style`, typeset in the browser by KaTeX.
# `[^1]: definition` block anywhere in the document. Alerts use a first
# quote line such as `[!NOTE]` (see render_callouts; `[!SUMMARY]` is
# Coplan's own, see CoPlan::Markdown). Math uses `$inline$`,
# `$$display$$`, `` $`inline`$ `` and math fences, typeset by KaTeX.
# comrak's dollar rules keep currency ("$5 and $10") as text.
EXTENSION_OPTIONS = { footnotes: true, math_dollars: true, math_code: true }.freeze
EXTENSION_OPTIONS = { footnotes: true, alerts: true, math_dollars: true, math_code: true }.freeze

CALLOUT_TYPES = %w[note tip important warning caution summary].freeze

# The only elements and values comrak emits for math. Anything else
# carrying the attribute (authored HTML) loses it during rendering.
Expand All @@ -43,7 +46,7 @@ module MarkdownHelper
# version. Bump it whenever the rendering pipeline changes output for the
# same input (new tags, attribute changes, checkbox wiring, etc.), or
# stale HTML will be served from cache.
RENDER_CACHE_VERSION = 29
RENDER_CACHE_VERSION = 32

# Matches `[@username](mention:username)` where the bracket text and link
# target encode the same username. Username allows letters, digits, dots,
Expand Down Expand Up @@ -85,6 +88,7 @@ def render_markdown(content, interactive: true, footnote_prefix: nil, footnotes:
sanitized = sanitize(with_references, scrubber: scrubber)
sanitized = prepare_math(sanitized, source)
sanitized = render_iframe_blocks(sanitized, content.to_s)
sanitized = render_callouts(sanitized)
result = interactive ? make_checkboxes_interactive(sanitized, content, line_offset: line_offset, source_comments: source_comments, retain_sourcepos: retain_sourcepos) : sanitized
unless interactive
fragment = Nokogiri::HTML::DocumentFragment.parse(result)
Expand Down Expand Up @@ -183,7 +187,9 @@ def markdown_to_plain_text(content)
source = content.to_s.encode("UTF-8")
source = ContentRegions::Split.call(source).canonical_source if source.include?("::: {.presentation")
html = CoPlan::Markdown.to_html(source, options: { extension: EXTENSION_OPTIONS }, plugins: { syntax_highlighter: nil })
Nokogiri::HTML::DocumentFragment.parse(html).text.squish
fragment = Nokogiri::HTML::DocumentFragment.parse(html)
fragment.css("p.markdown-alert-title").remove
fragment.text.squish
end

private
Expand Down Expand Up @@ -367,6 +373,42 @@ def task_name_text(node)
node.children.map { |child| task_name_text(child) }.join
end

# Commonmarker renders an alert as a div with a title paragraph. The
# title moves into data-callout-title, which the stylesheet shows beside
# the type's icon, so the label adds no text to the document. Comment
# anchors and occurrence counts then match the source text, where the
# `[!NOTE]` marker is not prose either.
#
# This runs after sanitization because data-callout-title is not an
# attribute authors may write. Raw HTML can copy Commonmarker's alert
# markup, which gives the same callout the marker gives, but it cannot
# set the callout classes directly: those are removed first.
def render_callouts(html)
return html unless html.include?("callout") || html.include?("markdown-alert")

doc = Nokogiri::HTML::DocumentFragment.parse(html)
doc.css("[class*='callout']").each do |node|
classes = node.classes.reject { |name| name.match?(/\Acallout(?:--|\z)/) }
if classes.empty?
node.remove_attribute("class")
else
node["class"] = classes.join(" ")
end
end

doc.css("div.markdown-alert").each do |alert|
type = CALLOUT_TYPES.find { |name| alert.classes.include?("markdown-alert-#{name}") }
title = alert.element_children.first
next unless type && title&.name == "p" && title.classes == [ "markdown-alert-title" ]

title.remove
alert["class"] = "callout callout--#{type}"
alert["data-callout-title"] = title.text.squish
end

doc.to_html
end

# A table is a block of data, not a paragraph. Each one gets a scroll
# frame — so a wide table can't run off the page the way it does bare —
# and the Stimulus controller that pins its header, fades its edges, and
Expand Down
69 changes: 69 additions & 0 deletions engine/app/javascript/coplan/rich_document.js
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,25 @@ nodes = nodes.addBefore("paragraph", "template_note", {
attrs: { source: { default: null }, snapshot: { default: null }, opening: {}, closing: {} },
toDOM: () => ["div", { class: "document-editor__template-note", "data-coplan-template-note": "", "aria-label": "Comment" }, 0]
})
// A callout is a blockquote whose first line is a GitHub alert marker such as
// `[!NOTE]`. The marker line is kept verbatim as an attribute, so the body
// edits as rich text and the marker, including its case and any custom title,
// serializes back unchanged.
const calloutMarker = /^\[!(note|tip|important|warning|caution|summary)\]([^\n]*)(?:\n|$)/i
function calloutLabel(callout, marker) {
return marker.slice(marker.indexOf("]") + 1).trim() || callout[0].toUpperCase() + callout.slice(1)
}
const blockquoteSpec = nodes.get("blockquote")
nodes = nodes.update("blockquote", {
...blockquoteSpec,
attrs: { ...blockquoteSpec.attrs, callout: { default: null }, marker: { default: null } },
parseDOM: [{ tag: "blockquote", getAttrs: dom => {
const match = calloutMarker.exec(dom.dataset.calloutMarker || "")
return match ? { callout: match[1].toLowerCase(), marker: dom.dataset.calloutMarker } : {}
} }],
toDOM: node => node.attrs.callout ? ["blockquote", { class: `document-editor__callout document-editor__callout--${node.attrs.callout}`,
"data-callout-title": calloutLabel(node.attrs.callout, node.attrs.marker), "data-callout-marker": node.attrs.marker }, 0] : blockquoteSpec.toDOM(node)
})
// Math keeps its delimiters in the text: the TeX is raw, editable source and
// serializes verbatim. It may sit inside emphasis or links, never in code.
const schema = new Schema({ nodes, marks: defaultMarkdownParser.schema.spec.marks.addToEnd("strikethrough", {
Expand Down Expand Up @@ -205,6 +224,44 @@ tokenizer.core.ruler.after("block", "coplan_tasks", state => {
inline.content = inline.content.slice(match[0].length)
})
})
tokenizer.core.ruler.after("block", "coplan_callouts", state => {
let quoteDepth = 0
const lines = state.src.split("\n")
for (let index = 0; index < state.tokens.length; index++) {
const token = state.tokens[index]
if (token.type === "blockquote_close") quoteDepth--
if (token.type !== "blockquote_open") continue
quoteDepth++
const paragraph = state.tokens[index + 1]
// Commonmarker only recognizes a marker on the opening quote line.
Comment thread
HamptonMakes marked this conversation as resolved.
Resolved
if (!["paragraph_open", "heading_open"].includes(paragraph?.type) || paragraph.map[0] !== token.map[0]) continue
const inline = state.tokens[index + 2]
const match = inline?.type === "inline" && calloutMarker.exec(inline.content)
Comment thread
HamptonMakes marked this conversation as resolved.
Resolved
if (!match) continue
// Commonmarker requires exactly one space after the quote marker.
const opening = lines[paragraph.map[0]]
if (!opening.slice(0, opening.indexOf("[!")).endsWith("> ")) continue
// Markdown-it folds the marker into a setext heading, but Commonmarker
// parses the alert first. Preserve that source rather than rewrite it.
if (paragraph.type === "heading_open") {
token.meta = { preserveCallout: true }
continue
}
// An alert marker is not a paragraph for lazy continuation. Markdown-it
// folds an unquoted following line into this paragraph; keep that unusual
// source in the server-rendered preview instead of changing its meaning.
if (inline.content.slice(match[0].length) &&
!new RegExp(`^(?:[ \t]*>){${quoteDepth}}`).test(lines[paragraph.map[0] + 1] || "")) {
token.meta = { preserveCallout: true }
continue
}
token.meta = { callout: match[1].toLowerCase(), marker: match[0].replace(/\n$/, "") }
inline.content = inline.content.slice(match[0].length)
// A marker on its own line, before a blank line, list or fence, leaves
// an empty paragraph. Drop it so the callout body starts at its content.
if (!inline.content && state.tokens[index + 4]?.type !== "blockquote_close") state.tokens.splice(index + 1, 3)
}
})
tokenizer.core.ruler.after("block", "coplan_tables", state => {
const lines = state.src.split("\n")
for (let index = 0; index < state.tokens.length; index++) {
Expand All @@ -230,6 +287,7 @@ const richMarkdownParser = new MarkdownParser(schema, tokenizer, {
...defaultMarkdownParser.tokens,
s: { mark: "strikethrough" },
list_item: { block: "list_item", getAttrs: token => ({ checked: token.meta?.checked ?? null }) },
blockquote: { block: "blockquote", getAttrs: token => token.meta?.callout ? token.meta : {} },
ordered_list: { ...defaultMarkdownParser.tokens.ordered_list, getAttrs: (token, tokens, index) => ({
...defaultMarkdownParser.tokens.ordered_list.getAttrs(token, tokens, index), listStyle: token.meta?.listStyle || null
}) },
Expand Down Expand Up @@ -282,6 +340,16 @@ const defaultMarkdownSerializer = new MarkdownSerializer({
if (node.attrs.checked !== null) state.write(`[${node.attrs.checked ? "x" : " "}] `)
state.renderContent(node)
},
blockquote: (state, node) => {
if (!node.attrs.marker) return baseMarkdownSerializer.nodes.blockquote(state, node)
state.wrapBlock("> ", null, node, () => {
state.write(node.attrs.marker)
// An empty body keeps the marker alone, as `> [!NOTE]` was written.
if (node.childCount === 1 && node.firstChild.type.name === "paragraph" && !node.firstChild.content.size) return
state.ensureNewLine()
state.renderContent(node)
})
},
ordered_list: (state, node) => {
if (!node.attrs.listStyle) return baseMarkdownSerializer.nodes.ordered_list(state, node)
const start = node.attrs.order || 1
Expand Down Expand Up @@ -521,6 +589,7 @@ export function parseDocument(markdown) {
if (start > consumed) blocks.push(preserved(source.slice(consumed, start)))
const text = source.slice(start, end)
try {
if (text.includes("[!") && tokenizer.parse(text, {}).some(token => token.meta?.preserveCallout)) throw new Error("preserve")
const parsed = richMarkdownParser.parse(text)
let unsupported = text.replace(imageTag, tag => htmlImage(tag) ? "" : tag)
// Raw TeX such as "$a<b$" or "$[0,1] [2]$" is math, not HTML or a link.
Expand Down
Loading
Loading