From d3ae51ff7d9d1386d14f0d939c73ce93b2867e6d Mon Sep 17 00:00:00 2001 From: Andrew Mikofalvy <5668128+amikofalvy@users.noreply.github.com> Date: Sun, 9 Aug 2026 10:51:00 -0700 Subject: [PATCH] Make the visimer.com hero a live, editable Mermaid diagram (#3353) * Add four design directions for the Visimer site Four complete, working landing pages at /v1../v4, indexed at /variants. Each carries the same product truth and the same live editor, and differs only in visual world, so the review is about direction rather than build quality. v1 split-flap: the mechanism as material. A departures board only flips the flaps that must change, which is what the engine does to your source. v2 editor-first: the working editor fills the first viewport; copy gets one line. v3 datamatics: pure black and white, monospace at data density, every claim stated as a measured quantity. v4 poster: flat screened inks, ray burst, claims crammed into ruled cells. Content the current site never states now appears in all four: the 22 of 23 diagram-type coverage, and a gesture-to-diff demonstration of the minimal edit property. Also adds PRODUCT.md recording product truth for the site work. * Add SPA rewrites so the variant routes survive a direct load vercel.json only rewrote /playground to index.html, so /variants and /v1../v4 returned 404 on a direct load or refresh. That is the whole point of a preview link, so the review routes need the same fallback. * Add v5, where the headline itself is an editable diagram The h1 is a live Mermaid flowchart set as display type: node boxes hidden, words as the glyphs, the connector as the only drawn rule. Double-click a word and both the headline and the source shown directly beneath it rewrite, which demonstrates the minimal-edit claim on the claim itself. Three things the masthead needed that a normal canvas does not: Mermaid ignores the fontSize theme variable for HTML labels and panZoom caps its fit at 2x, so neither can drive display scale. The SVG's own viewBox does it instead, which scales glyphs, rule weights and hit regions together with nothing to keep in sync. Selection ships as a drop-shadow around the node box. With the box hidden it haloed the glyphs, so selection here is the affordance type already has: the words go signal blue and pick up a rule. The edit popover is a child of the canvas, so a clipped masthead cut it in half on the first double-click. Direction is picked once at mount, top-down under 760px, because a phone fits the horizontal layout by width and shrinks the masthead to caption size. Re-picking on resize would overwrite whatever the visitor typed. * Fix clipped masthead text and the stacked popover icons in v5 Two bugs, both from the masthead CSS reaching further than intended. The edit popover is a sibling of the diagram inside the same canvas, so the bare `.mw-canvas svg` sizing rule also matched every icon inside the toolbar and gave each one `position: absolute; inset: 0`. All five icons piled onto the same spot. Every masthead rule is now scoped to `.mw-svg-host > svg`, which is the diagram and nothing else. Mermaid measures labels in a detached node using its own font and weight, so the box it allocated was about four pixels narrower than this heavier face needs and the last glyph of "diagram" was cut off. The node boxes are invisible here, so the label is allowed out of its box rather than being made to fit. Verified in a browser: the popover's five buttons now sit at five distinct x positions, and no label exceeds its foreignObject with overflow hidden. * Give v5 a mark that makes the same move the masthead does The V of the wordmark drawn as a two-edge flow with a node on each vertex: a letter at a glance, a valid little flowchart on inspection. The bottom vertex carries the signal blue because that is how a selected node reads everywhere else on the page. Nodes run about three times the edge weight so they still read as nodes rather than as rounded stroke terminals when the mark is shown large, and the silhouette stays solid enough to survive a 16px tab. The favicon is a separate file swapped in only while v5 is mounted, and restored on the way out, so the current site keeps its own mark while this is still a candidate. It inverts with the browser colour scheme so it holds on a dark tab strip. Checked at 16 through 128 on light and dark, in a tab lockup, and beside the existing mark, which loses its detail below about 24px. * Reword the v5 masthead to "Edit this directly / render native mermaid" The headline now names the two things the product actually does rather than describing the interaction, and the connector becomes single-headed because the copy reads as a sequence rather than a loop. The longer strings widen the graph, so the fit-to-width scale drops a little and the masthead fills more of its box. Checked at both breakpoints; the top-down layout under 760px still stacks and stays legible. * Draw the node boxes in the v5 masthead and rename it hero.mmd The masthead now renders as an ordinary mermaid flowchart, boxes and all, in the same fill, border and connector colours the playground further down the page uses. Only the type stays ours: the display face at headline weight, with the node geometry sizing itself around it. Hiding the boxes had forced two workarounds that are no longer needed. Labels were allowed out of their boxes because nothing was drawn to contain them; they now sit inside, with node padding supplying the width mermaid does not measure for the heavier face. Selection had been rewritten as an underline because the shipped drop-shadow haloed bare glyphs; with boxes drawn it is left exactly as the package ships it, which also matches what selecting a node does in the playground. Padding is deliberately tight. The masthead fits to width, so every unit of node padding is paid for in type size. The source pane is renamed headline.mmd to hero.mmd, and the line claiming the masthead is "this component with its node boxes hidden" is corrected, since that is no longer what it is. * Add a review page comparing six shapes for the v5 masthead Six candidate diagrams for the hero, each two or three nodes, rendered through the real hero component and the real mermaid config so the only thing being compared is the diagram. All are live and editable in place. The options use mermaid's own vocabulary rather than just different words: a plain sequence, a bidirectional edge, a decision diamond with a labelled edge, a verb carried on the connector, a cylinder standing for the file on disk, and a three-step chain ending on the losslessness guarantee. Each card reports its measured type scale, which is the real trade-off between them. The masthead fits to width, so the shortest option renders its headline at 3.4x and the wordiest at 2.57x. The hero mermaid config and the v5 chrome are exported rather than copied, so the preview cannot drift from what /v5 actually renders. Adds SPA rewrites for the new route, since /v:id(\d+) does not match /v5-options. * Add the "WYSIWYG editor / renders native mermaid" masthead options Three variants of the same positioning, flagged as the latest proposal and placed at the top of the review page. Unlike the earlier six, the left node names the product category outright, which is the one thing a cold visitor cannot infer from a demonstration. G states it plainly across two nodes. H moves "renders" onto the connector, where mermaid puts verbs. I drops the verb for a bidirectional edge. Measured, the verb-on-the-edge trick does not buy the type size it looks like it should: H renders at 2.66x against G's 2.63x, because the edge label widens the gap by about as much as the shorter node saves. Only I, which drops the word entirely, gains real size at 3.05x. * Make the live diagram the headline on the real site Replaces the text h1 on the existing site with the editable Mermaid masthead, keeping everything else exactly as it was: same cream and teal palette, same badge, subhead, calls to action, playground, features, recipes and footer. flowchart LR A[WYSIWYG editor] -->|renders| B[native mermaid] The verb rides the connector, which is where mermaid puts verbs, so the whole thing reads as one sentence. The headline runs on its own editor, so editing it leaves the playground below untouched, and a hint line under it carries the live source. Two things this needed beyond styling. The hero container widens from 1000px to 1180px, matching the demo section, because the headline is fit to its container and a narrow one rendered it at 47px against the 76px the text h1 carried. And the edge label takes the card colour rather than the theme's, since mermaid backs edge labels with the secondary (gold) tint regardless of edgeLabelBackground, which read as a highlighter chip. Also fixes a containment bug the same treatment had in v5: the svg host was left to size itself, so the diagram positioned against its own natural height instead of the card and painted straight out of the hero on the stacked phone layout. The host is now pinned to the card, and the card goes portrait under 760px so the stacked headline is not fitted by height. Headline text is kept in the document for assistive tech and crawlers, since the visible version is drawn as SVG. * Make the hero canvas full-bleed, with the page as its background The headline canvas loses its card. No border, no fill, no radius: it runs the full width of the window on the site's own background, so the only thing drawn in that band is the diagram itself. The edge label takes the page colour too, since there is no longer a card for it to match. It owns its own band rather than sitting behind the whole section. Mermaid centres the diagram in its canvas, so a canvas spanning the section centres on the section's midpoint, which on a phone put the diagram straight on top of the hint and the subhead. Giving it its own height keeps the diagram where the layout expects it at every width. An inset on the svg host keeps the diagram off both screen edges while the canvas stays edge to edge, and the band goes deeper under 760px so the stacked layout is fitted by width rather than squashed by height. * Drop the design-review variants now the hero direction is chosen Removes the five candidate pages, the hero-options comparison page, the review index and their routes, the v5 favicon, and the SPA rewrites that existed only to serve those routes. vercel.json is back to byte-identical with main. What the exploration produced stays on the real site: the headline is a live Mermaid flowchart, full-bleed on the page background, editable in place. Also corrects PRODUCT.md, whose "known state" still described the old text headline and the buried editor. * Stop the full-bleed hero from overflowing where scrollbars take space The hero canvas is 100vw, which counts the scrollbar on platforms that reserve room for one, so the page picked up a horizontal scrollbar everywhere except macOS overlay scrollbars. `overflow-x: clip` on main trims it without creating a scroll container, so the sticky header keeps working. Measured at 1440, 1024 and 390 with 15px scrollbars forced: zero overflow at each, header still sticky. Also drops an unused hero-section class and an edgeLabelBackground theme value that mermaid ignores for flowchart edge labels, which site.css was already having to override. * Stop the hero heading announcing itself twice With the canvas inside the h1, the heading exposed the sentence once from its visually hidden span and then again, out of order, from the diagram's own node labels: heading "WYSIWYG editor renders native mermaid" text: WYSIWYG editor renders native mermaid document: paragraph: renders paragraph: WYSIWYG editor paragraph: native mermaid The heading's textContent also picked up the whole stylesheet mermaid injects into the svg, about 3KB of CSS, which anything reading the h1 as text would have to wade through. The heading is now the text and the canvas is its rendering, as siblings. The canvas is hidden from assistive tech because it duplicates the heading and its editing is pointer-only, so exposing it adds confusion without adding a capability. Keyboard access to canvas editing does not exist in the package today; that is unchanged by this and worth its own issue. Verified: the heading's accessible name is now exactly the sentence with nothing nested under it, its textContent is just that sentence, editing still rewrites the source, the playground below stays untouched, and there is still no horizontal overflow at 1440, 1024 or 390. * Put a real headline in the served HTML, and drop PRODUCT.md The served document body was `
`, so anything that does not execute JavaScript saw no headline at all and the first paint was blank until a 430KB gzipped bundle had run. That was true of the old text h1 too, but the new headline is drawn by mermaid at runtime, so it is worth fixing here rather than inheriting. #root now ships the headline and subhead as ordinary markup, which React replaces on mount. Vite emits the stylesheet as a real link and the bundle as a deferred module, so it paints styled, in Inter on the site's cream, positioned where the hero lands. It doubles as the fallback for the case flagged in the PR: if the mermaid render ever fails while the rest of the page works, this is what shows. Verified: the built index.html contains the h1; with JavaScript disabled the production build renders it styled and centred; after mount the boot markup is gone and exactly one h1 remains, the visually hidden one. Also removes PRODUCT.md. It was scaffolding for the design exploration and was the only file of its kind in the monorepo, so it does not belong in a change to the hero. * Address review: config race, focus stop in aria-hidden, min-height Three findings from review, all verified against the package source before fixing. Mermaid config race. `mermaid.initialize()` writes a module-global config and `MermaidCanvasView.render()` called `mermaid.render()` without re-asserting its own, so the last canvas to mount owned the config for the whole page. The hero mounts first and lost every time: the first headline edit re-rendered it with the demo canvas's theme and layout, and dark node fills if the visitor had used the demo's dark toggle. Fixed in the package rather than worked around at the call site, since any page with two canvases hits it. Verified by toggling the demo to dark and then editing the headline: node fill stays the site mint, where before it followed the demo. The control confirms the toggle really does move the demo from rgb(234,243,240) to rgb(38,34,27), so the test exercises the path. Focus stop inside aria-hidden. The canvas host sets `tabIndex = 0`, so the aria-hidden headline contained a focusable element, which is axe's aria-hidden-focus rule and WCAG 4.1.2. A keyboard user got a tab stop that announced nothing and did nothing, editing here being pointer-only. The host is now taken out of the tab order at this call site. Verified: zero focusable elements remain inside the subtree. Canvas min-height. The React binding defaults its host to `min-height: 240`, which fights the absolute inset positioning: between 761 and 999px the band is shorter than that, so the canvas outgrew its box and the diagram drifted off centre toward the hint. My earlier verification ran 1440, 1024 and 390, which straddles that window exactly. Verified at 780, 860, 950 and 999: zero overshoot at each. * Close the three remaining review suggestions Breakpoint off-by-one. The stacked layout was chosen with `< 760` while site.css sizes its band with `@media (max-width: 760px)`, which is inclusive, so at exactly 760px the band went tall for a stacked diagram while the source was still left-to-right. Both now agree, via a named constant so the pairing is visible. Verified at the boundary: 759 and 760 are stacked in the 360px band, 761 is wide in the 210px band, no canvas overshoot at any of them. Static heading. Documented why the heading text deliberately does not track edits to the diagram: rewriting a page heading under an assistive-tech user as somebody types would churn the accessibility tree and the document outline for a change only the editing visitor made, to their own local copy. The live source is echoed in the hint line instead. Stale comment. It still said the sentence was exposed "once from this span" after that span became the h1 itself. GitOrigin-RevId: 34c686f1043375b1016b7cde7e17bd5d767319a5 --- .changeset/olive-donkeys-repeat.md | 11 ++ apps/site/index.html | 18 +++- apps/site/src/App.tsx | 155 +++++++++++++++++++++++++--- apps/site/src/site.css | 156 +++++++++++++++++++++++++++++ packages/dom/src/view.ts | 5 + 5 files changed, 332 insertions(+), 13 deletions(-) create mode 100644 .changeset/olive-donkeys-repeat.md diff --git a/.changeset/olive-donkeys-repeat.md b/.changeset/olive-donkeys-repeat.md new file mode 100644 index 0000000..426b69e --- /dev/null +++ b/.changeset/olive-donkeys-repeat.md @@ -0,0 +1,11 @@ +--- +'@visimer/dom': patch +--- + +Re-assert each canvas's mermaid config before it renders. + +`mermaid.initialize()` writes a module-global config, so on a page with more +than one canvas the last one to mount owned it: every other canvas rendered +with the wrong theme and layout options from its second render onwards. +`render()` now re-asserts its own config instead of trusting whatever was +initialized in the constructor. diff --git a/apps/site/index.html b/apps/site/index.html index 78ab270..1491e8e 100644 --- a/apps/site/index.html +++ b/apps/site/index.html @@ -74,7 +74,23 @@ -
+
+ +
+

WYSIWYG editor renders native mermaid

+

Click a node to edit it. Perfect for polishing AI-generated diagrams.

+
+
diff --git a/apps/site/src/App.tsx b/apps/site/src/App.tsx index e7593fc..aaed7d3 100644 --- a/apps/site/src/App.tsx +++ b/apps/site/src/App.tsx @@ -17,6 +17,60 @@ const REPO = 'inkeep/visimer' const REPO_URL = `https://github.com/${REPO}` const INSTALL_CMD = 'npm i @visimer/react' +/** + * The headline, as Mermaid. The verb rides the connector, which is where mermaid + * puts verbs, so the whole thing reads as one sentence. + * + * Direction is picked once at mount rather than on every resize: a phone fits the + * left-to-right layout by width, which shrinks the headline to caption size, but + * re-picking on resize would overwrite whatever the visitor had typed into it. + */ +const HERO_SOURCE_LR = `flowchart LR + A[WYSIWYG editor] -->|renders| B[native mermaid]` + +const HERO_SOURCE_TD = `flowchart TD + A[WYSIWYG editor] -->|renders| B[native mermaid]` + +/** + * Inclusive at 760 to match `@media (max-width: 760px)` in site.css, which sizes + * the band for this layout. A strict `<` disagrees with the media query at + * exactly 760px: the band goes tall for a stacked diagram while the source is + * still left-to-right. + */ +const HERO_STACK_MAX_WIDTH = 760 + +function initialHeroSource(): string { + if (typeof window === 'undefined') return HERO_SOURCE_LR + return window.innerWidth <= HERO_STACK_MAX_WIDTH ? HERO_SOURCE_TD : HERO_SOURCE_LR +} + +/** + * Mermaid lays the headline out at display size, so this config carries the type + * scale rather than CSS scaling a small render up. Node padding is deliberately + * tight: the masthead fits to width, so every unit of padding is paid for in + * headline size. + */ +const heroConfig = { + theme: 'base', + flowchart: { padding: 11, nodeSpacing: 44, rankSpacing: 44, useMaxWidth: false }, + themeVariables: { + fontFamily: "'Inter', sans-serif", + primaryColor: '#EAF3F0', + primaryBorderColor: '#0E7C6B', + primaryTextColor: '#1C1A17', + lineColor: '#0E7C6B', + secondaryColor: '#F5E9C9', + secondaryBorderColor: '#C9A227', + tertiaryColor: '#FBF9F4', + tertiaryBorderColor: '#E6E0D4', + mainBkg: '#EAF3F0', + nodeBorder: '#0E7C6B', + textColor: '#1C1A17', + // No edgeLabelBackground: mermaid ignores it for flowchart edge labels and + // backs them with the secondary tint instead, so site.css sets it. + }, +} + const FEATURES: Array<{ mark: string; title: string; body: string }> = [ { @@ -156,6 +210,14 @@ export default function App() { const [type, setType] = useState('flowchart') const [skin, setSkin] = useState<'light' | 'dark'>('light') const { editor } = useMermaidEditor(PRESETS.flowchart) + + // The headline runs on its own editor so editing it never disturbs the + // playground below, and vice versa. + const [heroInitial] = useState(initialHeroSource) + const { editor: heroEditor } = useMermaidEditor(heroInitial) + const [heroSource, setHeroSource] = useState(heroInitial) + useEffect(() => heroEditor.on('change', ({ code }) => setHeroSource(code.trim())), [heroEditor]) + useCanvasControlTracking() // Expanding hands off to the dedicated /playground page, carrying the @@ -409,8 +471,15 @@ export default function App() { -
-
+ {/* The hero canvas is 100vw, which counts the scrollbar on platforms that + reserve space for one, so it would sit a few pixels wider than the page + and add a horizontal scrollbar. `clip` trims that without creating a + scroll container, so the sticky header above is unaffected. */} +
+ {/* Wider than the old 1000px hero: the headline is now fit to the width of + this container, so a narrow one renders it well below the 76px the + text h1 used to carry. 1180 matches the demo section below it. */} +
Open source · {licenseLabel} · React & vanilla
-

WYSIWYG editor renders native mermaid

+
- Edit Mermaid diagrams visually. - + { + view.container.tabIndex = -1 + }} + /> +
+
+ That headline is a live Mermaid diagram. + Double-click a word to rewrite it. + {/* the statement line only: "flowchart LR" is noise in a one-line hint */} + + {heroSource.split('\n').slice(1).join(' ').replace(/\s+/g, ' ').trim()} + +

svg`, the diagram itself. A bare + `.mw-canvas svg` also matches the icons inside that popover, which would pin + each one to inset 0 and stack them into an unreadable pile. */ +/* The host has to be pinned to the card as well as the svg. Left to size itself + it takes the diagram's natural height, and since the svg is positioned against + the host rather than the card, the diagram then paints straight out of the + hero — most visibly on phones, where the stacked layout is tallest. */ +.hero-masthead .mw-svg-host { + position: absolute; + inset: 0; +} +.hero-masthead .mw-svg-host > svg { + position: absolute; + inset: 0; + width: 100% !important; + height: 100% !important; + max-width: none !important; + transform: none !important; +} +.hero-masthead .mw-svg-host > svg .nodeLabel, +.hero-masthead .mw-svg-host > svg .nodeLabel p { + font-family: 'Inter', sans-serif !important; + font-weight: 700 !important; + letter-spacing: -0.035em !important; + color: #1c1a17 !important; + fill: #1c1a17 !important; +} +.hero-masthead .mw-svg-host > svg foreignObject { + overflow: visible !important; +} +.hero-masthead .mw-svg-host > svg .nodeLabel, +.hero-masthead .mw-svg-host > svg .nodeLabel p, +.hero-masthead .mw-svg-host > svg .nodeLabel div { + overflow: visible !important; + white-space: nowrap !important; + max-width: none !important; +} +.hero-masthead .mw-svg-host > svg .edgePath path, +.hero-masthead .mw-svg-host > svg .flowchart-link { + stroke: #0e7c6b !important; + stroke-width: 2px !important; +} +.hero-masthead .mw-svg-host > svg marker path, +.hero-masthead .mw-svg-host > svg .marker { + fill: #0e7c6b !important; + stroke: #0e7c6b !important; +} +/* The edge label needs an opaque backing so the connector does not strike + through the word, but mermaid takes that colour from the theme's secondary + (the gold tint) and ignores edgeLabelBackground here, which reads as a + highlighter chip. It matches the page instead, since the hero canvas sits + directly on the site background with no card of its own. */ +.hero-masthead .mw-svg-host > svg .edgeLabel, +.hero-masthead .mw-svg-host > svg .edgeLabel span, +.hero-masthead .mw-svg-host > svg .edgeLabel p { + background: #f7f4ed !important; + background-color: #f7f4ed !important; + color: #544f47 !important; + fill: #544f47 !important; +} +.hero-masthead .mw-svg-host > svg .edgeLabel rect { + fill: #f7f4ed !important; +} +/* Zoom chrome in the corner of an h1 gives away that it is a widget, and a + headline should not pan. */ +.hero-masthead .mw-zoom-btn { + display: none !important; +} +.hero-masthead, +.hero-masthead .mw-canvas, +.hero-masthead .mw-svg-host { + overflow: visible !important; +} + +/* The canvas runs the full width of the window, so without an inset the diagram + would butt against both screen edges. This keeps a margin at every size while + leaving the canvas itself full-bleed. */ +.hero-masthead .mw-svg-host { + left: clamp(16px, 5vw, 90px); + right: clamp(16px, 5vw, 90px); +} + +/* Under 760px the headline switches to the stacked top-down layout, which is + nearly square rather than wide, so the open band it is centred into has to be + deep enough that it is fitted by width rather than squashed by height. */ +@media (max-width: 760px) { + .hero-masthead { + height: clamp(300px, 78vw, 360px) !important; + } + .hero-hint { + font-size: 12px !important; + } +} + +/* First-paint content, defined in index.html inside #root and replaced by the + app on mount. Vite emits the stylesheet as a real and the bundle as a + deferred module, so this paints styled before any JavaScript runs. Positioned + to sit roughly where the hero headline lands, so the swap is not a jump. */ +.boot { + max-width: 1180px; + margin: 0 auto; + padding: 150px 26px 30px; + text-align: center; +} +.boot-title { + font-family: 'Inter', sans-serif; + font-weight: 700; + font-size: clamp(34px, 5vw, 58px); + line-height: 1.06; + letter-spacing: -0.035em; + color: #1c1a17; + margin: 0; + text-wrap: balance; +} +.boot-sub { + max-width: 620px; + margin: 20px auto 0; + font-size: 18.5px; + line-height: 1.55; + color: #544f47; +} + +/* Keeps the headline text in the document for assistive tech and crawlers, + since the visible version is drawn as SVG by the diagram renderer. */ +.sr-only { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0 0 0 0); + clip-path: inset(50%); + white-space: nowrap; + border: 0; +} + /* The live demo embeds the real editor; keep its canvas transparent so the dotted preview background shows through, and center the rendered SVG the way the design's preview pane does. */ diff --git a/packages/dom/src/view.ts b/packages/dom/src/view.ts index 2cbd885..cdd7294 100644 --- a/packages/dom/src/view.ts +++ b/packages/dom/src/view.ts @@ -749,6 +749,11 @@ export class MermaidCanvasView { const seq = ++this.renderSeq const id = `mw-render-${++renderIdCounter}` try { + // mermaid.initialize writes a module-global config, so with more than one + // canvas on a page the last one to mount owns it and every other canvas + // renders with the wrong theme and layout. Re-assert ours here rather than + // trusting whatever was initialized in the constructor. + this.mermaid.initialize(this.mermaidConfig) const { svg } = await this.mermaid.render(id, code) if (seq !== this.renderSeq) return // stale // an in-place edit may have kept typing since the live commit this render