Skip to content

Draw Mermaid diagrams in the pad, history and /editor #528

Closed

Description

@HMarzban

Part of #521. Depends on #527. Read the tracker first: its §Shared contract binds this issue. Work on main.

Summary

Wire the package into the docs.plus pad, history and /editor, never into chat. The Files list names every touched file.

Rulings: 1, 2, 3, 4, 5, 10, 13 (#521 §Maintainer rulings).

Current behaviour

  • TipTap.tsx registers CodeBlockLowlight in baseExtensions, which the pad, history and /editor share; chat builds its own list (read from code).
  • The desktop Code select keys rows by value and checks isActive(item.value), so two codeBlock rows would collide (read from ToolbarSelect.tsx).
  • next.config.js buildExcludes has two entries, so next-pwa would precache every Pele chunk (read from code).

Files and interfaces

Files

  • Modify: apps/webapp/package.json, bun.lock (through bun install), apps/webapp/src/utils/sanitizeContent.ts, apps/webapp/src/components/icons/registry.ts, apps/webapp/src/components/TipTap/TipTap.tsx, apps/webapp/src/components/TipTap/slash/slashItems.ts, apps/webapp/src/components/TipTap/toolbar/ToolbarSelect.tsx, apps/webapp/src/components/TipTap/toolbar/desktop/EditorToolbar.tsx, apps/webapp/src/components/TipTap/placeholders/buildBreadcrumbPlaceholder.ts, apps/webapp/src/styles/editor-extensions.scss, apps/webapp/src/styles/editor/_index.scss, apps/webapp/src/styles/_print.scss, apps/webapp/src/components/toc/TocTickRail.tsx, apps/webapp/next.config.js, apps/hocuspocus.server/src/modules/mcp/http/serverFactory.ts, CONTEXT.md, .cursor/docs/design-system.md, docs/README.md.
  • Create: apps/webapp/src/styles/editor/_mermaid.scss, docs/diagrams.md (R9).
  • Gitignored evidence: REPO/Notes/local-docs/mermaid-measure/d6/.

Interfaces

  • Consumes: Mermaid and type MermaidStateEvent from the built package; insertMermaid through the Commands augmentation; the CSS contract; Pele's svg.pele, .pele-zoom, .pele-zoom-in, .pele-zoom-out and .pele-zoom-reset.

The build:ci command (used twice). Run it and its step's follow-up lines as one Bash call, because shell state does not persist between calls:

cd REPO/apps/webapp && E=REPO/.env.local && D=REPO/Notes/local-docs/mermaid-measure/d6
rm -rf .next && rm -f public/sw.js public/workbox-*.js public/worker-*.js
env -u NODE_ENV -u NEXT_PUBLIC_E2E \
  NEXT_PUBLIC_SUPABASE_URL="$(grep -E '^NEXT_PUBLIC_SUPABASE_URL=' "$E" | cut -d= -f2- | tr -d '"')" \
  NEXT_PUBLIC_SUPABASE_ANON_KEY="$(grep -E '^NEXT_PUBLIC_SUPABASE_ANON_KEY=' "$E" | cut -d= -f2- | tr -d '"')" \
  NEXT_PUBLIC_RESTAPI_URL="$(grep -E '^NEXT_PUBLIC_RESTAPI_URL=' "$E" | cut -d= -f2- | tr -d '"')" \
  bun run build:ci

Never source the whole .env.local: its NODE_ENV breaks the /404 prerender. Never run it while next dev is live.

Interfaces

Webapp names

// apps/webapp/src/utils/sanitizeContent.ts
export const sanitizeDiagramSvg = (svg: string): Node =>
  DOMPurify.sanitize(svg, { USE_PROFILES: { svg: true }, ADD_ATTR: ['role'], RETURN_DOM_FRAGMENT: true })

// apps/webapp/src/components/TipTap/toolbar/ToolbarSelect.tsx
export interface ToolbarSelectItem { value: string; attrs?: Record<string, unknown>; label: string; icon: IconType; action: () => void; testId: string }
// rows and trigger use editor.isActive(item.value, item.attrs); key={item.testId}
  • Icon: diagram: LuWorkflow in the registry.
  • Slash item: { id: 'diagram', label: 'Diagram', icon: Icons.diagram, aliases: ['mermaid','flowchart','chart','sequence','gantt','mindmap'], can: (editor) => editor.can().insertMermaid(), run: (editor) => editor.chain().focus().insertMermaid().run() }.
  • Code select rows: Code block gets attrs: { language: /^(?!mermaid$)/ } (its action is in Step 8). Diagram is the last row: { value: 'codeBlock', attrs: { language: 'mermaid' }, label: 'Diagram', icon: Icons.diagram, action: () => editor.chain().focus().insertMermaid().run(), testId: 'toolbar-diagram' }.
  • TipTap.tsx: lowlight.register('mermaid', () => ({ name: 'Mermaid', contains: [] })) and Mermaid.configure({ sanitize: sanitizeDiagramSvg, onStateChange: reportDiagramState }). Module-local reportDiagramState(event) calls captureMessageOnce('mermaid:slow-render', 'mermaid:slow-render', { level: 'warning', tags: { surface: 'mermaid' }, extra: { durationMs, diagramType } }) when durationMs > 50, and captureMessageOnce('mermaid:load-error', 'mermaid:load-error', { level: 'warning', tags: { surface: 'mermaid' } }) on load-error. It never reports syntax, semantic, limit or unsupported errors. Each key reports once per page load.

Implementation steps

  • Step 1: Check the preconditions. PKG/dist/index.js and PKG/dist/styles.css exist, and scripts/publishable-extensions.ts holds the mermaid entry. Note whether a dev server is running (lsof -nP -iTCP:3000 -sTCP:LISTEN) and how it was started.

  • Step 2: Add the dependency and install.

    • Copy REPO/bun.lock to <scratchpad>/bun.lock.pre-task7.
    • In apps/webapp/package.json, add "@docs.plus/extension-mermaid": "workspace:*" after the inline-code entry. Add no pele entry: the webapp never imports it, and the root override pins it.
    • Run bun install from REPO. Then diff <scratchpad>/bun.lock.pre-task7 REPO/bun.lock shows only the new webapp workspace line. Any other change, a dompurify move included, is unexpected: stop and report.
    • grep -o '"pele@[0-9][^"]*"' REPO/bun.lock | sort -u still prints only "pele@1.6.0".
    • Cold-install proof (AGENTS.md §Dependencies):
      T=$(mktemp -d)
      rsync -a -m --exclude='node_modules/' --exclude='.next/' --include='*/' --include='package.json' --include='bun.lock' --include='bunfig.toml' --include='.npmrc' --exclude='*' REPO/ "$T/repo/"
      cd "$T/repo" && bun install --frozen-lockfile --ignore-scripts --cache-dir "$T/cache"
      Expected: exit 0, with no IntegrityCheckFailed line.
  • Step 3: Add sanitizeDiagramSvg to sanitizeContent.ts, as §Interfaces says. Pele's root SVG carries role, and the DOMPurify svg profile allows role for HTML only.

  • Step 4: Add the icon. Add LuWorkflow to the react-icons/lu import list in registry.ts, and diagram: LuWorkflow, after codeBlock.

  • Step 5: Register Mermaid in TipTap.tsx.

    • Import Mermaid and type MermaidStateEvent (between the inline-code and placeholder imports), captureMessageOnce from @utils/observability, and sanitizeDiagramSvg from @utils/sanitizeContent.
    • Add the lowlight stub after lowlight.register('bash', …), with a comment: without it, highlightAuto runs on every diagram source.
    • Add the module-local reportDiagramState (§Interfaces).
    • In baseExtensions, add the Mermaid.configure call from §Interfaces after CodeBlockLowlight.configure({ lowlight }).
    • Do not touch useHistoryEditor.ts, the /editor page or chat's useTiptapEditor.ts. History and /editor use baseExtensions (D2); chat builds its own list.
  • Step 6: Add the "Diagram" slash item (§Interfaces) after the codeBlock item in slashItems.ts.

  • Step 7: Fix the ToolbarSelect collision. Apply the ToolbarSelectItem change in §Interfaces. The JSDoc on attrs says it narrows isActive when two rows share one node name.

  • Step 8: Update the Code select rows in EditorToolbar.tsx (§Interfaces). The Code block action is:

    () =>
      editor.isActive('codeBlock', { language: 'mermaid' })
        ? editor.chain().focus().updateAttributes('codeBlock', { language: null }).run()
        : editor.chain().focus().toggleCodeBlock().run()

    toggleCodeBlock() on a diagram would make a paragraph. Append the Diagram row last.

  • Step 9: Add the placeholder text. In buildBreadcrumbPlaceholder, after the pos === 0 Title check: if (nodeName === 'codeBlock' && node.attrs.language === 'mermaid') return 'Write a Mermaid diagram'. No new Jest case.

  • Step 10: Add the styles.

    • editor-extensions.scss: append @import '../../../../extensions/extension-mermaid/src/styles/mermaid.css'; with a one-line comment.
    • _mermaid.scss, using existing tokens only. On .mermaid-diagram: --mermaid-diagram-border: var(--color-base-300), --mermaid-diagram-radius: var(--radius-field), --pele-bg: var(--color-base-100), --pele-fg: var(--color-base-content), --pele-muted: var(--code-ink-comment), --pele-line: var(--color-base-content), --pele-border: var(--muted-foreground), --pele-surface: var(--color-base-200), --pele-surface-alt: var(--color-base-300), --pele-accent: var(--color-primary), --pele-critical: var(--color-error), --pele-font: var(--font-sans) and --pele-font-mono: var(--font-mono). Leave --pele-series-* unset (D4).
    • The view padding: .mermaid-diagram__preview { padding: var(--prose-pre-padding-block) var(--prose-pre-padding-inline); }.
    • The pre reset: .ProseMirror .mermaid-diagram__source > pre { border: 0; border-radius: 0; margin: 0; }. A bare .mermaid-diagram__source > pre would lose to .ProseMirror pre.
    • The placeholder: .ProseMirror .mermaid-diagram.is-empty { position: relative; }, and .ProseMirror .mermaid-diagram.is-empty::before { content: attr(data-placeholder); position: absolute; top: calc(1px + var(--prose-pre-padding-block)); left: calc(1px + var(--prose-pre-padding-inline)); float: none; height: auto; font-family: var(--font-mono); font-size: 0.8rem; }. The global .is-empty::before rule (_prose-mirror-body.scss:105) floats it and sets height: 0, so this rule resets both. Decoration.node puts is-empty on the wrapper, not on pre.
    • The head controls: .mermaid-diagram__fold, .mermaid-diagram__help, .mermaid-diagram__copy { background: var(--color-base-100); border-color: var(--color-base-300); border-radius: var(--radius-field); }, with outline: var(--focus-ring-soft) on :focus-visible.
    • The zoom cluster: .mermaid-diagram .pele-zoom { --pele-border: var(--color-base-300); --pele-radius: var(--radius-field); }, and :focus-visible on its buttons, svg.pele and .mermaid-diagram__retry with outline: var(--focus-ring-soft); outline-offset: 1px. --pele-radius is set only here.
    • _index.scss: add @use './mermaid'; after @use './history-diff';.
  • Step 11: Add the print rules in _print.scss: add .pele-zoom to the hide list. Then add .mermaid-diagram { break-inside: avoid; }, .mermaid-diagram__diagram { contain: none !important; overflow: visible !important; } (the inline contain: paint and overflow: hidden would clip on paper), .mermaid-diagram:has(> [data-state='ready']) > pre { display: none !important; }, and .mermaid-diagram > [data-state]:not([data-state='ready']) { display: none !important; }.

  • Step 12: Fix the TOC rail clone in TocTickRail.tsx. Append '.pele-zoom' and '.mermaid-diagram__error' to PREVIEW_STRIP_SEL: the error line holds a focusable button, and the clone is aria-hidden. In preparePreviewClone, set tabindex="-1" on each svg.pele, and remove id from each .mermaid-diagram [id]. Do not add svg to PREVIEW_MEDIA_SEL.

  • Step 13: Add the MCP sentence. Insert A ```mermaid fenced block renders as a diagram in the pad. (leading space, literal backticks, no backslashes) before the closing quote of INSTRUCTIONS in serverFactory.ts.

  • Step 14: Add the glossary entry. Under CONTEXT.md ## Pad tools: - **Diagram** — a code block whose language is exactly \mermaid`. The pad draws it with Pele below its source (`@docs.plus/extension-mermaid`). The document stores only the source text., then Avoid: mermaid block, chart`.

  • Step 15: Add the design-system catalog row (D5 approved). In .cursor/docs/design-system.md, after the ### Prose/editor shared styling table, add ### Diagram frame (.mermaid-diagram) — both, a source line naming mermaid.css and _mermaid.scss, and a | State | Recipe | table:

    • frame: one two-tone frame, a 1px --mermaid-diagram-border (base-300), --mermaid-diagram-radius (var(--radius-field); 8px at the publish boundary), no shadow. The inner pre drops its border, radius and margin.
    • source: the pad pre well (base-200, mono, --prose-pre-padding-*), then a 1px divider.
    • view: base-100 paper, --prose-pre-padding-*, and the --pele-* map. Series stay unset (D4).
    • loading: the inherited-ink spinner, centred in the min-height: 6rem reserve on the diagram host.
    • error: the last good diagram at opacity: 0.4; Pele's own error UI hidden; our error line, then the raw message in mono.
    • load-error: the message, then the Try again button; focus var(--focus-ring-soft).
    • empty: "Write a Mermaid diagram" inside the pre padding, at the pre code size.
    • zoom: .pele-zoom with a base-300 border and var(--radius-field); focus var(--focus-ring-soft), offset 1px; off on (pointer: coarse).
    • print: the diagram when ready, otherwise the source; zoom hidden.
    • head: at rest, only the mermaid label in the right corner (mono, muted). Hover or focus-within cross-fades it, in place, with the Syntax link, Copy and the Hide or Show source button. Each button: base-100 fill, a base-300 border, var(--radius-field), 28px. On coarse pointers they are 44px and always visible. Motion: --motion-overlay-in and --motion-overlay-out for the swap, --motion-panel for the fold, --motion-flash-hold for the Copy check, coloured by .mermaid-diagram__copy[data-copied] { color: var(--color-success) }. Reduced motion turns all of it off.
    • folded: the source well shows only the head row; the diagram stays below.
  • Step 16: Write the help page through tech-writer. docs/diagrams.md: what it covers (writing diagrams in a pad) with a link to the package README; Insert (slash "Diagram" or /mermaid, the desktop Code select, a ```mermaid fence); Edit (source on top, the diagram below, the redraw and error timing); Syntax (link to https://pele.run); Not supported (chat, the digest email, pictures in DOCX and ODT export, which carry the source; Markdown keeps the fence); Offline ("Could not load the diagram renderer." and Try again); Accessibility (accTitle, accDescr, not colour alone). In docs/README.md, add ## Write in a document before ## Run it on your own server, holding this table:

    | Page | What it covers |
    | --- | --- |
    | [Diagrams](diagrams.md) | How to insert, edit and read a Mermaid diagram in a pad |
  • Step 17: Add the CSP comment. Beside style: in next.config.js: one line saying Pele's SVG carries style attributes, so diagrams need 'unsafe-inline' there.

  • Step 18: Run the static gates. The Root checks, bunx prettier --check on every touched tracked file, bun run check:agent-docs, the Webapp checks, and bun run --filter @docs.plus/hocuspocus typecheck. Each passes. Fix and rerun before Step 19. If any file other than next.config.js changes after Step 19, run Step 19 again.

  • Step 19: D6 proof, baseline build. Stop next dev; lsof -nP -iTCP:3000 -sTCP:LISTEN prints nothing. grep -c '^NEXT_PUBLIC_E2E=' REPO/.env.local prints 0. If it prints 1, stop: Next would load it through the apps/webapp/.env.local symlink and turn the PWA off. Run the build:ci command, then, in the same call:

    mkdir -p "$D"
    test -f public/sw.js && echo "sw.js written"
    grep -o '/_next/static/chunks/[^"]*\.js' public/sw.js | sed 's#^/_next/##' | sort -u > "$D/base.txt"
    jq -r 'to_entries[] | select(.key | test("node_modules/pele/|-> pele/lazy")) | .value.files[] | select(endswith(".js"))' .next/react-loadable-manifest.json | sort -u > "$D/pele-base.txt"
    wc -l < "$D/pele-base.txt"; comm -12 "$D/base.txt" "$D/pele-base.txt" | wc -l

    Expected: sw.js written, and both counts equal the Pele chunk count C, with C ≥ 2. The baseline precaches every Pele chunk.

  • Step 20: Add the D6 predicate. Change buildExcludes in next.config.js to:

    buildExcludes: [
      /dynamic-css-manifest\.json$/,
      /middleware-manifest\.json$/,
      // workbox 6 stops at the first function in `exclude`, so next-pwa's own one never runs.
      // Its two production rules are copied here (next-pwa 5.6.0 index.js:214-219).
      ({ asset, compilation }) =>
        asset.name.startsWith('server/') ||
        /^(build-manifest|react-loadable-manifest)\.json$/.test(asset.name) ||
        [...compilation.chunks].some(
          (chunk) =>
            chunk.files.has(asset.name) &&
            compilation.chunkGraph
              .getChunkModules(chunk)
              .some((m) => /[\\/]node_modules[\\/]pele[\\/]/.test(m.nameForCondition?.() ?? ''))
        )
    ],

    Then run bunx prettier --check REPO/apps/webapp/next.config.js and bun run lint from REPO. Both pass. Formatting does not change chunk hashes.

  • Step 21: D6 proof, second build. Run the build:ci command again, then, in the same call:

    test -f public/sw.js && echo "sw.js written"
    grep -o '/_next/static/chunks/[^"]*\.js' public/sw.js | sed 's#^/_next/##' | sort -u > "$D/new.txt"
    jq -r 'to_entries[] | select(.key | test("node_modules/pele/|-> pele/lazy")) | .value.files[] | select(endswith(".js"))' .next/react-loadable-manifest.json | sort -u > "$D/pele.txt"
    diff "$D/pele-base.txt" "$D/pele.txt" && echo "pele chunks stable"
    comm -23 "$D/base.txt" "$D/new.txt" | diff - "$D/pele.txt" && echo "removed = pele"
    comm -12 "$D/new.txt" "$D/pele.txt" | wc -l
    grep -o 'build-manifest\.json\|react-loadable-manifest\.json\|/_next/server/' public/sw.js | wc -l
    sed 's#^#.next/#' "$D/pele.txt" | xargs grep -l "No diagram type detected" | wc -l
    jq -r '.pages["/_app"][]' .next/build-manifest.json | sed 's#^#.next/#' | xargs grep -l "No diagram type detected" | wc -l
    jq -r '.pages[][]' .next/build-manifest.json | sort -u | comm -12 - "$D/pele.txt" | wc -l

    Expected, in order: sw.js written, pele chunks stable, removed = pele, then 0, 0, a count of 1 or more, 0 and 0: no Pele chunk precached; next-pwa's own excludes hold; the Pele marker string exists in the Pele chunks (the positive control); no Pele code in /_app; no Pele chunk loads up front on any route. Hash drift in either diff means a source changed between the builds: rerun from Step 19, and never loosen the check.

  • Step 22: Clean up and restart dev. Run rm -rf REPO/apps/webapp/.next and rm -f REPO/apps/webapp/public/sw.js REPO/apps/webapp/public/workbox-*.js REPO/apps/webapp/public/worker-*.js. Dev would otherwise serve the production service worker. Start dev the way it ran before, and hard-refresh.

  • Step 23: Run the existing webapp specs this task touches. From REPO/apps/webapp: bunx cypress run --config baseUrl=http://localhost:3000 --spec "cypress/e2e/editor/slash/slash-menu.cy.ts,cypress/e2e/editor/toc/*.cy.js,cypress/e2e/editor/indent/indent-code-list.cy.js,cypress/e2e/editor/lists/*.cy.js". Every spec passes. The lists specs drive the Lists ToolbarSelect, and check:ci skips webapp Cypress, so this is their only run.

  • Step 24: Smoke check in a visible Chrome tab. In the pad, /diagram then Enter inserts the starter with the caret at the end of the source, and a flowchart draws. History and /editor draw the same frame.

  • Step 25: Review checkpoint. git status --short shows the Files list above plus the earlier tasks' changes (PKG, bun.lock, root package.json and the Register extension-mermaid as the sixth family package #527 files), and nothing else. Report each step as pass or fail.


Decisions

  • R3. Do you accept a module-path predicate in place of a fixed chunk name? Recommended: yes. A fixed name cannot name Pele's internal chunks, and Steps 19 to 21 prove the predicate.
  • R9. Is the help page docs/diagrams.md, linked from docs/README.md? Recommended: yes.

Acceptance criteria

  • The Step 18 static gates pass.
  • The D6 proof prints sw.js written, pele chunks stable, removed = pele, and the five counts that Step 21 expects.
  • The existing slash, TOC, indent and lists specs pass.
  • The smoke check passes in a visible tab.

Activity

  1. HMarzban commented on Oct 19, 2026

    @HMarzban
    CollaboratorAuthor

    Maintainer ruling, 2026-10-19

    R3 (the next.config.js PWA no-precache predicate) and R9 (docs/diagrams.md) are confirmed. The behaviour-neutral move of the TOC preview clone into apps/webapp/src/components/toc/utils/sectionPreviewClone.ts is accepted.

    The work is in the maintainer's working tree and is not committed or pushed yet.

  2. HMarzban commented on Oct 19, 2026

    @HMarzban
    CollaboratorAuthor

    D6 re-run and smoke check, 2026-10-19 (final code)

    • D6: run in a clone of the repo, never in the live checkout. Run 2 passed every check as written: sw.js written, 37 Pele chunks all precached, pele chunks stable, removed = pele, then 0 / 0 / 1 / 0 / 0. Run 1 was off by one line in the webpack runtime chunk; both builds had the same runtime hash, so that was build noise.
    • Smoke:
      • In the pad, /diagram then Enter inserts the starter with the caret at the end of the source, and the flowchart is ready.
      • History shows the same frame, folded and ready, with no Restore control.
      • /editor gives the same result.
    • The running webapp served the current build: an over-limit diagram showed the new .mermaid-diagram__advice sentence.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

      Milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions