/** * Content-frame block primitives: Doc, Row, Col, Space, Md, Diagram, Img. * One vocabulary at two scales: boards arrange frames in rows/columns with space * units; these primitives do the same for blocks INSIDE a frame - as CSS flex, * deliberately NOT the board lane-flow grammar (shared vocabulary, not semantics). * * Doc owns the measurement protocol: it reports its content height so the shell * can give the frame a natural size (the shell alone owns node dimensions). */ import { useEffect, useMemo, useRef, useState, type ReactNode } from 'react' import { useInSlide } from './slide.tsx' import { CONTENT_WIDTH } from '../const.ts' import { assetUrl, renderMarkdown, sanitizeMarkdownHtml, FAMILIES } from './md.ts' import { LOD_SETTLED, lodSupported, registerLodImage } from './img-lod.ts' // D3: family color classes for inline Md (`:blue[...]`), theme-aware (frames carry .dark + [data-theme]) const FAMILY_CSS = Object.entries(FAMILIES).map(([f, c]) => `.mv-md .mv-c-${f}{color:${c.light}}.dark .mv-md .mv-c-${f},[data-theme="dark"] .mv-md .mv-c-${f}{color:${c.dark}}`).join('\n') import { Diagram as DiagramRoot, diagramsPending } from './diagram.tsx' export function Diagram(props: Parameters[0]) { ensureStyles(); return } import { Slide as SlideRoot } from './slide.tsx' import { Chart as ChartRoot } from './chart.tsx' import { Video as VideoRoot } from './video.tsx' export { SLIDE_W, SLIDE_H, useSlidePlay } from './slide.tsx' // the shared stylesheet used to ride in with Doc alone; a slide composes Img, // Chart, and Video straight inside with no Doc, so every public // primitive installs it - once per document, idempotent export function Slide(props: Parameters[0]) { ensureStyles(); return } export function Chart(props: Parameters[0]) { ensureStyles(); return } export function Video(props: Parameters[0]) { ensureStyles(); return } const UNIT = 16 // one gap unit, px - plain adjacency on boards is one gutter; same feel here /* ---------------------------------- Doc ---------------------------------- */ /** How long the first measurement waits for the doc to finish growing before it reports anyway. */ const SETTLE_CAP = 2000 /** After the cap, a provisional doc keeps checking (slower) for this long, to report once it IS done. */ const SETTLE_TAIL = 60_000 /** Is anything that still changes this doc's height in flight? Fonts loading, a diagram rendering, * an image not decoded yet (an LOD canvas pins its aspect on its first decode - img-lod.ts - and * an with no size yet is 0 tall until it loads; a lazy one off-screen may not load at all). * A video poster sits in a fixed-ratio box: it never changes the height. */ export function heightPending(el: HTMLElement, doc: Document = document): boolean { if (doc.fonts?.status === 'loading') return true if (diagramsPending() > 0) return true for (const c of el.querySelectorAll('canvas.mv-img-el')) if (!c.style.aspectRatio && c.dataset.mvLod !== 'failed') return true for (const img of el.querySelectorAll('img')) if (!img.complete && !img.closest('.mv-video')) return true return false } export function Doc({ layout = 'document', children }: { layout?: 'document' | 'wide'; children?: ReactNode }) { const ref = useRef(null) useEffect(() => { ensureStyles() const el = ref.current if (!el || window.parent === window) return let t: ReturnType | undefined let poll: ReturnType | undefined let reported = false // the first measurement went out - the observer may report from here let lastSettled = false let tailFrom = 0 const params = new URLSearchParams(location.search) const height = () => Math.ceil(el.getBoundingClientRect().height) const post = () => { reported = true lastSettled = !heightPending(el) // ANY provisional report - the first, or a later one (an edit that brought a loading image) - // is followed by a settled one once the doc is done, even when finishing changes no geometry if (!lastSettled && poll === undefined) { tailFrom = performance.now(); poll = setTimeout(check, 500) } window.parent.postMessage({ type: 'sh:measure', // identity guards: board files may reuse node keys (frame id must match), and // a WindowProxy survives navigation (gen = THIS document's URL rev - the shell // drops echoes that don't match the iframe's current src) frame: params.get('id') ?? '', gen: params.get('r') ?? '', // and THIS document's id (the frame bridge sets it; absent where no bridge ran - the stage, a headless shot): // the shell's recorder counts a settled report only from the document there now (spec 28 A1.7) boot: (window as { __mvBoot?: string }).__mvBoot, ownWidth: CONTENT_WIDTH[layout] ?? CONTENT_WIDTH.document, measuredWidth: window.innerWidth, // the width this height is TRUE at (r3 #1) height: height(), // settled = the doc is done growing: the shell commits it as THE height (and the dev shell // keeps it for the next load - sizes.ts). Provisional = it may still grow (an image to come) settled: lastSettled, }, '*') } // The FIRST report waits until the doc is done: fonts, diagrams and images, and the height // still the same two checks running - otherwise a board that already knows this frame's // height (the committed cache) would see it shrink to the half-loaded doc and grow back. // Capped: a slow or broken image never holds the frame hostage - it reports provisional, // then once more, settled, when the image lands (or the observer sees it). const start = performance.now() let prevH = -1, quiet = 0 function check() { poll = undefined if (!reported) { const h = height() quiet = !heightPending(el!) && h === prevH ? quiet + 1 : 0 prevH = h if (quiet >= 2 || performance.now() - start >= SETTLE_CAP) post() else poll = setTimeout(check, 50) return } if (lastSettled) return if (!heightPending(el!)) { post(); return } if (performance.now() - tailFrom < SETTLE_TAIL) poll = setTimeout(check, 500) } // debounced ~300ms after the last content change; the shell guards staleness // on its side (event.source must map to a mounted iframe; reflow is board-scoped) const ro = new ResizeObserver(() => { if (!reported) return; clearTimeout(t); t = setTimeout(post, 300) }) ro.observe(el) // an image landing or failing, a font finishing: check again now - the poll stops after its tail, // and a size-given image that loads a minute later still owes the settled report const onAsset = () => { if (reported && !lastSettled && poll === undefined) poll = setTimeout(check, 50) } el.addEventListener('load', onAsset, true) el.addEventListener('error', onAsset, true) el.addEventListener(LOD_SETTLED, onAsset) // a canvas image's first decode, landed or failed (img-lod.ts) document.fonts?.addEventListener?.('loadingdone', onAsset) check() return () => { ro.disconnect(); clearTimeout(t); clearTimeout(poll) el.removeEventListener('load', onAsset, true); el.removeEventListener('error', onAsset, true) el.removeEventListener(LOD_SETTLED, onAsset) document.fonts?.removeEventListener?.('loadingdone', onAsset) } }, [layout]) return
{children}
} /* ----------------------------- layout blocks ------------------------------ */ export function Row({ space = 1, children }: { space?: number; children?: ReactNode }) { ensureStyles() return
{children}
} export function Col({ space = 1, children }: { space?: number; children?: ReactNode }) { ensureStyles() return
{children}
} export function Space({ n = 1 }: { n?: number }) { ensureStyles() return
} /* ---------------------------------- Md ------------------------------------ */ export function Md({ children }: { children?: ReactNode }) { ensureStyles() const src = typeof children === 'string' ? children : Array.isArray(children) ? children.join('') : String(children ?? '') const html = useMemo(() => sanitizeMarkdownHtml(renderMarkdown(src)), [src]) return
} /* ---------------------------------- Img ----------------------------------- */ export function Img({ src, caption, alt, h }: { src: string; caption?: string; alt?: string; h?: number }) { ensureStyles() const url = assetUrl(src) const [err, setErr] = useState(false) const canvasRef = useRef(null) // on a slide the LOD canvas is OFF: slides mode scales the whole stage up to the screen, // and a bitmap decoded for the frame's own box would blur there - the plain keeps // its full resolution. A slide frame is marked on (data-mv-slide) before it renders. const inSlide = useInSlide() || (typeof document !== 'undefined' && document.documentElement.hasAttribute('data-mv-slide')) // LOD: paint the image on a decoded to its on-screen size (never the full 17MB bitmap), and // re-pick resolution only when the canvas settles after a zoom. See img-lod.ts. Falls back to a plain // where createImageBitmap/bitmaprenderer isn't available (correctness over the optimization). const lodOn = lodSupported && !inSlide useEffect(() => { if (!url || err || !lodOn || !canvasRef.current) return return registerLodImage(canvasRef.current, url) }, [url, err, lodOn]) if (!url || err) { return (
image unavailable {src} {!url && must be a relative path inside design/assets/}
) } // A reference image ALWAYS shows in full: it fills its column at its natural aspect ratio, never // cropped and never letterboxed. Equal-aspect images (e.g. a row of screenshots) line up on their own, // and the frame auto-heights to fit. `h` is accepted for back-compat but no longer constrains size - // capping height below natural would force the image narrower than its column (whitespace) or slice it // (the old object-fit:cover). Width:100% + height:auto come from the .mv-img-el rule. void h const style = undefined return (
{lodOn ? : {alt setErr(true)} />} {caption &&
{caption}
}
) } /* --------------------------------- styles --------------------------------- */ /** Injected once per document. Content frames paint their own surface and read * the frame theme from the bridge's signals (html.dark / [data-theme]). */ let stylesIn = false function ensureStyles() { if (stylesIn || document.getElementById('mv-content-css')) { stylesIn = true; return } stylesIn = true const el = document.createElement('style') el.id = 'mv-content-css' el.textContent = CSS + '\n' + FAMILY_CSS document.head.appendChild(el) } const CSS = ` /* only content frames inject this stylesheet, and their document is the Doc - zeroing the body margin makes the measured height the truth (a min-height would echo the frame's own height back and the measurement could never shrink) */ body { margin: 0; } .mv-doc { --mv-bg: #FFFFFF; --mv-surface: #F7F7F9; --mv-line: #D1D1D6; --mv-line-soft: #E5E5EA; --mv-text: #1C1C1E; --mv-muted: #636366; --mv-faint: #8E8E93; --mv-accent: #0088FF; --mv-block-bg: rgba(20, 22, 28, 0.02); --mv-block-line: rgba(20, 22, 28, 0.07); box-sizing: border-box; width: 100%; background: var(--mv-bg); color: var(--mv-text); font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; font-size: 15px; line-height: 1.6; -webkit-font-smoothing: antialiased; padding: 40px; display: flex; flex-direction: column; gap: ${UNIT}px; } .dark .mv-doc, [data-theme="dark"] .mv-doc { --mv-bg: #1C1C1E; --mv-surface: #26262A; --mv-line: #3A3A3C; --mv-line-soft: #2C2C2E; --mv-text: #F2F2F7; --mv-muted: #AEAEB2; --mv-faint: #8E8E93; --mv-accent: #0091FF; --mv-block-bg: rgba(255, 255, 255, 0.025); --mv-block-line: rgba(255, 255, 255, 0.07); } .mv-doc * { box-sizing: border-box; } .mv-row { display: flex; flex-wrap: wrap; align-items: flex-start; } .mv-row > * { flex: 1 1 260px; min-width: 0; } .mv-row > .mv-space { flex: none; } .mv-col { display: flex; flex-direction: column; min-width: 0; } /* the rubber: diagram + image blocks own their breathing room. The surface is a whisper, not a card - the content pops, the block only frames it. The card is a DOCUMENT treatment: inside a Slide or a UI screen a block is bare (the author's own card or the slide's grid frames it), so it takes no invisible ${UNIT}px of padding there. */ .mv-block { margin: 0; } .mv-doc .mv-block { padding: ${UNIT}px; border: 1px solid var(--mv-block-line); border-radius: 10px; background: var(--mv-block-bg); } /* an image block is NOT a card: the screenshot IS the content. Drop the surface + border so we don't frame a frame; give the image itself a hairline edge and a whisper of shadow so it reads as a clean, distinct object on the page (no heavy double-border around browser mockups). */ .mv-block.mv-img { padding: 0; border: none; background: none; } /* INSET hairline: the line sits ON the image's own rounded edge (outline-offset:-1px), overriding a screenshot's ragged or baked-in dark border instead of drawing a second frame outside it. It shares the image's exact border-radius (same element), so the two never mismatch. Grayscale token = clean in both light and dark. A whisper of drop shadow lifts it off the page. */ .mv-img .mv-img-el { outline: 1px solid var(--mv-line); outline-offset: -1px; box-shadow: 0 1px 4px rgba(20, 22, 28, 0.07); } .mv-block figcaption, .mv-img figcaption { font-size: 12.5px; color: var(--mv-faint); padding-top: 10px; } .mv-diagram-svg { display: flex; justify-content: center; } .mv-diagram-svg svg { max-width: 100%; height: auto; } .mv-diagram-err { display: flex; flex-direction: column; gap: 4px; font-size: 13px; color: var(--mv-muted); font-family: ui-monospace, "SF Mono", Menlo, monospace; } .mv-diagram-err b { color: #E0402F; font-family: inherit; } .mv-diagram-err .dim, .mv-imgerr .dim { color: var(--mv-faint); font-size: 12px; } .mv-img .mv-img-el { display: block; width: 100%; height: auto; max-width: 100%; border-radius: 6px; } .mv-imgerr { display: flex; flex-direction: column; gap: 4px; font-size: 13px; color: var(--mv-muted); } .mv-imgerr b { color: #E0402F; } /* markdown typography - the HIG scale, Notion's calm. Body 16 on generous leading; headings tighten tracking as they grow (SF behavior); sections breathe ABOVE far more than below, so structure reads at a glance. Every list-style is re-asserted: host Tailwind preflight strips markers. */ .mv-md { max-width: 72ch; min-width: 0; font-size: 16px; line-height: 1.65; } /* #12: prose keeps its measure, but a table straight in a wide Doc grows past it as far as its cells need, up to the Doc's own width (cqi) - never into a Row's neighbour */ .mv-doc-wide { container-type: inline-size; } .mv-doc-wide > .mv-md table { width: max-content; min-width: 100%; max-width: 100cqi; } .mv-md > :first-child { margin-top: 0; } .mv-md > :last-child { margin-bottom: 0; } .mv-md h1 { font-size: 30px; line-height: 1.16; letter-spacing: -0.021em; font-weight: 700; margin: 44px 0 14px; } .mv-md h2 { font-size: 22px; line-height: 1.22; letter-spacing: -0.017em; font-weight: 700; margin: 40px 0 12px; } .mv-md h3 { font-size: 18px; line-height: 1.3; letter-spacing: -0.01em; font-weight: 650; margin: 30px 0 8px; } .mv-md h4, .mv-md h5, .mv-md h6 { font-size: 15px; font-weight: 650; margin: 24px 0 6px; } .mv-md p { margin: 0 0 14px; } .mv-md ul, .mv-md ol { margin: 0 0 14px; padding-left: 26px; } .mv-md ul { list-style: disc; } .mv-md ul ul { list-style: circle; } .mv-md ol { list-style: decimal; } .mv-md li { margin-bottom: 5px; } .mv-md li::marker { color: var(--mv-faint); } .mv-md li:has(> input[type="checkbox"]) { list-style: none; margin-left: -26px; } .mv-md a { color: var(--mv-accent); text-decoration: none; } .mv-md a[data-goto] { border-bottom: 1px dashed var(--mv-accent); cursor: pointer; } .mv-md blockquote { margin: 4px 0 16px; padding: 2px 0 2px 18px; border-left: 3px solid var(--mv-text); color: var(--mv-text); } .mv-md blockquote p:last-child { margin-bottom: 0; } .mv-md code { font-family: ui-monospace, "SF Mono", Menlo, Consolas, monospace; font-size: 0.85em; background: var(--mv-surface); border: 1px solid var(--mv-line-soft); border-radius: 5px; padding: 1.5px 6px; } .mv-md pre { background: var(--mv-surface); border: 1px solid var(--mv-line-soft); border-radius: 10px; padding: 16px 18px; overflow-x: auto; margin: 0 0 16px; } .mv-md pre code { background: none; border: 0; padding: 0; font-size: 13px; line-height: 1.65; } /* tables are CONTAINED (Notion): outer border + radius, header surface, row hairlines */ .mv-md table { border-collapse: separate; border-spacing: 0; width: 100%; margin: 4px 0 18px; font-size: 14.5px; border: 1px solid var(--mv-line-soft); border-radius: 10px; overflow: hidden; } .mv-md th, .mv-md td { text-align: left; padding: 9px 14px; } .mv-md th { font-size: 11.5px; text-transform: uppercase; letter-spacing: 0.07em; font-weight: 600; color: var(--mv-faint); background: var(--mv-surface); border-bottom: 1px solid var(--mv-line-soft); } .mv-md td { border-bottom: 1px solid var(--mv-line-soft); } .mv-md tr:last-child td { border-bottom: 0; } .mv-md img { max-width: 100%; border-radius: 8px; } .mv-md hr { border: 0; border-top: 1px solid var(--mv-line-soft); margin: 32px 0; } .mv-md input[type="checkbox"] { appearance: auto; accent-color: var(--mv-accent); width: 15px; height: 15px; margin: 0 8px 0 0; vertical-align: -2px; } .mv-md-noimg { font-size: 13px; color: var(--mv-faint); font-style: italic; } `