diff --git a/app/globals.css b/app/globals.css index fbbce21..c902320 100644 --- a/app/globals.css +++ b/app/globals.css @@ -353,6 +353,12 @@ body { no edge doubles. (md-blocks supplies the wrapper + the copy corner.) */ [data-streamdown="table"] { border: 1px solid var(--border); + /* Tables HUG their contents (owner, callout round 2026-10-07): no more + full-width stretch with dead space. max-content sizes to the cells + (width:auto would still stretch — the classic table trap). No + max-width cap: a max-width would squish-wrap wide tables instead of + letting the md-blocks overflow-x scroller carry them. */ + width: max-content; /* Tables run at the SESSION prose size (owner note, 2026-09-19: they visibly lagged the 16px chat text at streamdown's 12.5px text-sm). Unlayered CSS beats the cells' own utility-layer text-sm, and the diff --git a/components/md-blocks.tsx b/components/md-blocks.tsx index 858eb1d..77d7423 100644 --- a/components/md-blocks.tsx +++ b/components/md-blocks.tsx @@ -27,7 +27,7 @@ * EXPAND icon (opens the session overlay) when a chat page is mounted. */ -import { isValidElement, useEffect, useRef, useState, type ComponentProps, type ReactNode } from "react"; +import { cloneElement, isValidElement, useEffect, useRef, useState, type ComponentProps, type ReactNode } from "react"; import { CheckIcon, CopyIcon, MaximizeIcon } from "lucide-react"; import { cn } from "@/lib/utils"; import { @@ -265,6 +265,88 @@ export function MarkdownCode(props: ComponentProps<"code"> & ExtraProps) { ); } +// ─── Key-idea callouts (the Streamdown `blockquote` override) ──────────────── + +/** GitHub-style alert markers — the syntax the models already know from a + * million READMEs. The PROMPT teaches only [!IMPORTANT] (one emphasis + * device, one meaning: load-bearing); the renderer accepts all five and + * boxes them identically — a stray [!NOTE] still meant "this deserves + * emphasis", and a literal marker line must never leak as text (owner + * design, callout round 2026-10-07). */ +const ALERT_MARKER_RE = /^\[!(note|tip|important|warning|caution)\][ \t]*(?:\n|$)/i; + +export interface ParsedAlert { + kind: string; + /** The blockquote children with the marker line stripped — prose intact. */ + children: ReactNode; +} + +const significant = (c: ReactNode) => c !== null && c !== undefined && c !== "" && !(typeof c === "string" && c.trim() === ""); + +/** The element's markdown tag — Streamdown wraps every block in its own + * styled component (MarkdownParagraph, …), so the React type is rarely + * the raw "p": the hast node carried in props tells the truth. */ +function markdownTag(el: React.ReactElement): string | null { + if (typeof el.type === "string") return el.type; + const node = (el.props as { node?: { tagName?: string } }).node; + return node?.tagName ?? null; +} + +/** Pure: does this blockquote open with a GitHub alert marker? Returns the + * kind and the children with the marker line removed (the marker's own + * paragraph dropped entirely when it stands alone — `> [!IMPORTANT]` then + * a blank quote line then content; a standalone marker also drops the + *
hard-break artifact that carried its line break). Non-alert + * blockquotes pass through untouched. Exported for hermetic tests. */ +export function parseAlertMarker(children: ReactNode): ParsedAlert | null { + const list = Array.isArray(children) ? children : [children]; + const para = list.find(significant); + if (!isValidElement(para) || markdownTag(para) !== "p") return null; + const inner = (para.props as { children?: ReactNode }).children; + const innerList = Array.isArray(inner) ? inner : [inner]; + const lead = innerList.find(significant); + if (typeof lead !== "string") return null; + const match = ALERT_MARKER_RE.exec(lead); + if (!match) return null; + const kind = match[1]!.toLowerCase(); + const stripped = lead.slice(match[0].length); + // Rebuild the paragraph without the marker line; drop it entirely when + // the marker stood alone (nothing but the marker text in the paragraph). + const leadIdxInner = innerList.indexOf(lead); + const hasFollowing = innerList.some((c, i) => i > leadIdxInner && significant(c)); + let rebuiltPara: ReactNode = null; + if (stripped.trim() !== "" || hasFollowing) { + let rest = innerList.map((c) => (c === lead ? (stripped === "" ? null : stripped) : c)); + if (stripped === "") { + // the hard-break
that ended the marker line is now leading junk + while (rest.length && (rest[0] === null || (isValidElement(rest[0]) && markdownTag(rest[0]) === "br"))) rest = rest.slice(1); + } + rebuiltPara = cloneElement(para, {}, ...rest); + } + const leadIdx = list.indexOf(para); + const restChildren = [...list.slice(0, leadIdx), ...(rebuiltPara ? [rebuiltPara] : []), ...list.slice(leadIdx + 1)]; + return { kind, children: restChildren }; +} + +/** The key-idea callout (owner rulings, 2026-10-07): SHARP corners, a white + * stroke, NO background, NO label, NO icon — just the outline. The box + * hugs its text (width: fit-content, capped at the column) instead of + * stretching full-width into empty space. */ +export function MarkdownBlockquote(props: ComponentProps<"blockquote"> & ExtraProps) { + const { children, node, ...rest } = props; + const alert = parseAlertMarker(children); + if (!alert) return
{children}
; + return ( +
+ {alert.children} +
+ ); +} + // ─── Tables (the Streamdown `table` override) ──────────────────────────────── /** A plain PROSE table: no wrapper box, no button rows — just the table @@ -367,4 +449,5 @@ export const mdComponents = { table: MarkdownTable, span: MathDisplay, strong: MarkdownStrong, + blockquote: MarkdownBlockquote, }; diff --git a/src/agent/prompts/shared/40-formatting.md b/src/agent/prompts/shared/40-formatting.md index dfdaf49..c331e20 100644 --- a/src/agent/prompts/shared/40-formatting.md +++ b/src/agent/prompts/shared/40-formatting.md @@ -3,4 +3,5 @@ Everything you write — every message, every quiz question, every quiz option and explanation — renders as markdown to him, and the full set is available: headings, lists, tables, fenced code, inline code, bold, mermaid diagrams. Use it maximally when it fits — the default is a structured document, not a wall of prose. Headings for the sections a message naturally has; lists and tables for anything enumerable or comparative; code blocks for anything literal. Structure is not decoration — it makes the content readable at a glance — and when in doubt, structure it. - Math: anything expressible in LaTeX goes in LaTeX — formulas, symbols, even single variables — everywhere text renders, and that means EVERY field: prose, quiz questions, quiz options, explanations, problem statements, answers. Inline `\(f(x)\)`, display `\[` fenced on its own lines. NEVER `$` or `$$` — a bare `$` is literal currency here; `\(...\)` and `\[...\]` are the only delimiters that render. +- Key ideas: a GitHub-style `> [!IMPORTANT]` callout renders as a distinct outlined block — the one emphasis device for load-bearing ideas. Use it sparingly: an unconditional truth as it's laid down, a payoff when it lands, a definition the whole chain hinges on. Everything else stays in prose — if everything is boxed, nothing is. - Diagrams: ```mermaid fenced blocks render as real diagrams — reach for one whenever structure, flow, or relationships are the point; a good diagram beats three paragraphs. Node labels with spaces or symbols must use quoted bracket form (`B["u = x^3"]`). diff --git a/tests/render-md-blocks.test.tsx b/tests/render-md-blocks.test.tsx index 8e57f5f..f340660 100644 --- a/tests/render-md-blocks.test.tsx +++ b/tests/render-md-blocks.test.tsx @@ -173,3 +173,59 @@ describe("mermaid expand — the context seam", () => { assert.deepEqual(opened, [], "nothing opened by rendering alone"); }); }); + +describe("md-blocks — key-idea callouts (GitHub alerts, owner round 2026-10-07)", () => { + it("an [!IMPORTANT] callout renders as the sharp white-outline box, marker never leaks", () => { + const html = render("> [!IMPORTANT]\n> ALL X is done through Y."); + assert.ok(html.includes('data-testid="md-callout"'), "the callout box mounts"); + assert.ok(html.includes('data-alert-kind="important"'), "the kind is recorded"); + assert.match(html, /ALL X is done through Y\./, "the prose survives"); + assert.ok(!html.includes("[!IMPORTANT]"), "the marker line never leaks as text"); + // The owner's minimal law: sharp corners (no rounded), white stroke, + // no background, no label, no icon — and the box hugs its text. + assert.match(html, /border-white/, "white stroke"); + assert.match(html, /w-fit/, "width: fit-content — the hug"); + assert.ok(!html.includes("rounded"), "sharp corners — no rounding anywhere"); + assert.ok(!html.includes("bg-card") && !html.includes("bg-background"), "transparent — no background"); + }); + + it("all five GitHub markers box identically — one device, one meaning", () => { + for (const kind of ["NOTE", "TIP", "IMPORTANT", "WARNING", "CAUTION"]) { + const html = render(`> [!${kind}]\n> Load-bearing words.`); + assert.ok(html.includes('data-testid="md-callout"'), `${kind} boxes`); + assert.ok(html.includes(`data-alert-kind="${kind.toLowerCase()}"`), `${kind} kind recorded`); + assert.ok(!html.includes(`[!${kind}]`), `${kind} marker never leaks`); + } + }); + + it("a standalone-marker callout (blank quote line) drops the empty paragraph", () => { + const html = render("> [!IMPORTANT]\n>\n> The payoff lands here."); + assert.ok(html.includes('data-testid="md-callout"')); + assert.match(html, /The payoff lands here\./); + assert.ok(!html.includes("[!IMPORTANT]")); + }); + + it("multi-paragraph callouts keep their structure", () => { + const html = render("> [!IMPORTANT]\n> First the truth.\n>\n> Then the payoff."); + assert.match(html, /First the truth\./); + assert.match(html, /Then the payoff\./); + assert.ok((html.match(/

/g) ?? []).length >= 2, "both paragraphs render inside the box"); + }); + + it("plain blockquotes pass through untouched — no box for ordinary quotes", () => { + const html = render("> Just an ordinary quote."); + assert.ok(!html.includes('data-testid="md-callout"'), "no callout box"); + assert.match(html, /Just an ordinary quote\./); + }); +}); + +describe("parseAlertMarker (pure)", () => { + it("non-paragraph openers and marker-less text return null", async () => { + const { parseAlertMarker } = await import("../components/md-blocks.tsx"); + assert.equal(parseAlertMarker("just text"), null); + assert.equal(parseAlertMarker(null), null); + // a leading node, not a paragraph: no crash, no alert + const em = { type: "em", props: { children: "x" } } as never; + assert.equal(parseAlertMarker(em), null); + }); +});