From 2580f880a8f99a33ef6b614cf0509e3d77a85b0a Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Mon, 10 Aug 2026 19:12:46 -0400 Subject: [PATCH] feat(merman): refine sequence diagram styling (#41617) --- packages/merman/src/markdown.ts | 13 +- packages/merman/src/palette.test.ts | 9 + packages/merman/src/palette.ts | 6 + packages/merman/src/plugin.ts | 2 + packages/merman/src/sequence/diagram.test.ts | 175 +++++++++++------- packages/merman/src/sequence/drawing.ts | 52 ++---- packages/merman/src/sequence/options.ts | 1 + .../merman/src/sequence/placement.test.ts | 6 +- packages/merman/src/sequence/placement.ts | 17 +- packages/merman/src/sequence/style.ts | 19 +- packages/merman/src/sequence/types.ts | 2 + scripts/drive-mermaid-visual.ts | 75 ++++++++ 12 files changed, 252 insertions(+), 125 deletions(-) create mode 100644 scripts/drive-mermaid-visual.ts 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}`) + }), +})