From ba1e8cb6c922481eb5b43f2258edb0cc465ad128 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Wed, 12 Aug 2026 15:37:41 -0400 Subject: [PATCH 1/3] feat(tui): render Mermaid timelines --- packages/merman/src/detect.ts | 2 + packages/merman/src/diagnostics.ts | 2 +- packages/merman/src/markdown.ts | 23 +++ packages/merman/src/test/diagnostics.test.ts | 7 + packages/merman/src/test/markdown.test.ts | 28 ++++ packages/merman/src/timeline/diagram.test.ts | 139 +++++++++++++++++++ packages/merman/src/timeline/diagram.ts | 8 ++ packages/merman/src/timeline/drawing.ts | 93 +++++++++++++ packages/merman/src/timeline/parser.ts | 119 ++++++++++++++++ packages/merman/src/timeline/render-grid.ts | 17 +++ packages/merman/src/timeline/style.ts | 25 ++++ packages/merman/src/timeline/types.ts | 27 ++++ 12 files changed, 489 insertions(+), 1 deletion(-) create mode 100644 packages/merman/src/timeline/diagram.test.ts create mode 100644 packages/merman/src/timeline/diagram.ts create mode 100644 packages/merman/src/timeline/drawing.ts create mode 100644 packages/merman/src/timeline/parser.ts create mode 100644 packages/merman/src/timeline/render-grid.ts create mode 100644 packages/merman/src/timeline/style.ts create mode 100644 packages/merman/src/timeline/types.ts diff --git a/packages/merman/src/detect.ts b/packages/merman/src/detect.ts index 3e8443a833c5..443ae61cda2a 100644 --- a/packages/merman/src/detect.ts +++ b/packages/merman/src/detect.ts @@ -2,10 +2,12 @@ import type { MermaidDiagramKind } from "./diagnostics.js" import { isMermaidFlowchartDiagram } from "./flowchart/parser.js" import { isMermaidSequenceDiagram } from "./sequence/parser.js" import { isMermaidStateDiagram } from "./state/parser.js" +import { isMermaidTimelineDiagram } from "./timeline/parser.js" export function detectMermaidDiagram(content: string): MermaidDiagramKind | undefined { if (isMermaidFlowchartDiagram(content)) return "flowchart" if (isMermaidSequenceDiagram(content)) return "sequence" if (isMermaidStateDiagram(content)) return "state" + if (isMermaidTimelineDiagram(content)) return "timeline" return undefined } diff --git a/packages/merman/src/diagnostics.ts b/packages/merman/src/diagnostics.ts index 7336bfcaa83e..8a9366188808 100644 --- a/packages/merman/src/diagnostics.ts +++ b/packages/merman/src/diagnostics.ts @@ -1,4 +1,4 @@ -export type MermaidDiagramKind = "flowchart" | "sequence" | "state" +export type MermaidDiagramKind = "flowchart" | "sequence" | "state" | "timeline" /** An otherwise valid diagram contains syntax that this renderer does not support. */ export class MermaidSyntaxError extends Error { diff --git a/packages/merman/src/markdown.ts b/packages/merman/src/markdown.ts index 136b5cab1687..e933742869f2 100644 --- a/packages/merman/src/markdown.ts +++ b/packages/merman/src/markdown.ts @@ -25,6 +25,10 @@ import { drawStateDiagramGrid } from "./state/drawing.js" import { parseMermaidStateDiagram } from "./state/parser.js" import { renderStateGridStyledText } from "./state/render-grid.js" import { resolveStateStyleColors } from "./state/style.js" +import { drawTimelineDiagramGrid } from "./timeline/drawing.js" +import { parseMermaidTimelineDiagram } from "./timeline/parser.js" +import { renderTimelineGridStyledText } from "./timeline/render-grid.js" +import { resolveTimelineStyleColors } from "./timeline/style.js" type DiagramKind = NonNullable> @@ -180,6 +184,25 @@ function prepareDiagram( height: size.height, } } + case "timeline": { + const grid = drawTimelineDiagramGrid(parseMermaidTimelineDiagram(source)) + const size = grid.getTextSize({ trimBottom: true }) + return { + kind, + source, + text: renderTimelineGridStyledText( + grid, + resolveTimelineStyleColors({ + title: color(colors.text), + section: color(colors.secondary), + period: color(colors.warning), + spine: color(colors.muted), + event: color(colors.primary), + }), + ), + height: size.height, + } + } } } diff --git a/packages/merman/src/test/diagnostics.test.ts b/packages/merman/src/test/diagnostics.test.ts index 18f01cf36335..5ccebb2ac611 100644 --- a/packages/merman/src/test/diagnostics.test.ts +++ b/packages/merman/src/test/diagnostics.test.ts @@ -3,6 +3,7 @@ import { MermaidSyntaxError } from "../diagnostics.js" import { parseMermaidFlowchartDiagram } from "../flowchart/parser.js" import { parseMermaidSequenceDiagram } from "../sequence/parser.js" import { parseMermaidStateDiagram } from "../state/parser.js" +import { renderTimelineDiagram } from "../timeline/diagram.js" import { renderSequenceDiagram } from "../sequence/diagram.js" describe("parser diagnostics", () => { @@ -104,6 +105,12 @@ describe("parser diagnostics", () => { ).toThrow('Unexpected "end" without an open block in sequence diagram at line 2: "end"') }) + test("reports malformed timeline continuations with timeline diagnostics", () => { + expect(() => renderTimelineDiagram("timeline\n : orphan event")).toThrow( + 'Timeline continuation requires a preceding period in timeline diagram at line 2: ": orphan event"', + ) + }) + test("does not attach else through an unclosed nested sequence block", () => { expect(() => parseMermaidSequenceDiagram(`sequenceDiagram diff --git a/packages/merman/src/test/markdown.test.ts b/packages/merman/src/test/markdown.test.ts index 056b0f636a4c..49c3423c2d64 100644 --- a/packages/merman/src/test/markdown.test.ts +++ b/packages/merman/src/test/markdown.test.ts @@ -333,3 +333,31 @@ stateDiagram-v2 expect(frame).toContain("Idle") expect(frame).not.toContain("stateDiagram-v2") }) + +test("renders a Mermaid timeline fence inside MarkdownRenderable", async () => { + const testRenderer = await createTestRenderer({ width: 80, height: 18 }) + renderer = testRenderer.renderer + const { renderOnce, captureCharFrame } = testRenderer + const markdown = new MarkdownRenderable(renderer, { + id: "markdown-timeline", + content: `\`\`\`mermaid +timeline + title Product history + section Foundation + 2024 : Prototype + : First release +\`\`\``, + syntaxStyle, + treeSitterClient, + renderNode: createMermaidMarkdownRenderer(renderer), + }) + + renderer.root.add(markdown) + await renderMarkdown(markdown, renderOnce) + + const frame = captureCharFrame() + expect(frame).toContain("Product history") + expect(frame).toContain("Foundation") + expect(frame).toContain("First release") + expect(frame).not.toContain("timeline") +}) diff --git a/packages/merman/src/timeline/diagram.test.ts b/packages/merman/src/timeline/diagram.test.ts new file mode 100644 index 000000000000..6e907a3c4713 --- /dev/null +++ b/packages/merman/src/timeline/diagram.test.ts @@ -0,0 +1,139 @@ +import { describe, expect, test } from "bun:test" +import { renderTimelineDiagram } from "./diagram.js" +import { drawTimelineDiagramGrid } from "./drawing.js" +import { parseMermaidTimelineDiagram } from "./parser.js" +import { renderTimelineGridText } from "./render-grid.js" +import { resolveTimelineStyleColors } from "./style.js" + +describe("TimelineDiagram", () => { + test("detects and parses titles, sections, periods, inline events, and continuations", () => { + const diagram = parseMermaidTimelineDiagram(` +%% product history +timeline LR + title Product &
Platform + + section Foundation + 2024 : Prototype : First release + : Public beta + section Growth + 2025 : "Scale: ≥ 10k" +`) + + expect(diagram.direction).toBe("LR") + expect(diagram.title).toBe("Product &
Platform") + expect(diagram.sections).toEqual([{ label: "Foundation" }, { label: "Growth" }]) + expect(diagram.periods).toEqual([ + { period: "2024", events: ["Prototype", "First release", "Public beta"] }, + { period: "2025", events: ["Scale: ≥ 10k"] }, + ]) + expect(diagram.entries.map((entry) => entry.type)).toEqual(["section", "period", "section", "period"]) + }) + + test("renders a vertical spine with title, section, periods, events, entities, and line breaks", () => { + const output = renderTimelineDiagram(`timeline + title Product &
Platform + section Foundation
phase + 2024 : Prototype
ready : First release + : Scale ≥ 10k`) + + expect(output).toBe( + [ + " Product &", + " Platform", + "", + " ◆ Foundation", + " │ phase", + " │", + "2024 ──●── Prototype", + " │ ready", + " │── First release", + " │── Scale ≥ 10k", + " │", + ].join("\n"), + ) + }) + + test.each(["timeline", "timeline TD", "timeline LR"])("uses the vertical terminal layout for %s", (header) => { + const output = renderTimelineDiagram(`${header}\n 2024 : One\n 2025 : Two`) + const lines = output.split("\n") + + expect(lines.findIndex((line) => line.includes("2024"))).toBeLessThan( + lines.findIndex((line) => line.includes("2025")), + ) + expect(output).toContain("│") + expect(output).toContain("●") + }) + + test("preserves Mermaid direction semantics while using vertical terminal layout", () => { + expect(parseMermaidTimelineDiagram("timeline\n 2024 : One").direction).toBe("LR") + expect(parseMermaidTimelineDiagram("timeline TD\n 2024 : One").direction).toBe("TD") + }) + + test("keeps ordinary colons in event text", () => { + const diagram = parseMermaidTimelineDiagram(`timeline + 2024 : https://example.com : event:detail : next event`) + + expect(diagram.periods[0]?.events).toEqual(["https://example.com", "event:detail", "next event"]) + }) + + test("does not treat apostrophes in event prose as quotes", () => { + const diagram = parseMermaidTimelineDiagram("timeline\n 2024 : Kit's launch : Public beta") + + expect(diagram.periods[0]?.events).toEqual(["Kit's launch", "Public beta"]) + }) + + test("supports standalone periods followed by continuation events", () => { + const diagram = parseMermaidTimelineDiagram(`timeline + 2024 + : First release + : Public beta`) + + expect(diagram.periods).toEqual([{ period: "2024", events: ["First release", "Public beta"] }]) + }) + + test("ignores timeline comments and accessibility directives", () => { + const diagram = parseMermaidTimelineDiagram(`timeline + # product history + accTitle: Product timeline + accDescr Product release history + 2024 : Prototype %% internal note`) + + expect(diagram.periods).toEqual([{ period: "2024", events: ["Prototype"] }]) + }) + + test("ignores multiline accessibility descriptions", () => { + const diagram = parseMermaidTimelineDiagram(`timeline + accDescr { + Product milestones by year. + Includes launch and growth. + } + 2024 : Ship`) + + expect(diagram.periods).toEqual([{ period: "2024", events: ["Ship"] }]) + }) + + test("rejects a continuation without a period with source diagnostics", () => { + expect(() => parseMermaidTimelineDiagram("timeline\n : orphan event")).toThrow( + 'Timeline continuation requires a preceding period in timeline diagram at line 2: ": orphan event"', + ) + }) + + test("rejects unsupported and empty syntax", () => { + expect(() => parseMermaidTimelineDiagram("timeline\n section")).toThrow("Timeline section cannot be empty") + expect(() => parseMermaidTimelineDiagram("timeline\n 2024 :")).toThrow("Timeline event cannot be empty") + expect(() => parseMermaidTimelineDiagram("timeline\n : unsupported")).toThrow("requires a preceding period") + }) + + test("draws semantic styles for every timeline role", () => { + const grid = drawTimelineDiagramGrid( + parseMermaidTimelineDiagram("timeline\n title Roadmap\n section Now\n 2026 : Ship"), + ) + const styles = new Set(grid.rows.flatMap((row) => row.map((cell) => cell.style).filter(Boolean))) + + expect(styles).toEqual(new Set(["title", "section", "spine", "period", "event"])) + expect(Object.keys(resolveTimelineStyleColors()).sort()).toEqual(["event", "period", "section", "spine", "title"]) + expect(renderTimelineGridText(grid)).toBe( + renderTimelineDiagram("timeline\n title Roadmap\n section Now\n 2026 : Ship"), + ) + }) +}) diff --git a/packages/merman/src/timeline/diagram.ts b/packages/merman/src/timeline/diagram.ts new file mode 100644 index 000000000000..d8340bff5edc --- /dev/null +++ b/packages/merman/src/timeline/diagram.ts @@ -0,0 +1,8 @@ +import { drawTimelineDiagramGrid } from "./drawing.js" +import { parseMermaidTimelineDiagram } from "./parser.js" +import { renderTimelineGridText } from "./render-grid.js" +import type { TimelineDiagramRenderOptions } from "./types.js" + +export function renderTimelineDiagram(content: string, options: TimelineDiagramRenderOptions = {}): string { + return renderTimelineGridText(drawTimelineDiagramGrid(parseMermaidTimelineDiagram(content), options)) +} diff --git a/packages/merman/src/timeline/drawing.ts b/packages/merman/src/timeline/drawing.ts new file mode 100644 index 000000000000..f5ee454ab0c7 --- /dev/null +++ b/packages/merman/src/timeline/drawing.ts @@ -0,0 +1,93 @@ +import { DiagramCanvas } from "../core/canvas.js" +import { splitDiagramLines } from "../core/text-lines.js" +import { diagramTextWidth } from "../core/text.js" +import type { TimelineGrid } from "./render-grid.js" +import type { TimelineCellStyle, TimelineDiagram, TimelineDiagramRenderOptions, TimelinePeriod } from "./types.js" + +interface PeriodLayout { + period: TimelinePeriod + periodLines: string[] + eventLines: string[][] + height: number +} + +const BRANCH = "──" + +export function drawTimelineDiagramGrid( + diagram: TimelineDiagram, + _options: TimelineDiagramRenderOptions = {}, +): TimelineGrid { + const periodLayouts = new Map() + let leftWidth = 0 + let rightWidth = 0 + let bodyHeight = 0 + + for (const entry of diagram.entries) { + if (entry.type === "section") { + const lines = splitDiagramLines(entry.section.label) + bodyHeight += lines.length + 1 + rightWidth = Math.max(rightWidth, ...lines.map(diagramTextWidth)) + continue + } + const periodLines = splitDiagramLines(entry.period.period) + const eventLines = entry.period.events.map(splitDiagramLines) + const eventHeight = eventLines.reduce((height, lines) => height + lines.length, 0) + const height = Math.max(periodLines.length, eventHeight) + periodLayouts.set(entry.period, { period: entry.period, periodLines, eventLines, height }) + leftWidth = Math.max(leftWidth, ...periodLines.map(diagramTextWidth)) + rightWidth = Math.max(rightWidth, ...eventLines.flat().map(diagramTextWidth)) + bodyHeight += height + 1 + } + + const titleLines = diagram.title ? splitDiagramLines(diagram.title) : [] + const bodyWidth = diagram.entries.length === 0 ? 0 : leftWidth + rightWidth + 7 + const titleWidth = titleLines.length === 0 ? 0 : Math.max(...titleLines.map(diagramTextWidth)) + const width = Math.max(bodyWidth, titleWidth) + const titleHeight = titleLines.length === 0 ? 0 : titleLines.length + (diagram.entries.length === 0 ? 0 : 1) + if (width === 0) return new DiagramCanvas(0, 0) + + const grid: TimelineGrid = new DiagramCanvas(width, titleHeight + bodyHeight) + titleLines.forEach((line, index) => + setText(grid, Math.floor((width - diagramTextWidth(line)) / 2), index, line, "title"), + ) + if (diagram.entries.length === 0) return grid + + const spineX = leftWidth + 3 + let y = titleHeight + for (const entry of diagram.entries) { + if (entry.type === "section") { + const lines = splitDiagramLines(entry.section.label) + setCell(grid, spineX, y, "◆", "section") + lines.forEach((line, index) => setText(grid, spineX + 3, y + index, line, "section")) + for (let row = y + 1; row < y + lines.length + 1; row++) setCell(grid, spineX, row, "│", "spine") + y += lines.length + 1 + continue + } + + const layout = periodLayouts.get(entry.period)! + for (let row = 0; row < layout.height + 1; row++) setCell(grid, spineX, y + row, "│", "spine") + setCell(grid, spineX, y, "●", "spine") + layout.periodLines.forEach((line, index) => { + const lineWidth = diagramTextWidth(line) + setText(grid, leftWidth - lineWidth, y + index, line, "period") + }) + for (let x = leftWidth + 1; x < spineX; x++) setCell(grid, x, y, "─", "spine") + + let eventY = y + for (const lines of layout.eventLines) { + setText(grid, spineX + 1, eventY, BRANCH, "spine") + lines.forEach((line, index) => setText(grid, spineX + 4, eventY + index, line, "event")) + eventY += lines.length + } + y += layout.height + 1 + } + return grid +} + +function setCell(grid: TimelineGrid, x: number, y: number, char: string, style: TimelineCellStyle): void { + grid.setCell(x, y, char, style) +} + +function setText(grid: TimelineGrid, x: number, y: number, text: string, style: TimelineCellStyle): void { + grid.setText(x, y, text, style) +} diff --git a/packages/merman/src/timeline/parser.ts b/packages/merman/src/timeline/parser.ts new file mode 100644 index 000000000000..818d90855766 --- /dev/null +++ b/packages/merman/src/timeline/parser.ts @@ -0,0 +1,119 @@ +import { firstMeaningfulMermaidLine, meaningfulNumberedMermaidLines, stripMermaidQuotes } from "../core/mermaid.js" +import { MermaidSyntaxError } from "../diagnostics.js" +import type { TimelineDiagram, TimelineDirection, TimelineEntry, TimelinePeriod, TimelineSection } from "./types.js" + +const HEADER_RE = /^timeline(?:\s+(TD|LR))?$/i +const TITLE_RE = /^title(?:\s+(.+))?$/i +const SECTION_RE = /^section(?:\s+(.+))?$/i +const ACCESSIBILITY_RE = /^acc(?:Title|Descr)(?::|\s|$)/i + +export function isMermaidTimelineDiagram(content: string): boolean { + return HEADER_RE.test(firstMeaningfulMermaidLine(content) ?? "") +} + +export function parseMermaidTimelineDiagram(content: string): TimelineDiagram { + const sections: TimelineSection[] = [] + const periods: TimelinePeriod[] = [] + const entries: TimelineEntry[] = [] + let direction: TimelineDirection = "LR" + let title: string | undefined + let currentPeriod: TimelinePeriod | undefined + let inAccessibilityDescription = false + + for (const source of meaningfulNumberedMermaidLines(content)) { + const line = stripTimelineComment(source.text) + if (inAccessibilityDescription) { + if (line === "}") inAccessibilityDescription = false + continue + } + if (/^accDescr\s*\{$/i.test(line)) { + inAccessibilityDescription = true + continue + } + if (!line || line.startsWith("#") || ACCESSIBILITY_RE.test(line)) continue + const header = line.match(HEADER_RE) + if (header) { + direction = (header[1]?.toUpperCase() as TimelineDirection | undefined) ?? "LR" + continue + } + + const titleMatch = line.match(TITLE_RE) + if (titleMatch) { + if (!titleMatch[1]) throw syntaxError(source.lineNumber, line, "Timeline title cannot be empty") + title = stripMermaidQuotes(titleMatch[1]) + continue + } + + const sectionMatch = line.match(SECTION_RE) + if (sectionMatch) { + if (!sectionMatch[1]) throw syntaxError(source.lineNumber, line, "Timeline section cannot be empty") + const section = { label: stripMermaidQuotes(sectionMatch[1]) } + sections.push(section) + entries.push({ type: "section", section }) + currentPeriod = undefined + continue + } + + if (line.startsWith(":")) { + if (!currentPeriod) { + throw syntaxError(source.lineNumber, line, "Timeline continuation requires a preceding period") + } + currentPeriod.events.push(...parseEvents(line.slice(1), source.lineNumber, line)) + continue + } + + const fields = splitEventFields(line) + const periodLabel = stripMermaidQuotes(fields.shift()!) + if (!periodLabel) throw syntaxError(source.lineNumber, line, "Timeline period cannot be empty") + const period = { + period: periodLabel, + events: fields.length === 0 ? [] : parseEventFields(fields, source.lineNumber, line), + } + periods.push(period) + entries.push({ type: "period", period }) + currentPeriod = period + } + + return { direction, ...(title === undefined ? {} : { title }), sections, periods, entries } +} + +function parseEvents(value: string, lineNumber: number, sourceLine: string): string[] { + return parseEventFields(splitEventFields(value), lineNumber, sourceLine) +} + +function parseEventFields(fields: string[], lineNumber: number, sourceLine: string): string[] { + const events = fields.map(stripMermaidQuotes) + if (events.length === 0 || events.some((event) => event.length === 0)) { + throw syntaxError(lineNumber, sourceLine, "Timeline event cannot be empty") + } + return events +} + +function splitEventFields(value: string): string[] { + const fields: string[] = [] + let quote: '"' | "'" | undefined + let start = 0 + for (let index = 0; index < value.length; index++) { + const char = value[index] + if (char === '"' || char === "'") { + if (quote === char) quote = undefined + else if (quote === undefined && value.slice(start, index).trim() === "") quote = char + continue + } + const next = value[index + 1] + if (char !== ":" || quote !== undefined || (next !== undefined && !/\s/.test(next))) continue + fields.push(value.slice(start, index)) + start = index + 1 + } + fields.push(value.slice(start)) + return fields +} + +function stripTimelineComment(value: string): string { + const comment = value.indexOf("%%") + return (comment < 0 ? value : value.slice(0, comment)).trim() +} + +function syntaxError(lineNumber: number, sourceLine: string, reason?: string): MermaidSyntaxError { + return new MermaidSyntaxError("timeline", lineNumber, sourceLine, reason) +} diff --git a/packages/merman/src/timeline/render-grid.ts b/packages/merman/src/timeline/render-grid.ts new file mode 100644 index 000000000000..cb28a919a7c2 --- /dev/null +++ b/packages/merman/src/timeline/render-grid.ts @@ -0,0 +1,17 @@ +import type { StyledText } from "@opentui/core" +import type { DiagramCanvas } from "../core/canvas.js" +import { renderDiagramGridStyledText } from "../core/render-grid.js" +import type { TimelineStyleColors } from "./style.js" +import type { TimelineCellStyle } from "./types.js" + +export type TimelineGrid = DiagramCanvas + +export function renderTimelineGridText(grid: TimelineGrid): string { + return grid.toString({ trimBottom: true }) +} + +export function renderTimelineGridStyledText(grid: TimelineGrid, colors: TimelineStyleColors): StyledText { + return renderDiagramGridStyledText(grid, (run) => (run.style ? colors[run.style] : undefined), undefined, { + trimBottom: true, + }) +} diff --git a/packages/merman/src/timeline/style.ts b/packages/merman/src/timeline/style.ts new file mode 100644 index 000000000000..439f24adf5f6 --- /dev/null +++ b/packages/merman/src/timeline/style.ts @@ -0,0 +1,25 @@ +import { RGBA } from "@opentui/core" +import { rgba, type DiagramRgb } from "../core/color/style.js" +import type { TimelineCellStyle } from "./types.js" + +const DEFAULT_THEME_RGB = { + title: [228, 239, 232], + section: [154, 184, 169], + period: [230, 177, 126], + spine: [111, 138, 126], + event: [134, 225, 200], +} as const satisfies Record + +export type TimelineStyleColors = Required> + +export function resolveTimelineStyleColors( + colors: Partial> = {}, +): TimelineStyleColors { + return { + title: colors.title ?? rgba(DEFAULT_THEME_RGB.title), + section: colors.section ?? rgba(DEFAULT_THEME_RGB.section), + period: colors.period ?? rgba(DEFAULT_THEME_RGB.period), + spine: colors.spine ?? rgba(DEFAULT_THEME_RGB.spine), + event: colors.event ?? rgba(DEFAULT_THEME_RGB.event), + } +} diff --git a/packages/merman/src/timeline/types.ts b/packages/merman/src/timeline/types.ts new file mode 100644 index 000000000000..dd3a766d9a85 --- /dev/null +++ b/packages/merman/src/timeline/types.ts @@ -0,0 +1,27 @@ +export type TimelineDirection = "TD" | "LR" + +export interface TimelineSection { + label: string +} + +export interface TimelinePeriod { + period: string + events: string[] +} + +export type TimelineEntry = { type: "section"; section: TimelineSection } | { type: "period"; period: TimelinePeriod } + +export interface TimelineDiagram { + direction: TimelineDirection + title?: string + sections: TimelineSection[] + periods: TimelinePeriod[] + entries: TimelineEntry[] +} + +export interface TimelineDiagramRenderOptions { + /** Parsed for Mermaid compatibility. Timeline diagrams always use a vertical terminal layout. */ + direction?: TimelineDirection +} + +export type TimelineCellStyle = "title" | "section" | "period" | "spine" | "event" From acb50e1eb75cda99553ad52fe6034df3b5168506 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Wed, 12 Aug 2026 15:55:01 -0400 Subject: [PATCH 2/3] style(tui): simplify Mermaid timeline spine --- packages/merman/src/timeline/diagram.test.ts | 20 ++++++++++---------- packages/merman/src/timeline/drawing.ts | 15 +++++++-------- 2 files changed, 17 insertions(+), 18 deletions(-) diff --git a/packages/merman/src/timeline/diagram.test.ts b/packages/merman/src/timeline/diagram.test.ts index 6e907a3c4713..cee6d31b4a8a 100644 --- a/packages/merman/src/timeline/diagram.test.ts +++ b/packages/merman/src/timeline/diagram.test.ts @@ -38,17 +38,17 @@ timeline LR expect(output).toBe( [ - " Product &", - " Platform", + " Product &", + " Platform", "", - " ◆ Foundation", - " │ phase", - " │", - "2024 ──●── Prototype", - " │ ready", - " │── First release", - " │── Scale ≥ 10k", - " │", + "Foundation │", + " phase │", + " │", + " 2024 ──● Prototype", + " │ ready", + " │ First release", + " │ Scale ≥ 10k", + " │", ].join("\n"), ) }) diff --git a/packages/merman/src/timeline/drawing.ts b/packages/merman/src/timeline/drawing.ts index f5ee454ab0c7..fe209e04e4e6 100644 --- a/packages/merman/src/timeline/drawing.ts +++ b/packages/merman/src/timeline/drawing.ts @@ -11,8 +11,6 @@ interface PeriodLayout { height: number } -const BRANCH = "──" - export function drawTimelineDiagramGrid( diagram: TimelineDiagram, _options: TimelineDiagramRenderOptions = {}, @@ -26,7 +24,7 @@ export function drawTimelineDiagramGrid( if (entry.type === "section") { const lines = splitDiagramLines(entry.section.label) bodyHeight += lines.length + 1 - rightWidth = Math.max(rightWidth, ...lines.map(diagramTextWidth)) + leftWidth = Math.max(leftWidth, ...lines.map(diagramTextWidth)) continue } const periodLines = splitDiagramLines(entry.period.period) @@ -57,9 +55,11 @@ export function drawTimelineDiagramGrid( for (const entry of diagram.entries) { if (entry.type === "section") { const lines = splitDiagramLines(entry.section.label) - setCell(grid, spineX, y, "◆", "section") - lines.forEach((line, index) => setText(grid, spineX + 3, y + index, line, "section")) - for (let row = y + 1; row < y + lines.length + 1; row++) setCell(grid, spineX, row, "│", "spine") + lines.forEach((line, index) => { + setText(grid, leftWidth - diagramTextWidth(line), y + index, line, "section") + setCell(grid, spineX, y + index, "│", "spine") + }) + setCell(grid, spineX, y + lines.length, "│", "spine") y += lines.length + 1 continue } @@ -75,8 +75,7 @@ export function drawTimelineDiagramGrid( let eventY = y for (const lines of layout.eventLines) { - setText(grid, spineX + 1, eventY, BRANCH, "spine") - lines.forEach((line, index) => setText(grid, spineX + 4, eventY + index, line, "event")) + lines.forEach((line, index) => setText(grid, spineX + 3, eventY + index, line, "event")) eventY += lines.length } y += layout.height + 1 From 1b36075069594e816baa87ec224bd4285fe8a477 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Wed, 12 Aug 2026 16:24:30 -0400 Subject: [PATCH 3/3] style(tui): join timeline labels to rail --- packages/merman/src/timeline/diagram.test.ts | 71 +++++++++++++++++--- packages/merman/src/timeline/drawing.ts | 35 +++++++--- packages/merman/src/timeline/style.ts | 25 +++++-- packages/merman/src/timeline/types.ts | 6 +- 4 files changed, 109 insertions(+), 28 deletions(-) diff --git a/packages/merman/src/timeline/diagram.test.ts b/packages/merman/src/timeline/diagram.test.ts index cee6d31b4a8a..52e8ee2f8c18 100644 --- a/packages/merman/src/timeline/diagram.test.ts +++ b/packages/merman/src/timeline/diagram.test.ts @@ -38,17 +38,17 @@ timeline LR expect(output).toBe( [ - " Product &", + " Product &", " Platform", "", - "Foundation │", - " phase │", - " │", - " 2024 ──● Prototype", - " │ ready", - " │ First release", - " │ Scale ≥ 10k", - " │", + "Foundation ───┐", + " phase │", + " │", + " 2024 ───● Prototype", + " │ ready", + " │ First release", + " │ Scale ≥ 10k", + " │", ].join("\n"), ) }) @@ -130,10 +130,59 @@ timeline LR ) const styles = new Set(grid.rows.flatMap((row) => row.map((cell) => cell.style).filter(Boolean))) - expect(styles).toEqual(new Set(["title", "section", "spine", "period", "event"])) - expect(Object.keys(resolveTimelineStyleColors()).sort()).toEqual(["event", "period", "section", "spine", "title"]) + expect(styles).toEqual( + new Set([ + "title", + "section", + "sectionFade1", + "sectionFade2", + "sectionFade3", + "spine", + "period", + "periodFade1", + "periodFade2", + "periodFade3", + "event", + ]), + ) + expect(Object.keys(resolveTimelineStyleColors()).sort()).toEqual([ + "event", + "period", + "periodFade1", + "periodFade2", + "periodFade3", + "section", + "sectionFade1", + "sectionFade2", + "sectionFade3", + "spine", + "title", + ]) expect(renderTimelineGridText(grid)).toBe( renderTimelineDiagram("timeline\n title Roadmap\n section Now\n 2026 : Ship"), ) }) + + test("uses section starts and joins with ordered color ramps", () => { + const grid = drawTimelineDiagramGrid( + parseMermaidTimelineDiagram("timeline\n section Morning\n 09:00 : Start\n section Midday\n 12:00 : Continue"), + ) + const text = renderTimelineGridText(grid) + + expect(text).toContain("Morning ───┐") + expect(text).toContain("Midday ───┤") + expect(grid.rows[0]?.map((cell) => cell.style).filter(Boolean)).toEqual([ + "section", + "section", + "section", + "section", + "section", + "section", + "section", + "sectionFade1", + "sectionFade2", + "sectionFade3", + "spine", + ]) + }) }) diff --git a/packages/merman/src/timeline/drawing.ts b/packages/merman/src/timeline/drawing.ts index fe209e04e4e6..34fe704747e1 100644 --- a/packages/merman/src/timeline/drawing.ts +++ b/packages/merman/src/timeline/drawing.ts @@ -2,6 +2,7 @@ import { DiagramCanvas } from "../core/canvas.js" import { splitDiagramLines } from "../core/text-lines.js" import { diagramTextWidth } from "../core/text.js" import type { TimelineGrid } from "./render-grid.js" +import { TIMELINE_PERIOD_FADE_STYLES, TIMELINE_SECTION_FADE_STYLES } from "./style.js" import type { TimelineCellStyle, TimelineDiagram, TimelineDiagramRenderOptions, TimelinePeriod } from "./types.js" interface PeriodLayout { @@ -11,6 +12,10 @@ interface PeriodLayout { height: number } +const JOIN_WIDTH = TIMELINE_SECTION_FADE_STYLES.length +const SPINE_OFFSET = JOIN_WIDTH + 1 +const EVENT_OFFSET = 3 + export function drawTimelineDiagramGrid( diagram: TimelineDiagram, _options: TimelineDiagramRenderOptions = {}, @@ -24,7 +29,7 @@ export function drawTimelineDiagramGrid( if (entry.type === "section") { const lines = splitDiagramLines(entry.section.label) bodyHeight += lines.length + 1 - leftWidth = Math.max(leftWidth, ...lines.map(diagramTextWidth)) + for (const line of lines) leftWidth = Math.max(leftWidth, diagramTextWidth(line)) continue } const periodLines = splitDiagramLines(entry.period.period) @@ -32,14 +37,17 @@ export function drawTimelineDiagramGrid( const eventHeight = eventLines.reduce((height, lines) => height + lines.length, 0) const height = Math.max(periodLines.length, eventHeight) periodLayouts.set(entry.period, { period: entry.period, periodLines, eventLines, height }) - leftWidth = Math.max(leftWidth, ...periodLines.map(diagramTextWidth)) - rightWidth = Math.max(rightWidth, ...eventLines.flat().map(diagramTextWidth)) + for (const line of periodLines) leftWidth = Math.max(leftWidth, diagramTextWidth(line)) + for (const lines of eventLines) { + for (const line of lines) rightWidth = Math.max(rightWidth, diagramTextWidth(line)) + } bodyHeight += height + 1 } const titleLines = diagram.title ? splitDiagramLines(diagram.title) : [] - const bodyWidth = diagram.entries.length === 0 ? 0 : leftWidth + rightWidth + 7 - const titleWidth = titleLines.length === 0 ? 0 : Math.max(...titleLines.map(diagramTextWidth)) + const bodyWidth = diagram.entries.length === 0 ? 0 : leftWidth + SPINE_OFFSET + EVENT_OFFSET + rightWidth + 1 + let titleWidth = 0 + for (const line of titleLines) titleWidth = Math.max(titleWidth, diagramTextWidth(line)) const width = Math.max(bodyWidth, titleWidth) const titleHeight = titleLines.length === 0 ? 0 : titleLines.length + (diagram.entries.length === 0 ? 0 : 1) if (width === 0) return new DiagramCanvas(0, 0) @@ -50,32 +58,37 @@ export function drawTimelineDiagramGrid( ) if (diagram.entries.length === 0) return grid - const spineX = leftWidth + 3 + const spineX = leftWidth + SPINE_OFFSET let y = titleHeight + let railStarted = false for (const entry of diagram.entries) { if (entry.type === "section") { const lines = splitDiagramLines(entry.section.label) lines.forEach((line, index) => { setText(grid, leftWidth - diagramTextWidth(line), y + index, line, "section") - setCell(grid, spineX, y + index, "│", "spine") + if (index > 0) setCell(grid, spineX, y + index, "│", "spine") }) + drawJoin(grid, leftWidth, y, TIMELINE_SECTION_FADE_STYLES) + setCell(grid, spineX, y, railStarted ? "┤" : "┐", "spine") setCell(grid, spineX, y + lines.length, "│", "spine") + railStarted = true y += lines.length + 1 continue } const layout = periodLayouts.get(entry.period)! for (let row = 0; row < layout.height + 1; row++) setCell(grid, spineX, y + row, "│", "spine") + railStarted = true setCell(grid, spineX, y, "●", "spine") layout.periodLines.forEach((line, index) => { const lineWidth = diagramTextWidth(line) setText(grid, leftWidth - lineWidth, y + index, line, "period") }) - for (let x = leftWidth + 1; x < spineX; x++) setCell(grid, x, y, "─", "spine") + drawJoin(grid, leftWidth, y, TIMELINE_PERIOD_FADE_STYLES) let eventY = y for (const lines of layout.eventLines) { - lines.forEach((line, index) => setText(grid, spineX + 3, eventY + index, line, "event")) + lines.forEach((line, index) => setText(grid, spineX + EVENT_OFFSET, eventY + index, line, "event")) eventY += lines.length } y += layout.height + 1 @@ -83,6 +96,10 @@ export function drawTimelineDiagramGrid( return grid } +function drawJoin(grid: TimelineGrid, x: number, y: number, styles: readonly TimelineCellStyle[]): void { + styles.forEach((style, index) => setCell(grid, x + index + 1, y, "─", style)) +} + function setCell(grid: TimelineGrid, x: number, y: number, char: string, style: TimelineCellStyle): void { grid.setCell(x, y, char, style) } diff --git a/packages/merman/src/timeline/style.ts b/packages/merman/src/timeline/style.ts index 439f24adf5f6..bea9c14318c5 100644 --- a/packages/merman/src/timeline/style.ts +++ b/packages/merman/src/timeline/style.ts @@ -1,6 +1,6 @@ import { RGBA } from "@opentui/core" -import { rgba, type DiagramRgb } from "../core/color/style.js" -import type { TimelineCellStyle } from "./types.js" +import { blendColor, numberedStyleKeys, rgba, type DiagramRgb } from "../core/color/style.js" +import type { TimelineBaseCellStyle, TimelineCellStyle } from "./types.js" const DEFAULT_THEME_RGB = { title: [228, 239, 232], @@ -8,18 +8,29 @@ const DEFAULT_THEME_RGB = { period: [230, 177, 126], spine: [111, 138, 126], event: [134, 225, 200], -} as const satisfies Record +} as const satisfies Record export type TimelineStyleColors = Required> +export const TIMELINE_SECTION_FADE_STYLES = numberedStyleKeys("sectionFade", [1, 2, 3] as const) +export const TIMELINE_PERIOD_FADE_STYLES = numberedStyleKeys("periodFade", [1, 2, 3] as const) export function resolveTimelineStyleColors( - colors: Partial> = {}, + colors: Partial> = {}, ): TimelineStyleColors { + const section = colors.section ?? rgba(DEFAULT_THEME_RGB.section) + const period = colors.period ?? rgba(DEFAULT_THEME_RGB.period) + const spine = colors.spine ?? rgba(DEFAULT_THEME_RGB.spine) return { title: colors.title ?? rgba(DEFAULT_THEME_RGB.title), - section: colors.section ?? rgba(DEFAULT_THEME_RGB.section), - period: colors.period ?? rgba(DEFAULT_THEME_RGB.period), - spine: colors.spine ?? rgba(DEFAULT_THEME_RGB.spine), + section, + period, + spine, event: colors.event ?? rgba(DEFAULT_THEME_RGB.event), + sectionFade1: blendColor(section, spine, 0.5), + sectionFade2: blendColor(section, spine, 0.67), + sectionFade3: blendColor(section, spine, 0.83), + periodFade1: blendColor(period, spine, 0.5), + periodFade2: blendColor(period, spine, 0.67), + periodFade3: blendColor(period, spine, 0.83), } } diff --git a/packages/merman/src/timeline/types.ts b/packages/merman/src/timeline/types.ts index dd3a766d9a85..fc7c3ab108b9 100644 --- a/packages/merman/src/timeline/types.ts +++ b/packages/merman/src/timeline/types.ts @@ -24,4 +24,8 @@ export interface TimelineDiagramRenderOptions { direction?: TimelineDirection } -export type TimelineCellStyle = "title" | "section" | "period" | "spine" | "event" +export type TimelineBaseCellStyle = "title" | "section" | "period" | "spine" | "event" +export type TimelineFadeStep = 1 | 2 | 3 +export type TimelineSectionFadeStyle = `sectionFade${TimelineFadeStep}` +export type TimelinePeriodFadeStyle = `periodFade${TimelineFadeStep}` +export type TimelineCellStyle = TimelineBaseCellStyle | TimelineSectionFadeStyle | TimelinePeriodFadeStyle