Browse Source

feat(merman): refine sequence diagram styling (#41617)

Kit Langton 5 days ago
parent
commit
2580f880a8

+ 9 - 4
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,

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

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

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

+ 106 - 69
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("<br")
-    expect(output).toContain("First line")
-    expect(output).toContain("Second line")
+    expect(output).toContain("First line")
+    expect(output).toContain("Second line")
   })
 
   test("parses Mermaid arrow head variants", () => {
@@ -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)
   })
 

+ 14 - 38
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<SequenceStepPlacement, { type: "note" }>): 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)
     }
   }

+ 1 - 0
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))

+ 2 - 4
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<br/>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)
   })
 })

+ 10 - 7
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,

+ 16 - 3
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<SequenceStyleColors> & Record<FadeStyle | LifelineRampStyle, RGBA> {
+): Required<SequenceStyleColors> & Record<FadeStyle | LifelineFadeStyle | LifelineRampStyle, RGBA> {
   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<LifelineFadeStyle, RGBA>
   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<SequenceStyleColors> & Record<FadeStyle | LifelineRampStyle, RGBA>,
+  colors: Required<SequenceStyleColors> & Record<FadeStyle | LifelineFadeStyle | LifelineRampStyle, RGBA>,
 ): RGBA | undefined {
   if (style === "noteBadge") return colors.note
   if (style === "fragmentLabel") return colors.fragment

+ 2 - 0
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"

+ 75 - 0
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<br/>· Basic (cached by browser)<br/>· X-OpenCode-Ticket: 1
+  S->>T: issue { ptyID, … }
+  S-->>B: { ticket }
+  B->>S: WS …?ticket=…<br/>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}`)
+    }),
+})