From 8116e2432c0abeca50eb1251184df9cb8c73a503 Mon Sep 17 00:00:00 2001 From: Andrew Mikofalvy <5668128+amikofalvy@users.noreply.github.com> Date: Thu, 13 Aug 2026 18:13:45 +0000 Subject: [PATCH] fix(visimer): hold the hero headline in place while mermaid renders (PRD-8003) (#3467) * Hold the hero headline in place while mermaid renders The visimer.com headline is a live mermaid diagram, so on a cold load the page went through three states: a plain text headline from index.html for about 1.3s, then the fully drawn page with a 300px hole where the h1 belongs for another 600ms, then the diagram popping in. The middle state reads as broken, because the hint line points at an empty band. The 600ms hole is mermaid pulling its flowchart renderer over two dynamic imports after React mounts, then laying the diagram out. Rather than chase that down, close the window: draw a static stand-in headline that matches the real render and crossfade it out when the real one lands. src/hero-diagram.ts is the single definition of both. Its coordinates are mermaid's own output for the hero source, so the stand-in and the real diagram share a viewBox and are fitted into the same box by the same preserveAspectRatio. Measured over the band, the two differ by 0.01% of pixels at 1440px and 0% at 400px, and the 220ms crossfade covers the rest. The same module generates the first-paint markup that a small Vite plugin inlines into index.html, replacing the hand-maintained copy that used to live there. That copy carried its own geometry and sat the headline 150px from the top while the real hero puts it at 189px, so mounting moved the page under the visitor. The hero's layout values now live in site.css and both renderers read them. Note that mermaid measures its labels exactly once, and emits a layout about 2% tighter whenever it happens to render before Inter has loaded. The committed coordinates are the Inter-loaded ones, which is what a real visitor gets: the font stylesheet is preconnected and lands well before the bundle that starts the render. * Address local review on the hero stand-in Six items from the review panel, none blocking. Pass the boot HTML to String.replace as a function rather than a string. A string replacement gives $&, $' and friends their special meaning, so the day a hero string gains a dollar sign the served document would be silently corrupted, and only in the pre-React copy that nobody looks at during normal development. Move the last shared strings into hero-diagram.ts: the license label and the two hint lines were still written out in both renderers, which is the drift the module exists to prevent. licenseLabel now reads from it, so the badge and the footer follow one definition. Link the boot spacer to the real header through a --site-header-h custom property, with the header enforcing it as a floor. It stays a floor rather than a fixed height so taller content grows the header instead of being clipped by it. Correct the section comment in site.css that still described the old .mw-svg-host scope. The rules now reach the stand-in too, and a new rule written to the old scope would style only one of the pair, which is exactly how the crossfade stops being invisible. Unexport the two orientation constants, which are only composed into HERO_PLACEHOLDER_HTML in the same file. Tighten the comments: drop the point-in-time bundle size and the count of mermaid's internal imports, name all four inputs the coordinates depend on so a maintainer tuning heroConfig knows to re-derive them, and use mermaid's own arrowMarkerPath class on the arrowhead instead of claiming attribute-level fidelity while diverging on the class name. Re-verified after the changes: stand-in against the settled render still differs by 0.01% of band pixels at 1440px and 0% at 400px, hero geometry is byte-identical, in-place editing works, no console errors. * Address cloud review on the hero stand-in Four inline findings and two scope notes from pullfrog, none blocking. Compose HERO_HEADLINE from the three label constants rather than restating the sentence. It was the one string in the module still written twice, so the accessible headline and the visible diagram could drift apart, which is the duplication the module exists to remove. Escape the values heroBootHtml interpolates. The JSX consumers of those same constants get React's escaping; the raw-HTML consumer got none, so the first hero string to contain a <, an & or a quote would render correctly in the app and malformed in the served document. Same asymmetry as the dollar-sign fix, one layer up. Static entities in the template are markup rather than values and stay as authored. Stop letting two predicates decide one crossfade. The stand-in's orientation came from a media query while the diagram's came from a single mount-time read of window.innerWidth that is deliberately never re-picked, so resizing across 760px could leave a stacked stand-in fading into a left-to-right diagram in the same box. The app now renders one stand-in chosen from the source the editor actually got; the media-query pair stays, but only in the served document, which has no better option. Drop the boot block on non-landing routes. index.html is the only entry, so the landing hero also painted over /playground and /hero-loop until the bundle routed away from it. Before this change that was a line of text; a full hero reproduction is worse. An inline synchronous script removes it during parse, matching main.tsx's own pathname normalization. Correct the index.html comment that still called this a render-failure fallback. A failed parse emits render with ok false, which resolves the gate, fades the stand-in out and reveals the canvas error badge. Add a dev-only viewBox comparison in the render handler. Nothing else notices when the hand-measured coordinates stop matching mermaid, and the only symptom is a stand-in that shifts as it fades on a cold load. It checks the first render only, since an edited headline is legitimately a different shape, and import.meta.env.DEV keeps it out of the bundle. Verified: 0.01% band-pixel difference at 1440px and 0% at 400px unchanged; / keeps the stand-in while /playground and /hero-loop drop it; a 1000-to-400 resize after render leaves exactly one stand-in matching the rendered viewBox; the warning is absent from the production bundle; in-place editing works with no console errors. GitOrigin-RevId: c668267a3aef9067cd0392776a7ad61cf3fae633 --- apps/site/index.html | 14 +-- apps/site/src/App.tsx | 151 +++++++++++------------ apps/site/src/hero-diagram.ts | 223 ++++++++++++++++++++++++++++++++++ apps/site/src/site.css | 207 +++++++++++++++++++++++++------ apps/site/vite.config.ts | 30 ++++- 5 files changed, 504 insertions(+), 121 deletions(-) create mode 100644 apps/site/src/hero-diagram.ts diff --git a/apps/site/index.html b/apps/site/index.html index 1491e8e..31c4f74 100644 --- a/apps/site/index.html +++ b/apps/site/index.html @@ -81,15 +81,15 @@ empty div, so anything that does not execute JavaScript sees no headline at all, and the first paint is blank until the bundle has run. - Since the visible headline is drawn by mermaid at runtime, this is also - what shows if that render ever fails while the rest of the page works. + It is not a failure fallback: a render that throws still emits `render`, + which resolves the gate in App.tsx, fades this out and reveals the + canvas's own error badge. This covers the wait, not the failure. - Keep this copy in sync with the hero in App.tsx. + The markup is generated from src/hero-diagram.ts by the visimer-hero-boot + plugin in vite.config.ts, so it cannot drift from the hero it stands in + for. Edit that module, not the output. --> -
-

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 d222a7c..c526712 100644 --- a/apps/site/src/App.tsx +++ b/apps/site/src/App.tsx @@ -2,6 +2,18 @@ import { useEffect, useRef, useState, type CSSProperties, type ReactNode } from import mermaid from 'mermaid' import { MermaidCanvas, useMermaidEditor } from '@visimer/react' import { track } from './analytics' +import { + HERO_HEADLINE, + HERO_HINT_BODY, + HERO_HINT_LEAD, + HERO_LICENSE, + HERO_SOURCE_LR, + HERO_SOURCE_TD, + HERO_STACK_MAX_WIDTH, + HERO_SUBHEAD, + heroPlaceholderFor, + heroPlaceholderViewBox, +} from './hero-diagram' import { CodeMirrorPane, Logo, @@ -18,27 +30,10 @@ 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 @@ -224,6 +219,16 @@ export default function App() { const [heroSource, setHeroSource] = useState(heroInitial) useEffect(() => heroEditor.on('change', ({ code }) => setHeroSource(code.trim())), [heroEditor]) + // The stand-in headline stays up until mermaid has actually put an SVG on the + // page, which is a good half-second after mount on a cold load: the renderer + // arrives over its own dynamic imports and then has to lay the diagram out. + // Gating on mount instead would reopen the hole this is here to close. + // + // A failed parse also resolves this — the canvas shows its own error badge for + // that, and a visitor who has typed the headline into an invalid state is + // better served seeing that badge than the original sentence sitting under it. + const [heroRendered, setHeroRendered] = useState(false) + useCanvasControlTracking() // Expanding hands off to the dedicated /playground page, carrying the @@ -298,7 +303,9 @@ export default function App() { const starsLabel = ghStars == null ? 'Star' : ghStars >= 1000 ? `${(ghStars / 1000).toFixed(1).replace(/\.0$/, '')}k` : String(ghStars) - const licenseLabel = 'MIT' + // Shared with the first-paint copy of the badge, which the build inlines into + // index.html from the same module. + const licenseLabel = HERO_LICENSE const copyInstall = () => { void navigator.clipboard?.writeText(INSTALL_CMD) @@ -351,6 +358,7 @@ export default function App() { return (
-
- + {/* Geometry for this block lives in site.css rather than inline, because + the served document paints a copy of it before React exists (see + hero-diagram.ts). Two sources for the same vertical rhythm means the + mount visibly nudges the page; one means it does not. */} +
+
+ Open source · {licenseLabel} · React & vanilla
@@ -527,19 +525,19 @@ export default function App() { 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 below instead. */} -

WYSIWYG editor renders native mermaid

-
+

{HERO_HEADLINE}

+
+ {/* Same markup the served document already painted, minus the + media-query orientation pick: this one is fed the source the + editor actually got, so both halves of the crossfade follow one + decision. Rendering it again here rather than leaving the boot + copy in place keeps the band under React's control — the copy is + torn out with the rest of #root on mount, and this one crossfades + out on its own schedule. */} +
{ view.container.tabIndex = -1 + // Only the first render is comparable to the stand-in; once the + // visitor edits the headline the diagram is legitimately a + // different shape. + let driftChecked = false + // The view kicks off its first render in its constructor and + // awaits mermaid before emitting, so this subscription is in + // place well before the event it is waiting for. + view.on('render', () => { + setHeroRendered(true) + // Nothing else notices when the stand-in's hand-measured + // coordinates stop matching mermaid's output — reword the + // hero, retune heroConfig or bump mermaid and the only symptom + // is a stand-in that shifts as it fades, on a cold load, which + // is invisible in normal development. The viewBox is the one + // number the whole fit depends on, so compare it here. + if (import.meta.env.DEV && !driftChecked) { + driftChecked = true + const rendered = view.container.querySelector('.mw-svg-host > svg')?.getAttribute('viewBox') + const expected = heroPlaceholderViewBox(heroInitial) + if (rendered && rendered !== expected) { + console.warn( + `[hero] stand-in viewBox is stale: mermaid rendered "${rendered}", hero-diagram.ts has "${expected}". Re-derive the coordinates in hero-diagram.ts.`, + ) + } + } + }) }} />
-
- That headline is a live Mermaid diagram. - Double-click a word to rewrite it. +
+ {HERO_HINT_LEAD} + {HERO_HINT_BODY} {/* the statement line only: "flowchart LR" is noise in a one-line hint */} {heroSource.split('\n').slice(1).join(' ').replace(/\s+/g, ' ').trim()}
-

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

+

{HERO_SUBHEAD}

|${EDGE_LABEL}| B[${NODE_B}]` + +export const HERO_SOURCE_TD = `flowchart TD + A[${NODE_A}] -->|${EDGE_LABEL}| B[${NODE_B}]` + +/** + * 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. + */ +export const HERO_STACK_MAX_WIDTH = 760 + +/** + * Everything below builds raw HTML strings, where React's escaping does not + * apply. The JSX consumers of these same constants get escaped for free, so + * without this the first hero string to contain a `<`, `&` or quote would render + * correctly in the app and malformed in the served document — the copy nobody + * looks at during normal development. Static entities written directly into the + * templates (`·`, `&`) are markup, not values, and stay as authored. + */ +function esc(value: string): string { + return value + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') +} + +/** + * Mermaid's arrowhead. `userSpaceOnUse` keeps it sized in viewBox units rather + * than scaling with the 2px stroke the site puts on the connector, which is how + * mermaid draws it. + */ +function marker(id: string): string { + return `` +} + +/** + * A label as mermaid emits it: HTML in a foreignObject, not SVG . The + * class names are load-bearing — site.css styles the real diagram's labels + * through `.nodeLabel` / `.edgeLabel` and reaches the stand-in's by the same + * selectors, so the two cannot drift apart on typography or colour. + */ +function label(kind: 'nodeLabel' | 'edgeLabel', x: number, y: number, w: number, text: string): string { + return `

${esc(text)}

` +} + +function node(x: number, y: number, w: number, h: number, labelX: number, labelW: number, text: string): string { + return `${label('nodeLabel', labelX, y + (h - 24) / 2, labelW, text)}` +} + +/** + * Order is paint order: connector first, then the boxes, then the edge label + * last so its backing plate covers the line rather than the line striking + * through the word. + */ +function svg(id: string, viewBox: string, body: string): string { + return `` +} + +const VIEW_BOX_LR = '0 0 487.890625 70' +const VIEW_BOX_TD = '0 0 203.40625 198' + +const HERO_PLACEHOLDER_LR = svg( + 'hero-ph-arrow-lr', + VIEW_BOX_LR, + `` + + node(8, 8, 187.40625, 54, 38, 127.40625, NODE_A) + + node(303.546875, 8, 176.34375, 54, 333.546875, 116.34375, NODE_B) + + label('edgeLabel', 220.40625, 23, 58.140625, EDGE_LABEL), +) + +const HERO_PLACEHOLDER_TD = svg( + 'hero-ph-arrow-td', + VIEW_BOX_TD, + `` + + node(8, 8, 187.40625, 54, 38, 127.40625, NODE_A) + + node(13.53125, 136, 176.34375, 54, 43.53125, 116.34375, NODE_B) + + label('edgeLabel', 72.6328125, 87, 58.140625, EDGE_LABEL), +) + +/** + * Both orientations ship in the served document because the build cannot know + * the window width; site.css shows one and hides the other at the same + * breakpoint `HERO_STACK_MAX_WIDTH` encodes. Together they are under 2KB. + * + * This media-query pick is the best a static document can do, and it is only + * ever the pre-mount stand-in. Once React is up it renders `heroPlaceholderFor` + * instead — see below for why that distinction matters. + */ +const HERO_PLACEHOLDER_BOTH_HTML = + `
${HERO_PLACEHOLDER_LR}
` + + `
${HERO_PLACEHOLDER_TD}
` + +/** + * The stand-in for the orientation the live diagram actually chose. + * + * The app must not reuse the media-query pair above: the diagram's orientation + * is decided once at mount from `window.innerWidth` and deliberately never + * re-picked (a resize would overwrite whatever the visitor typed into the + * headline), while a media query keeps tracking the window forever. Two + * predicates over one crossfade means a resize across the breakpoint can leave a + * stacked stand-in fading into a left-to-right diagram in the same box, which is + * a jump rather than a settling. Feeding this the same source the editor got + * collapses it back to one decision. + */ +export function heroPlaceholderFor(source: string): string { + const svgMarkup = source === HERO_SOURCE_TD ? HERO_PLACEHOLDER_TD : HERO_PLACEHOLDER_LR + return `
${svgMarkup}
` +} + +/** The stand-in's viewBox for a given hero source, so a dev-only check can + * compare it against what mermaid actually rendered. */ +export function heroPlaceholderViewBox(source: string): string { + return source === HERO_SOURCE_TD ? VIEW_BOX_TD : VIEW_BOX_LR +} + +/** + * First-paint content for `#root`, replaced by the app on mount. + * + * It reproduces the hero's vertical rhythm exactly — a spacer the height of the + * sticky header, then the section's own padding, badge, band, hint and + * subheading at their real margins — so that mount swaps the markup without + * moving anything the visitor is already looking at. The band carries the same + * stand-in the React hero does, so the headline never blinks out. + * + * The chrome the app owns and this does not (nav, buttons, the sections below) + * arrives around the hero rather than displacing it. + * + * index.html is the single entry for every route, so this landing hero would + * also paint over /playground and /hero-loop until the bundle routes away from + * it. The trailing script drops it on those routes, matching main.tsx's own + * pathname normalization. It is inline and synchronous so it runs during parse, + * before first paint — it prevents a flash rather than causing one, and the only + * thing it costs a JavaScript-less visitor is a landing hero on a route that + * cannot function without JavaScript anyway. + */ +export function heroBootHtml(): string { + return `
+
+
+
+ + Open source · ${esc(HERO_LICENSE)} · React & vanilla +
+

${esc(HERO_HEADLINE)}

+ +
+ ${esc(HERO_HINT_LEAD)} + ${esc(HERO_HINT_BODY)} +
+

${esc(HERO_SUBHEAD)}

+
+
+` +} diff --git a/apps/site/src/site.css b/apps/site/src/site.css index 097d9a5..f53361e 100644 --- a/apps/site/src/site.css +++ b/apps/site/src/site.css @@ -55,18 +55,21 @@ textarea { 3. Room for the popover. The edit toolbar is a child of the canvas, so a clipped hero would cut it in half on the first double-click. - Every rule is scoped to `.mw-svg-host > 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. */ + Every rule is scoped to `.hero-masthead :is(.mw-svg-host, .hero-placeholder) + > svg` — the live diagram and the static stand-in that holds its place, which + have to be styled identically or the crossfade between them stops being + invisible. A new rule for one of them belongs on both. A bare `.mw-canvas svg` + would also match the icons inside the 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 { +.hero-masthead :is(.mw-svg-host, .hero-placeholder) { position: absolute; inset: 0; } -.hero-masthead .mw-svg-host > svg { +.hero-masthead :is(.mw-svg-host, .hero-placeholder) > svg { position: absolute; inset: 0; width: 100% !important; @@ -74,31 +77,31 @@ textarea { max-width: none !important; transform: none !important; } -.hero-masthead .mw-svg-host > svg .nodeLabel, -.hero-masthead .mw-svg-host > svg .nodeLabel p { +.hero-masthead :is(.mw-svg-host, .hero-placeholder) > svg .nodeLabel, +.hero-masthead :is(.mw-svg-host, .hero-placeholder) > 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 { +.hero-masthead :is(.mw-svg-host, .hero-placeholder) > 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 { +.hero-masthead :is(.mw-svg-host, .hero-placeholder) > svg .nodeLabel, +.hero-masthead :is(.mw-svg-host, .hero-placeholder) > svg .nodeLabel p, +.hero-masthead :is(.mw-svg-host, .hero-placeholder) > 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 { +.hero-masthead :is(.mw-svg-host, .hero-placeholder) > svg .edgePath path, +.hero-masthead :is(.mw-svg-host, .hero-placeholder) > 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 { +.hero-masthead :is(.mw-svg-host, .hero-placeholder) > svg marker path, +.hero-masthead :is(.mw-svg-host, .hero-placeholder) > svg .marker { fill: #0e7c6b !important; stroke: #0e7c6b !important; } @@ -107,15 +110,15 @@ textarea { (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 { +.hero-masthead :is(.mw-svg-host, .hero-placeholder) > svg .edgeLabel, +.hero-masthead :is(.mw-svg-host, .hero-placeholder) > svg .edgeLabel span, +.hero-masthead :is(.mw-svg-host, .hero-placeholder) > svg .edgeLabel p { background: #f7f4ed !important; background-color: #f7f4ed !important; color: #544f47 !important; fill: #544f47 !important; } -.hero-masthead .mw-svg-host > svg .edgeLabel rect { +.hero-masthead :is(.mw-svg-host, .hero-placeholder) > svg .edgeLabel rect { fill: #f7f4ed !important; } /* Zoom chrome in the corner of an h1 gives away that it is a widget, and a @@ -125,14 +128,14 @@ textarea { } .hero-masthead, .hero-masthead .mw-canvas, -.hero-masthead .mw-svg-host { +.hero-masthead :is(.mw-svg-host, .hero-placeholder) { 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 { +.hero-masthead :is(.mw-svg-host, .hero-placeholder) { left: clamp(16px, 5vw, 90px); right: clamp(16px, 5vw, 90px); } @@ -149,32 +152,162 @@ textarea { } } -/* 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 { +/* ---- Hero layout -------------------------------------------------------- + These classes are shared by two renderers of the same block: the copy Vite + inlines into index.html (see src/hero-diagram.ts) and the React hero that + replaces it on mount. Holding the geometry in one place is what makes that + replacement invisible — when the two carried their own numbers, mounting + moved the headline down the page in front of the visitor. */ +.hero-section { max-width: 1180px; margin: 0 auto; - padding: 150px 26px 30px; + padding: 74px 26px 34px; 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; +.hero-badge { + display: inline-flex; + align-items: center; + gap: 9px; + font-size: 13px; + color: #6b6559; + border: 1px solid #e6e0d4; + background: #fcfaf5; + padding: 6px 13px; + border-radius: 999px; + letter-spacing: 0.01em; +} +.hero-badge-dot { + display: inline-block; + width: 7px; + height: 7px; + border-radius: 99px; + background: #0e7c6b; +} +/* Full-bleed to the window while staying in the section's flow: the band is the + hero, not a card inside it. */ +.hero-masthead { + position: relative; + left: 50%; + width: 100vw; + transform: translateX(-50%); + height: clamp(210px, 24vw, 300px); + margin: 26px 0 0; +} +.hero-hint { + display: flex; + align-items: center; + justify-content: center; + gap: 10px; + flex-wrap: wrap; + margin: 14px 0 0; + font-size: 13px; + color: #8a857a; } -.boot-sub { +.hero-hint-lead { + color: #0e7c6b; + font-weight: 600; +} +.hero-sub { max-width: 620px; margin: 20px auto 0; font-size: 18.5px; line-height: 1.55; color: #544f47; + text-wrap: pretty; +} + +/* First-paint content, generated into 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. + + The served copy has no navbar — that is the app's — so it opens with a spacer + the height of one instead, and the hero starts at the same offset in both. + Both read the height from the same custom property, and the real header + enforces it as a floor, so the two cannot silently disagree. It is a floor + rather than a fixed height because taller content should still grow the + header rather than be clipped by it; if that ever happens, this number is the + one to re-measure. */ +:root { + --site-header-h: 59px; +} +.site-header { + min-height: var(--site-header-h); +} +.boot-header { + height: var(--site-header-h); +} + +/* ---- Hero stand-in ------------------------------------------------------ + Holds the headline's place while mermaid is still on its way. Position, + colour and typography all come from the `.hero-masthead ... > svg` rules + above, which name the stand-in alongside the live canvas host precisely so + the two cannot be styled differently. Only the declarations mermaid ships + inside its own generated SVG need restating here. */ +.hero-placeholder-layer { + position: absolute; + inset: 0; + /* decorative, and the live canvas underneath owns every gesture */ + pointer-events: none; +} +.hero-placeholder > svg .node rect { + fill: #eaf3f0; + stroke: #0e7c6b; + stroke-width: 1px; +} +.hero-placeholder > svg .flowchart-link { + fill: none; +} +.hero-placeholder > svg .hero-ph-label { + display: table-cell; + white-space: nowrap; + line-height: 1.5; + text-align: center; + font-size: 16px; +} +.hero-placeholder > svg .hero-ph-label p { + margin: 0; +} +/* Only the served document carries both orientations — it cannot know the + window width, so the breakpoint picks one, at the same threshold + HERO_STACK_MAX_WIDTH encodes. The mounted app renders a single stand-in + chosen from the source the editor actually got (heroPlaceholderFor), so these + two rules do not reach it: a media query keeps tracking the window, while the + diagram's orientation is fixed at mount, and letting both drive one crossfade + is how a settle turns into a jump. */ +.hero-placeholder-td { + display: none; +} +@media (max-width: 760px) { + .hero-placeholder-lr { + display: none; + } + .hero-placeholder-td { + display: block; + } +} + +/* The handover. Both layers are fitted into the same box by the same viewBox and + preserveAspectRatio, so this is a crossfade between two near-identical + pictures rather than a swap — it reads as the headline settling, and covers + whatever sub-pixel drift is left between hand-drawn and rendered. */ +.hero-masthead .mw-canvas, +.hero-placeholder-layer { + transition: opacity 220ms ease; +} +.hero-masthead .mw-canvas { + opacity: 0; +} +.hero-masthead.is-rendered .mw-canvas { + opacity: 1; +} +.hero-masthead.is-rendered .hero-placeholder-layer { + opacity: 0; +} +@media (prefers-reduced-motion: reduce) { + .hero-masthead .mw-canvas, + .hero-placeholder-layer { + transition: none; + } } /* Keeps the headline text in the document for assistive tech and crawlers, diff --git a/apps/site/vite.config.ts b/apps/site/vite.config.ts index 1886f0c..b783569 100644 --- a/apps/site/vite.config.ts +++ b/apps/site/vite.config.ts @@ -1,8 +1,34 @@ -import { defineConfig } from 'vite' +import { defineConfig, type Plugin } from 'vite' import react from '@vitejs/plugin-react' +import { heroBootHtml } from './src/hero-diagram' + +const BOOT_MARKER = '' + +/** + * Inlines the first-paint hero into index.html so the served document already + * contains a headline. Generating it from the same module the app renders from + * is the point: a hand-maintained copy in index.html drifts from the hero it is + * standing in for, and the drift only shows up in the first second of a cold + * load, which is exactly where nobody looks. + */ +function heroBoot(): Plugin { + return { + name: 'visimer-hero-boot', + transformIndexHtml(html) { + if (!html.includes(BOOT_MARKER)) { + throw new Error(`index.html is missing the ${BOOT_MARKER} placeholder`) + } + // Replacer function, not a string: a string replacement would give `$&`, + // `$'` and friends their special meaning, so the day a hero string gains a + // `$` the served document would be silently corrupted — and only in the + // pre-React copy, which is the one nobody looks at in normal dev. + return html.replace(BOOT_MARKER, () => heroBootHtml()) + }, + } +} export default defineConfig({ - plugins: [react()], + plugins: [react(), heroBoot()], optimizeDeps: { exclude: ['@visimer/core', '@visimer/dom', '@visimer/react', '@visimer/codemirror'], include: ['mermaid', 'react', 'react-dom', '@codemirror/state', '@codemirror/view', '@codemirror/commands', '@codemirror/language', '@lezer/highlight'],