Explorar el Código

feat(tui): render Mermaid timelines (#42130)

Kit Langton hace 4 días
padre
commit
d31a994c27

+ 2 - 0
packages/merman/src/detect.ts

@@ -2,10 +2,12 @@ import type { MermaidDiagramKind } from "./diagnostics.js"
 import { isMermaidFlowchartDiagram } from "./flowchart/parser.js"
 import { isMermaidFlowchartDiagram } from "./flowchart/parser.js"
 import { isMermaidSequenceDiagram } from "./sequence/parser.js"
 import { isMermaidSequenceDiagram } from "./sequence/parser.js"
 import { isMermaidStateDiagram } from "./state/parser.js"
 import { isMermaidStateDiagram } from "./state/parser.js"
+import { isMermaidTimelineDiagram } from "./timeline/parser.js"
 
 
 export function detectMermaidDiagram(content: string): MermaidDiagramKind | undefined {
 export function detectMermaidDiagram(content: string): MermaidDiagramKind | undefined {
   if (isMermaidFlowchartDiagram(content)) return "flowchart"
   if (isMermaidFlowchartDiagram(content)) return "flowchart"
   if (isMermaidSequenceDiagram(content)) return "sequence"
   if (isMermaidSequenceDiagram(content)) return "sequence"
   if (isMermaidStateDiagram(content)) return "state"
   if (isMermaidStateDiagram(content)) return "state"
+  if (isMermaidTimelineDiagram(content)) return "timeline"
   return undefined
   return undefined
 }
 }

+ 1 - 1
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. */
 /** An otherwise valid diagram contains syntax that this renderer does not support. */
 export class MermaidSyntaxError extends Error {
 export class MermaidSyntaxError extends Error {

+ 23 - 0
packages/merman/src/markdown.ts

@@ -25,6 +25,10 @@ import { drawStateDiagramGrid } from "./state/drawing.js"
 import { parseMermaidStateDiagram } from "./state/parser.js"
 import { parseMermaidStateDiagram } from "./state/parser.js"
 import { renderStateGridStyledText } from "./state/render-grid.js"
 import { renderStateGridStyledText } from "./state/render-grid.js"
 import { resolveStateStyleColors } from "./state/style.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<ReturnType<typeof detectMermaidDiagram>>
 type DiagramKind = NonNullable<ReturnType<typeof detectMermaidDiagram>>
 
 
@@ -180,6 +184,25 @@ function prepareDiagram(
         height: size.height,
         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,
+      }
+    }
   }
   }
 }
 }
 
 

+ 7 - 0
packages/merman/src/test/diagnostics.test.ts

@@ -3,6 +3,7 @@ import { MermaidSyntaxError } from "../diagnostics.js"
 import { parseMermaidFlowchartDiagram } from "../flowchart/parser.js"
 import { parseMermaidFlowchartDiagram } from "../flowchart/parser.js"
 import { parseMermaidSequenceDiagram } from "../sequence/parser.js"
 import { parseMermaidSequenceDiagram } from "../sequence/parser.js"
 import { parseMermaidStateDiagram } from "../state/parser.js"
 import { parseMermaidStateDiagram } from "../state/parser.js"
+import { renderTimelineDiagram } from "../timeline/diagram.js"
 import { renderSequenceDiagram } from "../sequence/diagram.js"
 import { renderSequenceDiagram } from "../sequence/diagram.js"
 
 
 describe("parser diagnostics", () => {
 describe("parser diagnostics", () => {
@@ -104,6 +105,12 @@ describe("parser diagnostics", () => {
     ).toThrow('Unexpected "end" without an open block in sequence diagram at line 2: "end"')
     ).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", () => {
   test("does not attach else through an unclosed nested sequence block", () => {
     expect(() =>
     expect(() =>
       parseMermaidSequenceDiagram(`sequenceDiagram
       parseMermaidSequenceDiagram(`sequenceDiagram

+ 28 - 0
packages/merman/src/test/markdown.test.ts

@@ -333,3 +333,31 @@ stateDiagram-v2
   expect(frame).toContain("Idle")
   expect(frame).toContain("Idle")
   expect(frame).not.toContain("stateDiagram-v2")
   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")
+})

+ 188 - 0
packages/merman/src/timeline/diagram.test.ts

@@ -0,0 +1,188 @@
+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 &amp;<br/>Platform
+
+  section Foundation
+  2024 : Prototype : First release
+       : Public beta
+  section Growth
+  2025 : "Scale: &#x2265; 10k"
+`)
+
+    expect(diagram.direction).toBe("LR")
+    expect(diagram.title).toBe("Product &<br/>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 &amp;<br/>Platform
+  section Foundation<br/>phase
+  2024 : Prototype<br/>ready : First release
+       : Scale &#x2265; 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",
+        "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",
+    ])
+  })
+})

+ 8 - 0
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))
+}

+ 109 - 0
packages/merman/src/timeline/drawing.ts

@@ -0,0 +1,109 @@
+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 {
+  period: TimelinePeriod
+  periodLines: string[]
+  eventLines: string[][]
+  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 = {},
+): TimelineGrid {
+  const periodLayouts = new Map<TimelinePeriod, PeriodLayout>()
+  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
+      for (const line of lines) leftWidth = Math.max(leftWidth, diagramTextWidth(line))
+      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 })
+    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 + 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)
+
+  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 + 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")
+        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")
+    })
+    drawJoin(grid, leftWidth, y, TIMELINE_PERIOD_FADE_STYLES)
+
+    let eventY = y
+    for (const lines of layout.eventLines) {
+      lines.forEach((line, index) => setText(grid, spineX + EVENT_OFFSET, eventY + index, line, "event"))
+      eventY += lines.length
+    }
+    y += layout.height + 1
+  }
+  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)
+}
+
+function setText(grid: TimelineGrid, x: number, y: number, text: string, style: TimelineCellStyle): void {
+  grid.setText(x, y, text, style)
+}

+ 119 - 0
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)
+}

+ 17 - 0
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<TimelineCellStyle>
+
+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,
+  })
+}

+ 36 - 0
packages/merman/src/timeline/style.ts

@@ -0,0 +1,36 @@
+import { RGBA } from "@opentui/core"
+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],
+  section: [154, 184, 169],
+  period: [230, 177, 126],
+  spine: [111, 138, 126],
+  event: [134, 225, 200],
+} as const satisfies Record<TimelineBaseCellStyle, DiagramRgb>
+
+export type TimelineStyleColors = Required<Record<TimelineCellStyle, RGBA>>
+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<Record<TimelineBaseCellStyle, RGBA | undefined>> = {},
+): 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,
+    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),
+  }
+}

+ 31 - 0
packages/merman/src/timeline/types.ts

@@ -0,0 +1,31 @@
+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 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