diff --git a/packages/merman/src/markdown.ts b/packages/merman/src/markdown.ts
index a525def955b..f380b7dec17 100644
--- a/packages/merman/src/markdown.ts
+++ b/packages/merman/src/markdown.ts
@@ -44,6 +44,10 @@ export interface MermaidMarkdownRendererOptions {
muted?: ColorInput
warning?: ColorInput
background?: ColorInput
+ request?: ColorInput
+ response?: ColorInput
+ note?: ColorInput
+ noteBackground?: ColorInput
}
}
@@ -130,13 +134,14 @@ function prepareDiagram(kind: DiagramKind, source: string, options: MermaidMarkd
resolveSequenceStyleColors({
participant: color(colors.primary),
lifeline: color(colors.muted),
+ lifelineEnd: color(colors.background),
group: color(colors.secondary),
- request: color(colors.primary),
- response: color(colors.primary),
+ request: color(colors.request ?? colors.primary),
+ response: color(colors.response ?? colors.primary),
fragment: color(colors.secondary),
fragmentLabelBg: color(colors.background),
- note: color(colors.warning),
- noteBg: color(colors.background),
+ note: color(colors.note ?? colors.warning),
+ noteBg: color(colors.noteBackground ?? colors.background),
}),
),
height: size.height,
diff --git a/packages/merman/src/palette.test.ts b/packages/merman/src/palette.test.ts
index 752bee9a6c9..5df1e0fc256 100644
--- a/packages/merman/src/palette.test.ts
+++ b/packages/merman/src/palette.test.ts
@@ -1,5 +1,6 @@
import { describe, expect, test } from "bun:test"
import { RGBA } from "@opentui/core"
+import { blendColor } from "./core/color/style.js"
import { createOpenCodeDiagramPalette } from "./palette.js"
type Rgb = readonly [number, number, number]
@@ -31,11 +32,15 @@ describe("OpenCode diagram palette", () => {
}>)("derives a controlled neutral ladder for a $name", ({ text, subdued, secondary, muted }) => {
const primary = rgb(text)
const info = RGBA.fromInts(40, 120, 220)
+ const success = RGBA.fromInts(80, 180, 120)
+ const warning = RGBA.fromInts(220, 160, 80)
const background = RGBA.fromInts(10, 20, 30)
const palette = createOpenCodeDiagramPalette({
text: primary,
subdued: rgb(subdued),
info,
+ success,
+ warning,
background,
})
@@ -45,5 +50,9 @@ describe("OpenCode diagram palette", () => {
expect(palette.muted.equals(rgb(muted))).toBe(true)
expect(palette.warning).toBe(info)
expect(palette.background).toBe(background)
+ expect(palette.request).toBe(success)
+ expect(palette.response).toBe(warning)
+ expect(palette.note).toBe(primary)
+ expect(palette.noteBackground.equals(blendColor(background, rgb(subdued), 0.25))).toBe(true)
})
})
diff --git a/packages/merman/src/palette.ts b/packages/merman/src/palette.ts
index ee1fc1a1410..5ea05e8ff25 100644
--- a/packages/merman/src/palette.ts
+++ b/packages/merman/src/palette.ts
@@ -5,6 +5,8 @@ export interface OpenCodeDiagramPaletteInput {
readonly text: RGBA
readonly subdued: RGBA
readonly info: RGBA
+ readonly success: RGBA
+ readonly warning: RGBA
readonly background: RGBA
}
@@ -16,5 +18,9 @@ export function createOpenCodeDiagramPalette(input: OpenCodeDiagramPaletteInput)
muted: blendColor(input.text, input.subdued, 0.7),
warning: input.info,
background: input.background,
+ request: input.success,
+ response: input.warning,
+ note: input.text,
+ noteBackground: blendColor(input.background, input.subdued, 0.25),
}
}
diff --git a/packages/merman/src/plugin.ts b/packages/merman/src/plugin.ts
index 2f4c7d02f6b..8ebb59c18d3 100644
--- a/packages/merman/src/plugin.ts
+++ b/packages/merman/src/plugin.ts
@@ -12,6 +12,8 @@ export default Plugin.define({
text: context.theme.text.default,
subdued: context.theme.text.subdued,
info: context.theme.text.feedback.info.default,
+ success: context.theme.text.feedback.success.default,
+ warning: context.theme.text.feedback.warning.default,
background: context.theme.background.default,
}),
})),
diff --git a/packages/merman/src/sequence/diagram.test.ts b/packages/merman/src/sequence/diagram.test.ts
index 099ebafb864..62a40c9106e 100644
--- a/packages/merman/src/sequence/diagram.test.ts
+++ b/packages/merman/src/sequence/diagram.test.ts
@@ -1,9 +1,11 @@
import { describe, expect, test } from "bun:test"
+import { RGBA } from "@opentui/core"
import { diagramTextWidth } from "../core/text.js"
import { expectDiagram } from "../test/diagram.js"
import { renderSequenceDiagram } from "./diagram.js"
import { drawSequenceDiagramGrid } from "./drawing.js"
import { parseMermaidSequenceDiagram } from "./parser.js"
+import { resolveSequenceStyleColors } from "./style.js"
describe("SequenceDiagram", () => {
test("parses Mermaid sequenceDiagram participants and messages", () => {
@@ -51,16 +53,19 @@ sequenceDiagram
`)
expectDiagram(output).toEqualDiagram(`
- ╭─────────╮ ╭────────╮
- │ Browser │ │ Server │
- ╰────┬────╯ ╰────┬───╯
- │ │
- │ GET / │
- ├─────────────────►
- │ │
- │ 401 WWW-Auth │
- ◄─────────────────┤
- │ │
+ Browser Server
+ ───┬─── ───┬──
+ │ │
+ │ GET / │
+ ├─────────────────►
+ │ │
+ │ 401 WWW-Auth │
+ ◄─────────────────┤
+ │ │
+ │ │
+ │ │
+ │ │
+ │ │
`)
})
@@ -93,6 +98,10 @@ sequenceDiagram
│ │ │
│ ├─ same target or reject ──►
│ │ │
+ │ │ │
+ │ │ │
+ │ │ │
+ │ │ │
`)
})
@@ -140,13 +149,13 @@ sequenceDiagram
`)
const lines = output.split("\n")
- const browserCenter = lines[1]!.indexOf("w")
- const serverCenter = lines[1]!.indexOf("v")
+ const browserCenter = lines[0]!.indexOf("w")
+ const serverCenter = lines[0]!.indexOf("v")
- expect(lines[2]?.[browserCenter]).toBe("┬")
- expect(lines[3]?.[browserCenter]).toBe("│")
- expect(lines[2]?.[serverCenter]).toBe("┬")
- expect(lines[3]?.[serverCenter]).toBe("│")
+ expect(lines[1]?.[browserCenter]).toBe("┬")
+ expect(lines[2]?.[browserCenter]).toBe("│")
+ expect(lines[1]?.[serverCenter]).toBe("┬")
+ expect(lines[2]?.[serverCenter]).toBe("│")
})
test("ramps participant frames into neutral lifelines", () => {
@@ -162,6 +171,27 @@ sequenceDiagram
expect(new Set(rampStyles)).toEqual(new Set(["lifelineRamp1", "lifelineRamp2", "lifelineRamp3"]))
})
+ test("fades the bottom of participant lifelines", () => {
+ const grid = drawSequenceDiagramGrid(
+ parseMermaidSequenceDiagram(
+ "sequenceDiagram\n participant Browser\n participant Server\n Browser->>Server: request",
+ ),
+ )
+ const fadeStyles = grid.rows
+ .flatMap((row) => row.map((cell) => cell.style))
+ .filter((style) => style?.startsWith("lifelineFade"))
+
+ expect(new Set(fadeStyles)).toEqual(
+ new Set(["lifelineFade1", "lifelineFade2", "lifelineFade3", "lifelineFade4", "lifelineFade5"]),
+ )
+
+ const lifeline = RGBA.fromInts(100, 120, 110)
+ const background = RGBA.fromInts(10, 20, 15)
+ const colors = resolveSequenceStyleColors({ lifeline, lifelineEnd: background })
+ expect(colors.lifelineFade1.equals(lifeline)).toBe(true)
+ expect(colors.lifelineFade5.equals(background)).toBe(true)
+ })
+
test("renders notes and long cross-participant messages in order", () => {
const output = renderSequenceDiagram(`
sequenceDiagram
@@ -277,8 +307,8 @@ sequenceDiagram
A->>B: hello`)
expect(output).not.toContain("
{
@@ -314,28 +344,31 @@ sequenceDiagram
`)
expect(output).toMatchInlineSnapshot(`
- "╭───╮ ╭───╮
- │ A │ │ B │
- ╰─┬─╯ ╰─┬─╯
- │ │
- │ open solid │
- ├─────────────────>│
- │ │
- │ open dashed │
- │<─────────────────┤
- │ │
- │ failed solid │
- ├─────────────────✕│
- │ │
- │ failed dashed │
- │✕─────────────────┤
- │ │
- │ async solid │
- ├─────────────────)│
- │ │
- │ async dashed │
- │(─────────────────┤
- │ │"
+ " A B
+ ─┬─ ─┬─
+ │ │
+ │ open solid │
+ ├─────────────────>│
+ │ │
+ │ open dashed │
+ │<─────────────────┤
+ │ │
+ │ failed solid │
+ ├─────────────────✕│
+ │ │
+ │ failed dashed │
+ │✕─────────────────┤
+ │ │
+ │ async solid │
+ ├─────────────────)│
+ │ │
+ │ async dashed │
+ │(─────────────────┤
+ │ │
+ │ │
+ │ │
+ │ │
+ │ │"
`)
})
@@ -387,7 +420,7 @@ sequenceDiagram
end
`)
const lines = output.split("\n")
- const participantCenter = lines.find((line) => line.includes("│ A │"))!.indexOf("A")
+ const participantCenter = lines.find((line) => line.includes(" A"))!.indexOf("A")
const fragmentStart = lines.find((line) => line.includes("alt: ok"))!.indexOf("╭")
expect(fragmentStart).toBeLessThan(participantCenter)
@@ -557,7 +590,7 @@ sequenceDiagram
const fragmentMessageRow = fragment.split("\n").find((line) => line.includes("this non adjacent message"))!
expect(groupMessageRow.trimEnd().endsWith("│")).toBe(true)
expect(fragmentMessageRow).toContain("this non adjacent message is deliberately much wider than the frame")
- expect(fragmentMessageRow.match(/│/g)?.length).toBe(2)
+ expect(fragmentMessageRow.match(/│/g)?.length).toBe(3)
})
test("keeps long notes inside groups and nested fragment frames intact", () => {
@@ -600,7 +633,7 @@ sequenceDiagram
const groupBorderRight = output.split("\n")[0]!.lastIndexOf("╮")
const lines = output.split("\n")
const externalLabelRow = lines.findIndex((line) => line.includes("External"))
- const externalHeaderLeft = lines[externalLabelRow - 1]!.lastIndexOf("╭")
+ const externalHeaderLeft = lines[externalLabelRow + 1]!.lastIndexOf("─")
expect(externalHeaderLeft).toBeGreaterThan(groupBorderRight)
})
@@ -655,18 +688,21 @@ sequenceDiagram
`)
expect(output).toMatchInlineSnapshot(`
- " ╭─ Backend ──────────────────────────────────╮
- ╭─────────╮ │ ╭─────╮ ╭───────╮ ╭────╮ │
- │ Browser │ │ │ API │ │ Cache │ │ DB │ │
- ╰────┬────╯ │ ╰──┬──╯ ╰───┬───╯ ╰──┬─╯ │
- │ │ │ │ │ │
- │ GET /users/42 │ │ │ │
- ├──────────────────► │ │ │
- │ │ │ │ │ │
- │ │ │ get user:42 │ │ │
- │ │ ├─────────────────► │ │
- │ │ │ │ │ │
- ╰────────────────────────────────────────────╯"
+ " ╭─ Backend ───────────────────────────────╮
+ Browser │ API Cache DB │
+ ───┬─── │ ─┬─ ──┬── ─┬─ │
+ │ │ │ │ │ │
+ │ GET /users/42 │ │ │ │ │
+ ├──────────────────► │ │ │
+ │ │ │ │ │ │
+ │ │ │ get user:42 │ │ │
+ │ │ ├─────────────────► │ │
+ │ │ │ │ │ │
+ │ │ │ │ │ │
+ │ │ │ │ │ │
+ │ │ │ │ │ │
+ │ │ │ │ │ │
+ ╰─────────────────────────────────────────╯"
`)
})
@@ -706,18 +742,21 @@ sequenceDiagram
`)
expect(output).toMatchInlineSnapshot(`
- "╭─────────╮
- │ Service │
- ╰────┬────╯
- │
- ├────────────────────╮
- │ Check Permissions │
- ◄────────────────────╯
- │"
+ "Service
+ ───┬───
+ │
+ ├────────────────────╮
+ │ Check Permissions │
+ ◄────────────────────╯
+ │
+ │
+ │
+ │
+ │"
`)
})
- test("frames notes in their reserved rows", () => {
+ test("renders note badges in their reserved rows", () => {
const output = renderSequenceDiagram(`
sequenceDiagram
Browser->>Server: one
@@ -729,11 +768,9 @@ sequenceDiagram
const nextMessageRow = lines.findIndex((line) => line.includes("two"))
expect(noteRow).toBeGreaterThan(0)
- expect(lines[noteRow - 1]).toContain("╭")
- expect(lines[noteRow - 1]).toContain("╮")
- expect(lines[noteRow]).toContain("│ phase │")
- expect(lines[noteRow + 1]).toContain("╰")
- expect(lines[noteRow + 1]).toContain("╯")
+ expect(lines[noteRow - 1]?.trim()).toBe("│ │")
+ expect(lines[noteRow]).toContain(" phase ")
+ expect(lines[noteRow + 1]?.trim()).toBe("│ │")
expect(nextMessageRow).toBe(noteRow + 2)
})
diff --git a/packages/merman/src/sequence/drawing.ts b/packages/merman/src/sequence/drawing.ts
index 2c0422be5fc..372610b3251 100644
--- a/packages/merman/src/sequence/drawing.ts
+++ b/packages/merman/src/sequence/drawing.ts
@@ -1,7 +1,7 @@
import { BorderChars, type BorderStyle } from "@opentui/core"
import { DiagramCanvas } from "../core/canvas.js"
import { diagramTextWidth } from "../core/text.js"
-import { DEFAULT_FRAGMENT_BORDER_STYLE } from "./options.js"
+import { DEFAULT_FRAGMENT_BORDER_STYLE, SEQUENCE_LIFELINE_FADE_STEPS } from "./options.js"
import {
createSequencePlacementPlan,
type SequenceGroupPlacement,
@@ -191,29 +191,9 @@ function renderSelfMessage(
}
function renderNote(grid: SequenceGrid, placement: Extract): void {
- const width = Math.max(...placement.textLines.map(diagramTextWidth))
- const left = placement.textX
- const right = left + width - 1
- const top = placement.textY - 1
- const bottom = placement.textY + placement.textLines.length
-
- for (let x = left + 1; x < right; x++) {
- setCell(grid, x, top, SEQUENCE_BORDER.horizontal, "note")
- setCell(grid, x, bottom, SEQUENCE_BORDER.horizontal, "note")
- }
- for (let y = top + 1; y < bottom; y++) {
- setCell(grid, left, y, SEQUENCE_BORDER.vertical, "note")
- setCell(grid, right, y, SEQUENCE_BORDER.vertical, "note")
- }
- setCell(grid, left, top, SEQUENCE_BORDER.topLeft, "note")
- setCell(grid, right, top, SEQUENCE_BORDER.topRight, "note")
- setCell(grid, left, bottom, SEQUENCE_BORDER.bottomLeft, "note")
- setCell(grid, right, bottom, SEQUENCE_BORDER.bottomRight, "note")
- placement.textLines.forEach((line, index) => setText(grid, left, placement.textY + index, line, "noteBadge"))
- for (let y = placement.textY; y < bottom; y++) {
- setCell(grid, left, y, SEQUENCE_BORDER.vertical, "note")
- setCell(grid, right, y, SEQUENCE_BORDER.vertical, "note")
- }
+ placement.textLines.forEach((line, index) =>
+ setText(grid, placement.textX, placement.textY + index, line, "noteBadge"),
+ )
}
export function drawSequenceDiagramGrid(
@@ -236,28 +216,24 @@ export function drawSequenceDiagramGrid(
setText(grid, centeredStart(center, line), participantHeaderY + index, line, "participant"),
)
} else {
+ labelLines.forEach((line, index) =>
+ setText(grid, centeredStart(center, line), participantHeaderTopY + index, line, "participant"),
+ )
for (let x = headerLeftX; x <= headerRightX; x++) {
- setCell(grid, x, participantHeaderTopY, SEQUENCE_BORDER.horizontal, "participant")
setCell(grid, x, participantRuleY, SEQUENCE_BORDER.horizontal, "participant")
}
-
- setCell(grid, headerLeftX, participantHeaderTopY, SEQUENCE_BORDER.topLeft, "participant")
- setCell(grid, headerRightX, participantHeaderTopY, SEQUENCE_BORDER.topRight, "participant")
- for (let y = participantHeaderY; y < participantRuleY; y++) {
- setCell(grid, headerLeftX, y, SEQUENCE_BORDER.vertical, "participant")
- setCell(grid, headerRightX, y, SEQUENCE_BORDER.vertical, "participant")
- }
- setCell(grid, headerLeftX, participantRuleY, SEQUENCE_BORDER.bottomLeft, "participant")
- setCell(grid, headerRightX, participantRuleY, SEQUENCE_BORDER.bottomRight, "participant")
- labelLines.forEach((line, index) =>
- setText(grid, centeredStart(center, line), participantHeaderY + index, line, "participant"),
- )
setCell(grid, center, participantRuleY, SEQUENCE_BORDER.topT, "participant")
}
for (let y = lifelineStartY; y <= lifelineEndY; y++) {
const distance = y - lifelineStartY
- const style = !options.compact && distance < 3 ? (`lifelineRamp${distance + 1}` as SequenceCellStyle) : "lifeline"
+ const fadeDistance = y - (lifelineEndY - SEQUENCE_LIFELINE_FADE_STEPS.length + 1)
+ const style =
+ fadeDistance >= 0
+ ? (`lifelineFade${fadeDistance + 1}` as SequenceCellStyle)
+ : !options.compact && distance < 3
+ ? (`lifelineRamp${distance + 1}` as SequenceCellStyle)
+ : "lifeline"
setCell(grid, center, y, SEQUENCE_BORDER.vertical, style)
}
}
diff --git a/packages/merman/src/sequence/options.ts b/packages/merman/src/sequence/options.ts
index 05a0fe88468..f20071cd8fd 100644
--- a/packages/merman/src/sequence/options.ts
+++ b/packages/merman/src/sequence/options.ts
@@ -2,6 +2,7 @@ import type { BorderStyle } from "@opentui/core"
export const DEFAULT_MIN_PARTICIPANT_GAP = 18
export const DEFAULT_FRAGMENT_BORDER_STYLE = "rounded" satisfies BorderStyle
+export const SEQUENCE_LIFELINE_FADE_STEPS = [1, 2, 3, 4, 5] as const
export function normalizeSequenceMinParticipantGap(value: number | undefined): number {
return value === undefined || !Number.isFinite(value) ? DEFAULT_MIN_PARTICIPANT_GAP : Math.max(1, Math.floor(value))
diff --git a/packages/merman/src/sequence/placement.test.ts b/packages/merman/src/sequence/placement.test.ts
index c96e6cf4f0e..effb40fdb4e 100644
--- a/packages/merman/src/sequence/placement.test.ts
+++ b/packages/merman/src/sequence/placement.test.ts
@@ -206,7 +206,7 @@ ${Array.from(
expect(explicit.activations).toEqual(shorthand.activations)
})
- test("centers message label blocks over their arrow span", () => {
+ test("left-aligns message label blocks inside their arrow span", () => {
const plan = createSequencePlacementPlan(
parseMermaidSequenceDiagram(`sequenceDiagram
participant A
@@ -214,8 +214,6 @@ ${Array.from(
A->>B: short
a much longer line`),
)
const message = plan.steps.find((step) => step.type === "message")!
- const labelWidth = Math.max(...message.labelLines.map(diagramTextWidth))
-
- expect(message.labelX * 2 + labelWidth).toBe(message.leftX + message.rightX)
+ expect(message.labelX).toBe(message.leftX + 2)
})
})
diff --git a/packages/merman/src/sequence/placement.ts b/packages/merman/src/sequence/placement.ts
index 17433117970..22cf14b4546 100644
--- a/packages/merman/src/sequence/placement.ts
+++ b/packages/merman/src/sequence/placement.ts
@@ -1,5 +1,5 @@
import { diagramTextWidth } from "../core/text.js"
-import { normalizeSequenceMinParticipantGap } from "./options.js"
+import { normalizeSequenceMinParticipantGap, SEQUENCE_LIFELINE_FADE_STEPS } from "./options.js"
import type {
SequenceDiagram,
SequenceDiagramRenderOptions,
@@ -142,7 +142,7 @@ function messageLabelText(message: SequenceMessage): string {
function participantHeaderWidth(label: string, compact: boolean): number {
const width = labelLinesWidth(mermaidLabelLines(label))
- return compact ? width : Math.max(5, width + 4)
+ return compact ? width : Math.max(3, width)
}
function fragmentLabelText(fragment: SequenceFragment): string {
@@ -247,7 +247,7 @@ function getStepContentBounds(
const leftX = Math.min(fromX, toX)
const rightX = Math.max(fromX, toX)
const labelWidth = messageWidth(step.message)
- const labelLeftX = Math.floor((leftX + rightX - labelWidth) / 2)
+ const labelLeftX = leftX + 2
return { leftX: Math.min(leftX, labelLeftX), rightX: Math.max(rightX, labelLeftX + labelWidth - 1) }
}
if (step.type !== "note") return undefined
@@ -525,13 +525,16 @@ export function createSequencePlacementPlan(
...diagram.participants.map((participant) => mermaidLabelLines(participant.label).length),
)
const participantHeaderTopY = hasGroups ? 1 : 0
- const participantHeaderY = participantHeaderTopY + (compact ? 0 : 1)
- const participantRuleY = participantHeaderTopY + (compact ? participantLabelHeight - 1 : participantLabelHeight + 1)
+ const participantHeaderY = participantHeaderTopY
+ const participantRuleY = participantHeaderTopY + (compact ? participantLabelHeight - 1 : participantLabelHeight)
const lifelineStartY = participantRuleY + 1
const stepStartY = lifelineStartY + 1
const width = Math.max(contentBounds.rightX + 1, ...groups.map((group) => group.rightX + 1), fragments.rightX + 1)
const baseHeight =
- stepStartY + diagram.steps.reduce((total, step) => total + getStepHeight(step, centers, indexes, compact), 0)
+ stepStartY +
+ diagram.steps.reduce((total, step) => total + getStepHeight(step, centers, indexes, compact), 0) +
+ SEQUENCE_LIFELINE_FADE_STEPS.length -
+ 1
const height = hasGroups ? Math.max(5, baseHeight + 1) : Math.max(3, baseHeight)
const lifelineEndY = hasGroups ? height - 2 : height - 1
const participants = diagram.participants.map((participant, index) => {
@@ -650,7 +653,7 @@ export function createSequencePlacementPlan(
const inlineLabel = inlineMessageLabel(step.message, labelLines, fromX, toX, compact)
const arrowY = inlineLabel ? stepY : stepY + labelLines.length
const renderedLabelWidth = inlineLabel ? visualLength(inlineLabel) : labelLinesWidth(labelLines)
- const labelX = Math.floor((leftX + rightX - renderedLabelWidth) / 2)
+ const labelX = inlineLabel ? Math.floor((leftX + rightX - renderedLabelWidth) / 2) : leftX + 2
steps.push({
type: "message",
message: step.message,
diff --git a/packages/merman/src/sequence/style.ts b/packages/merman/src/sequence/style.ts
index 382c9d1c63e..9a019f56e09 100644
--- a/packages/merman/src/sequence/style.ts
+++ b/packages/merman/src/sequence/style.ts
@@ -1,16 +1,19 @@
import { RGBA } from "@opentui/core"
import {
+ blendColor,
createColorRampTheme,
DIAGRAM_FADE_STEPS,
numberedStyleKeys,
rgba,
type DiagramRgb,
} from "../core/color/style.js"
-import type { FadeStyle, LifelineRampStyle, SequenceCellStyle } from "./types.js"
+import { SEQUENCE_LIFELINE_FADE_STEPS } from "./options.js"
+import type { FadeStyle, LifelineFadeStyle, LifelineRampStyle, SequenceCellStyle } from "./types.js"
export interface SequenceStyleColors {
participant?: RGBA
lifeline?: RGBA
+ lifelineEnd?: RGBA
group?: RGBA
request?: RGBA
response?: RGBA
@@ -30,6 +33,7 @@ const LIFELINE_RAMP_STYLES = [
const DEFAULT_THEME_RGB = {
participant: [228, 239, 232],
lifeline: [111, 138, 126],
+ lifelineEnd: [15, 23, 19],
group: [76, 99, 89],
request: [134, 225, 200],
response: [230, 177, 126],
@@ -41,14 +45,22 @@ const DEFAULT_THEME_RGB = {
export function resolveSequenceStyleColors(
colors: SequenceStyleColors = {},
-): Required & Record {
+): Required & Record {
const participant = colors.participant ?? rgba(DEFAULT_THEME_RGB.participant)
const lifeline = colors.lifeline ?? rgba(DEFAULT_THEME_RGB.lifeline)
const request = colors.request ?? rgba(DEFAULT_THEME_RGB.request)
const response = colors.response ?? rgba(DEFAULT_THEME_RGB.response)
+ const lifelineEnd = colors.lifelineEnd ?? rgba(DEFAULT_THEME_RGB.lifelineEnd)
+ const lifelineFade = Object.fromEntries(
+ SEQUENCE_LIFELINE_FADE_STEPS.map((step, index) => [
+ `lifelineFade${step}`,
+ blendColor(lifeline, lifelineEnd, index / (SEQUENCE_LIFELINE_FADE_STEPS.length - 1)),
+ ]),
+ ) as Record
return {
participant,
lifeline,
+ lifelineEnd,
group: colors.group ?? rgba(DEFAULT_THEME_RGB.group),
request,
response,
@@ -59,12 +71,13 @@ export function resolveSequenceStyleColors(
...createColorRampTheme(numberedStyleKeys("requestFade", SEQUENCE_FADE_STEPS), lifeline, request),
...createColorRampTheme(numberedStyleKeys("responseFade", SEQUENCE_FADE_STEPS), lifeline, response),
...createColorRampTheme(LIFELINE_RAMP_STYLES, participant, lifeline),
+ ...lifelineFade,
}
}
export function sequenceStyleColor(
style: SequenceCellStyle | undefined,
- colors: Required & Record,
+ colors: Required & Record,
): RGBA | undefined {
if (style === "noteBadge") return colors.note
if (style === "fragmentLabel") return colors.fragment
diff --git a/packages/merman/src/sequence/types.ts b/packages/merman/src/sequence/types.ts
index ad8cb39fc42..c8ccf1e955c 100644
--- a/packages/merman/src/sequence/types.ts
+++ b/packages/merman/src/sequence/types.ts
@@ -61,6 +61,7 @@ export interface SequenceDiagramRenderOptions {
export type MessageStyle = "request" | "response"
export type FadeStyle = `${MessageStyle}Fade${1 | 2 | 3 | 4 | 5}`
export type LifelineRampStyle = `lifelineRamp${1 | 2 | 3}`
+export type LifelineFadeStyle = `lifelineFade${1 | 2 | 3 | 4 | 5}`
export type SequenceCellStyle =
| "participant"
| "lifeline"
@@ -68,6 +69,7 @@ export type SequenceCellStyle =
| MessageStyle
| FadeStyle
| LifelineRampStyle
+ | LifelineFadeStyle
| "fragment"
| "fragmentLabel"
| "note"
diff --git a/scripts/drive-mermaid-visual.ts b/scripts/drive-mermaid-visual.ts
new file mode 100644
index 00000000000..5510396704f
--- /dev/null
+++ b/scripts/drive-mermaid-visual.ts
@@ -0,0 +1,75 @@
+import { mkdir } from "node:fs/promises"
+import { defineScript, Effect, Llm } from "opencode-drive"
+
+const theme = Bun.env.DRIVE_THEME ?? "opencode"
+const output = Bun.env.DRIVE_SCREENSHOT ?? `artifacts/mermaid-${theme}.png`
+const animate = Bun.env.DRIVE_ANIMATE === "1"
+const cycleThemes = Bun.env.DRIVE_CYCLE_THEMES === "1"
+
+const response = `\`\`\`mermaid
+sequenceDiagram
+ participant B as Browser
+ participant S as Server
+ participant T as Ticket store
+ participant P as PTY
+ B->>S: GET /
+ S-->>B: 401 WWW-Auth
+ Note over B,S: native browser Basic prompt
+ B->>S: GET / · Basic
+ S-->>B: 200 web UI
+ Note over B,S: user opens terminal
+ B->>S: POST connect-token
· Basic (cached by browser)
· X-OpenCode-Ticket: 1
+ S->>T: issue { ptyID, … }
+ S-->>B: { ticket }
+ B->>S: WS …?ticket=…
Upgrade: websocket
+ S->>T: consume(token,scope)
+ T-->>S: ok, delete
+ S->>P: attach
+ P-->>B: WS frames
+\`\`\``
+
+export default defineScript({
+ config: {
+ autoupdate: false,
+ },
+ tuiConfig: {
+ theme: {
+ name: theme,
+ mode: "dark",
+ },
+ },
+ tui: {
+ viewport: { cols: 180, rows: 64 },
+ },
+ run: ({ ui, llm }) =>
+ Effect.gen(function* () {
+ yield* ui.submit("Show the connection flow as a Mermaid sequence diagram")
+ yield* llm.send(
+ Llm.text(response, animate ? { delay: 80, chunkSize: 20 } : { delay: 0, chunkSize: response.length }),
+ )
+ yield* ui.waitFor("WS frames", { timeout: 10_000 })
+ if (cycleThemes) {
+ yield* Effect.sleep(800)
+ yield* Effect.forEach(
+ ["everforest", "synthwave84", "matrix", "opencode"],
+ (next) =>
+ Effect.gen(function* () {
+ yield* ui.press("x", { ctrl: true })
+ yield* ui.press("t")
+ yield* ui.waitFor("Themes")
+ yield* ui.type(next)
+ yield* Effect.sleep(700)
+ yield* ui.enter()
+ yield* Effect.sleep(1_200)
+ }),
+ { discard: true },
+ )
+ }
+ const screenshot = yield* ui.screenshot(`mermaid-${theme}`)
+ yield* Effect.promise(async () => {
+ await mkdir("artifacts", { recursive: true })
+ await Bun.write(output, Bun.file(screenshot))
+ })
+ yield* Effect.log(`Saved ${output}`)
+ }),
+})