Repository navigation
Draw Mermaid diagrams in the pad, history and /editor #528
Description
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.tsxregistersCodeBlockLowlightinbaseExtensions, which the pad, history and/editorshare; chat builds its own list (read from code).- The desktop Code select keys rows by
valueand checksisActive(item.value), so twocodeBlockrows would collide (read fromToolbarSelect.tsx). next.config.jsbuildExcludeshas two entries, so next-pwa would precache every Pele chunk (read from code).
Files and interfaces
Files
- Modify:
apps/webapp/package.json,bun.lock(throughbun 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:
Mermaidandtype MermaidStateEventfrom the built package;insertMermaidthrough theCommandsaugmentation; the CSS contract; Pele'ssvg.pele,.pele-zoom,.pele-zoom-in,.pele-zoom-outand.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:ciNever 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: LuWorkflowin 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: [] }))andMermaid.configure({ sanitize: sanitizeDiagramSvg, onStateChange: reportDiagramState }). Module-localreportDiagramState(event)callscaptureMessageOnce('mermaid:slow-render', 'mermaid:slow-render', { level: 'warning', tags: { surface: 'mermaid' }, extra: { durationMs, diagramType } })whendurationMs > 50, andcaptureMessageOnce('mermaid:load-error', 'mermaid:load-error', { level: 'warning', tags: { surface: 'mermaid' } })onload-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.jsandPKG/dist/styles.cssexist, andscripts/publishable-extensions.tsholds 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.lockto<scratchpad>/bun.lock.pre-task7. - In
apps/webapp/package.json, add"@docs.plus/extension-mermaid": "workspace:*"after the inline-code entry. Add nopeleentry: the webapp never imports it, and the root override pins it. - Run
bun installfromREPO. Thendiff <scratchpad>/bun.lock.pre-task7 REPO/bun.lockshows only the new webapp workspace line. Any other change, adompurifymove included, is unexpected: stop and report. grep -o '"pele@[0-9][^"]*"' REPO/bun.lock | sort -ustill prints only"pele@1.6.0".- Cold-install proof (AGENTS.md §Dependencies):
Expected: exit 0, with no
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"
IntegrityCheckFailedline.
- Copy
-
Step 3: Add
sanitizeDiagramSvgtosanitizeContent.ts, as §Interfaces says. Pele's root SVG carriesrole, and the DOMPurify svg profile allowsrolefor HTML only. -
Step 4: Add the icon. Add
LuWorkflowto thereact-icons/luimport list inregistry.ts, anddiagram: LuWorkflow,aftercodeBlock. -
Step 5: Register
MermaidinTipTap.tsx.- Import
Mermaidandtype MermaidStateEvent(between the inline-code and placeholder imports),captureMessageOncefrom@utils/observability, andsanitizeDiagramSvgfrom@utils/sanitizeContent. - Add the lowlight stub after
lowlight.register('bash', …), with a comment: without it,highlightAutoruns on every diagram source. - Add the module-local
reportDiagramState(§Interfaces). - In
baseExtensions, add theMermaid.configurecall from §Interfaces afterCodeBlockLowlight.configure({ lowlight }). - Do not touch
useHistoryEditor.ts, the/editorpage or chat'suseTiptapEditor.ts. History and/editorusebaseExtensions(D2); chat builds its own list.
- Import
-
Step 6: Add the "Diagram" slash item (§Interfaces) after the
codeBlockitem inslashItems.ts. -
Step 7: Fix the
ToolbarSelectcollision. Apply theToolbarSelectItemchange in §Interfaces. The JSDoc onattrssays it narrowsisActivewhen 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 thepos === 0Title 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
prereset:.ProseMirror .mermaid-diagram__source > pre { border: 0; border-radius: 0; margin: 0; }. A bare.mermaid-diagram__source > prewould 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::beforerule (_prose-mirror-body.scss:105) floats it and setsheight: 0, so this rule resets both.Decoration.nodeputsis-emptyon the wrapper, not onpre. - 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); }, withoutline: 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-visibleon its buttons,svg.peleand.mermaid-diagram__retrywithoutline: var(--focus-ring-soft); outline-offset: 1px.--pele-radiusis set only here. _index.scss: add@use './mermaid';after@use './history-diff';.
-
Step 11: Add the print rules in
_print.scss: add.pele-zoomto the hide list. Then add.mermaid-diagram { break-inside: avoid; },.mermaid-diagram__diagram { contain: none !important; overflow: visible !important; }(the inlinecontain: paintandoverflow: hiddenwould 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'toPREVIEW_STRIP_SEL: the error line holds a focusable button, and the clone isaria-hidden. InpreparePreviewClone, settabindex="-1"on eachsvg.pele, and removeidfrom each.mermaid-diagram [id]. Do not addsvgtoPREVIEW_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 ofINSTRUCTIONSinserverFactory.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., thenAvoid: mermaid block, chart`. -
Step 15: Add the design-system catalog row (D5 approved). In
.cursor/docs/design-system.md, after the### Prose/editor shared stylingtable, add### Diagram frame (.mermaid-diagram) — both, a source line namingmermaid.cssand_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 innerpredrops its border, radius and margin. - source: the pad
prewell (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: 6remreserve 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
prepadding, at thepre codesize. - zoom:
.pele-zoomwith a base-300 border andvar(--radius-field); focusvar(--focus-ring-soft), offset 1px; off on(pointer: coarse). - print: the diagram when ready, otherwise the source; zoom hidden.
- head: at rest, only the
mermaidlabel 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-inand--motion-overlay-outfor the swap,--motion-panelfor the fold,--motion-flash-holdfor 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.
- frame: one two-tone frame, a 1px
-
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```mermaidfence); 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). Indocs/README.md, add## Write in a documentbefore## 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:innext.config.js: one line saying Pele's SVG carriesstyleattributes, so diagrams need'unsafe-inline'there. -
Step 18: Run the static gates. The Root checks,
bunx prettier --checkon every touched tracked file,bun run check:agent-docs, the Webapp checks, andbun run --filter @docs.plus/hocuspocus typecheck. Each passes. Fix and rerun before Step 19. If any file other thannext.config.jschanges after Step 19, run Step 19 again. -
Step 19: D6 proof, baseline build. Stop
next dev;lsof -nP -iTCP:3000 -sTCP:LISTENprints nothing.grep -c '^NEXT_PUBLIC_E2E=' REPO/.env.localprints0. If it prints1, stop: Next would load it through theapps/webapp/.env.localsymlink and turn the PWA off. Run thebuild:cicommand, 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 countC, withC ≥ 2. The baseline precaches every Pele chunk. -
Step 20: Add the D6 predicate. Change
buildExcludesinnext.config.jsto: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.jsandbun run lintfromREPO. Both pass. Formatting does not change chunk hashes. -
Step 21: D6 proof, second build. Run the
build:cicommand 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, then0,0, a count of 1 or more,0and0: 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 eitherdiffmeans 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/.nextandrm -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. Thelistsspecs drive the ListsToolbarSelect, andcheck:ciskips webapp Cypress, so this is their only run. -
Step 24: Smoke check in a visible Chrome tab. In the pad,
/diagramthen Enter inserts the starter with the caret at the end of the source, and a flowchart draws. History and/editordraw the same frame. -
Step 25: Review checkpoint.
git status --shortshows the Files list above plus the earlier tasks' changes (PKG,bun.lock, rootpackage.jsonand 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 fromdocs/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
HMarzban commentedon Oct 19, 2026
Maintainer ruling, 2026-10-19
R3 (the
next.config.jsPWA no-precache predicate) and R9 (docs/diagrams.md) are confirmed. The behaviour-neutral move of the TOC preview clone intoapps/webapp/src/components/toc/utils/sectionPreviewClone.tsis accepted.The work is in the maintainer's working tree and is not committed or pushed yet.
build(deps): bump the Supabase CLI and pin Pele
feat(webapp): draw Mermaid diagrams in the pad, history and editor
HMarzban commentedon Oct 19, 2026
D6 re-run and smoke check, 2026-10-19 (final code)
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./diagramthen Enter inserts the starter with the caret at the end of the source, and the flowchart isready.ready, with no Restore control./editorgives the same result..mermaid-diagram__advicesentence.