Files
playwriter/website/src/components/markdown.tsx
Tommy D. Rossi ee948930fe website: move placeholders to src/assets for Vite base64 inlining, add LazyVideo component
Placeholders are now generated into src/assets/placeholders/ instead of
public/ so Vite's asset pipeline processes them. Since all are < 4KB,
Vite auto-inlines them as base64 data URIs via assetsInlineLimit — zero
extra HTTP requests for placeholders.

- Static imports resolve to data:image/png;base64,... at build time
- JSDoc on placeholder props documents why static imports are required
  (synchronous availability, Vite inlining) and warns against dynamic
  imports and public/ paths
- New LazyVideo component: same pixelated poster pattern, uses native
  <video preload="none" loading="lazy"> for zero-JS lazy loading
2026-02-21 15:58:21 +01:00

856 lines
24 KiB
TypeScript

/*
* Editorial markdown components.
*
* All components use CSS variables from globals.css (no prefix).
* Conflicting names with shadcn: --brand-primary, --brand-secondary,
* --link-accent, --page-border.
*/
import { useCallback, useEffect, useRef, useState } from "react";
import Prism from "prismjs";
import "prismjs/components/prism-jsx";
import "prismjs/components/prism-tsx";
import "prismjs/components/prism-bash";
/* =========================================================================
TOC sidebar (fixed left)
========================================================================= */
function useActiveTocId() {
const [activeId, setActiveId] = useState("");
useEffect(() => {
const headings = document.querySelectorAll<HTMLElement>("h1[id]");
if (headings.length === 0) {
return;
}
const observer = new IntersectionObserver(
(entries) => {
const visible: string[] = [];
entries.forEach((entry) => {
if (entry.isIntersecting && entry.target.id) {
visible.push(entry.target.id);
}
});
if (visible.length > 0) {
const sorted = visible.sort((a, b) => {
const elA = document.getElementById(a);
const elB = document.getElementById(b);
if (!elA || !elB) {
return 0;
}
return elA.getBoundingClientRect().top - elB.getBoundingClientRect().top;
});
setActiveId(sorted[sorted.length - 1]);
}
},
{
rootMargin: "-80px 0px -75% 0px",
threshold: 0,
},
);
headings.forEach((heading) => {
observer.observe(heading);
});
return () => {
observer.disconnect();
};
}, []);
return activeId;
}
export function TableOfContents({
items,
logo,
}: {
items: Array<{ label: string; href: string }>;
logo?: string;
}) {
const activeId = useActiveTocId();
return (
<aside
className="fixed top-[80px] hidden lg:block"
style={{ left: "max(1rem, calc((100vw - 550px) / 2 - 200px))", width: "122px" }}
>
<nav>
<a
href="/"
className="no-underline transition-colors block"
style={{
fontSize: "14px",
fontWeight: 700,
lineHeight: "20px",
letterSpacing: "-0.09px",
padding: "4px 0",
color: "var(--text-primary)",
fontFamily: "var(--font-primary)",
marginBottom: "8px",
}}
onMouseEnter={(e) => {
e.currentTarget.style.color = "var(--text-hover)";
}}
onMouseLeave={(e) => {
e.currentTarget.style.color = "var(--text-primary)";
}}
>
{logo ?? "index"}
</a>
{items.map((item) => {
const isActive = `#${activeId}` === item.href;
const defaultColor = isActive ? "var(--text-primary)" : "var(--text-secondary)";
return (
<a
key={item.href}
href={item.href}
className="block no-underline"
style={{
fontSize: "13px",
fontWeight: 475,
lineHeight: "15.6px",
letterSpacing: "-0.04px",
padding: "5px 0",
color: defaultColor,
fontFamily: "var(--font-primary)",
transition: "color 0.15s ease",
}}
onMouseEnter={(e) => {
if (!isActive) {
e.currentTarget.style.color = "var(--text-hover)";
}
}}
onMouseLeave={(e) => {
e.currentTarget.style.color = defaultColor;
}}
>
{item.label}
</a>
);
})}
</nav>
</aside>
);
}
/* =========================================================================
Back button (fixed top-right)
========================================================================= */
export function BackButton() {
return (
<a
href="/"
className="fixed top-5 right-5 z-[100000] flex items-center justify-center w-10 h-10 rounded-full no-underline"
style={{
background: "var(--btn-bg)",
color: "var(--text-secondary)",
boxShadow: "var(--btn-shadow)",
transition: "color 0.15s, transform 0.15s",
}}
onMouseEnter={(e) => {
e.currentTarget.style.color = "var(--text-hover)";
e.currentTarget.style.transform = "scale(1.05)";
}}
onMouseLeave={(e) => {
e.currentTarget.style.color = "var(--text-secondary)";
e.currentTarget.style.transform = "scale(1)";
}}
onMouseDown={(e) => {
e.currentTarget.style.transform = "scale(0.95)";
}}
onMouseUp={(e) => {
e.currentTarget.style.transform = "scale(1.05)";
}}
>
<svg width="14" height="14" viewBox="0 0 14 14" fill="none">
<path
d="M12.25 7H1.75M1.75 7L6.125 2.625M1.75 7L6.125 11.375"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
</a>
);
}
/* =========================================================================
Typography
========================================================================= */
export function SectionHeading({ id, children }: { id: string; children: React.ReactNode }) {
return (
<h1
id={id}
className="scroll-mt-[5.25rem]"
style={{
fontFamily: "var(--font-primary)",
fontSize: "14px",
fontWeight: 560,
lineHeight: "20px",
letterSpacing: "-0.09px",
color: "var(--text-primary)",
margin: 0,
padding: 0,
display: "flex",
alignItems: "center",
gap: "12px",
paddingTop: "24px",
paddingBottom: "24px",
}}
>
<span style={{ whiteSpace: "nowrap" }}>{children}</span>
<span style={{ flex: 1, height: "1px", background: "var(--divider)" }} />
</h1>
);
}
export function P({ children, className = "" }: { children: React.ReactNode; className?: string }) {
return (
<p
className={`editorial-prose ${className}`}
style={{
fontFamily: "var(--font-primary)",
fontSize: "14px",
fontWeight: 475,
lineHeight: "22px",
letterSpacing: "-0.09px",
color: "var(--text-primary)",
opacity: 0.82,
margin: 0,
}}
>
{children}
</p>
);
}
export function Caption({ children }: { children: React.ReactNode }) {
return (
<p
style={{
fontFamily: "var(--font-primary)",
fontSize: "12px",
fontWeight: 475,
textAlign: "center",
lineHeight: "20px",
letterSpacing: "-0.09px",
color: "var(--text-secondary)",
margin: 0,
}}
>
{children}
</p>
);
}
export function A({ href, children }: { href: string; children: React.ReactNode }) {
return (
<a
href={href}
target="_blank"
rel="noopener noreferrer"
style={{
color: "var(--link-accent, #0969da)",
fontWeight: 600,
textDecoration: "none",
}}
onMouseEnter={(e) => {
e.currentTarget.style.textDecoration = "underline";
}}
onMouseLeave={(e) => {
e.currentTarget.style.textDecoration = "none";
}}
>
{children}
</a>
);
}
export function Code({ children }: { children: React.ReactNode }) {
return (
<code className="inline-code">
{children}
</code>
);
}
/* =========================================================================
Layout
========================================================================= */
export function Divider() {
return (
<div style={{ padding: "24px 0", display: "flex", alignItems: "center" }}>
<div style={{ height: "1px", background: "var(--divider)", flex: 1 }} />
</div>
);
}
export function Section({ id, title, children }: { id: string; title: string; children: React.ReactNode }) {
return (
<>
<SectionHeading id={id}>{title}</SectionHeading>
{children}
</>
);
}
export function OL({ children }: { children: React.ReactNode }) {
return (
<ol
className="m-0 pl-5"
style={{
fontFamily: "var(--font-primary)",
fontSize: "14px",
fontWeight: 475,
lineHeight: "20px",
letterSpacing: "-0.09px",
color: "var(--text-primary)",
listStyleType: "decimal",
}}
>
{children}
</ol>
);
}
export function List({ children }: { children: React.ReactNode }) {
return (
<ul
className="m-0 pl-5"
style={{
fontFamily: "var(--font-primary)",
fontSize: "14px",
fontWeight: 475,
lineHeight: "20px",
letterSpacing: "-0.09px",
color: "var(--text-primary)",
listStyleType: "disc",
}}
>
{children}
</ul>
);
}
export function Li({ children }: { children: React.ReactNode }) {
return <li style={{ padding: "0 0 8px 12px" }}>{children}</li>;
}
/* =========================================================================
Code block with Prism syntax highlighting and line numbers
========================================================================= */
export function CodeBlock({ children, lang = "jsx", lineHeight = "1.85" }: { children: string; lang?: string; lineHeight?: string }) {
const codeRef = useRef<HTMLElement>(null);
const lines = children.split("\n");
useEffect(() => {
if (codeRef.current) {
Prism.highlightElement(codeRef.current);
}
}, [children]);
return (
<figure className="m-0 bleed">
<div className="relative">
<pre
className="overflow-x-auto"
style={{
// background: "var(--code-bg)",
borderRadius: "8px",
margin: 0,
padding: 0,
}}
>
<div
className="flex"
style={{
padding: "12px 8px 8px",
fontFamily: "var(--font-code)",
fontSize: "12px",
fontWeight: 400,
lineHeight,
letterSpacing: "normal",
color: "var(--text-primary)",
tabSize: 2,
}}
>
<span
className="select-none shrink-0"
aria-hidden="true"
style={{
color: "var(--code-line-nr)",
textAlign: "right",
paddingRight: "20px",
width: "36px",
userSelect: "none",
}}
>
{lines.map((_, i) => {
return (
<span key={i} className="block">
{i + 1}
</span>
);
})}
</span>
<code
ref={codeRef}
className={`language-${lang}`}
style={{ whiteSpace: "pre", background: "none", padding: 0 }}
>
{children}
</code>
</div>
</pre>
</div>
</figure>
);
}
/* =========================================================================
Pixelated placeholder image
Uses a tiny pre-generated image with CSS image-rendering: pixelated
(nearest-neighbor / point sampling in GPU terms) for a crisp mosaic
effect. The real image fades in on top once loaded — no flash because
the placeholder stays underneath and the real image starts at opacity 0.
========================================================================= */
export function PixelatedImage({
src,
placeholder,
alt,
width,
height,
className = "",
style,
}: {
src: string;
/**
* URL of the tiny pixelated placeholder image. Use a static import so Vite
* inlines it as a base64 data URI (all placeholders are < 4KB, well under
* Vite's default assetsInlineLimit of 4096 bytes). This makes the
* placeholder available synchronously on first render with zero HTTP
* requests. Do NOT use dynamic imports or public/ paths — dynamic imports
* add a microtask delay, and public/ files bypass Vite's asset pipeline.
*
* @example
* ```tsx
* import placeholderScreenshot from "../assets/placeholders/placeholder-screenshot@2x.png";
* <PixelatedImage placeholder={placeholderScreenshot} src="/screenshot@2x.png" ... />
* ```
*/
placeholder: string;
alt: string;
width: number;
height: number;
className?: string;
style?: React.CSSProperties;
}) {
const [loaded, setLoaded] = useState(false);
// Handles both the normal onLoad event and the case where the image is
// already cached (img.complete is true before React mounts the handler).
const imgRef = useCallback((img: HTMLImageElement | null) => {
if (img?.complete && img.naturalWidth > 0) {
setLoaded(true);
}
}, []);
return (
<div
className={className}
style={{
position: "relative",
width: "100%",
maxWidth: `${width}px`,
aspectRatio: `${width} / ${height}`,
overflow: "hidden",
...style,
}}
>
{/* Placeholder: tiny image rendered with nearest-neighbor sampling */}
<img
src={placeholder}
alt=""
aria-hidden
width={width}
height={height}
style={{
position: "absolute",
inset: 0,
width: "100%",
height: "100%",
objectFit: "cover",
imageRendering: "pixelated",
zIndex: 0,
}}
/>
{/* Real image: starts invisible, fades in over the placeholder */}
<img
ref={imgRef}
src={src}
alt={alt}
width={width}
height={height}
onLoad={() => {
setLoaded(true);
}}
style={{
position: "relative",
width: "100%",
height: "100%",
objectFit: "cover",
opacity: loaded ? 1 : 0,
transition: "opacity 0.4s ease",
zIndex: 1,
}}
/>
</div>
);
}
/* =========================================================================
Lazy video with pixelated poster placeholder
Same visual pattern as PixelatedImage but for <video> elements.
Poster layers (pixelated → real) show through the transparent video element.
Video uses native loading="lazy" + preload="none" so zero bytes are
downloaded until the element is near the viewport and the user clicks play.
No custom IntersectionObserver needed — all native HTML attributes.
========================================================================= */
export function LazyVideo({
src,
poster,
placeholderPoster,
width,
height,
type = "video/mp4",
className = "",
style,
}: {
src: string;
poster: string;
/**
* URL of the tiny pixelated poster placeholder. Use a static import so Vite
* inlines it as a base64 data URI (all placeholders are < 4KB, well under
* Vite's default assetsInlineLimit of 4096 bytes). This makes the
* placeholder available synchronously on first render with zero HTTP
* requests. Do NOT use dynamic imports or public/ paths — dynamic imports
* add a microtask delay, and public/ files bypass Vite's asset pipeline.
*
* @example
* ```tsx
* import placeholderPoster from "../assets/placeholders/placeholder-demo-poster.png";
* <LazyVideo placeholderPoster={placeholderPoster} poster="/demo-poster.png" ... />
* ```
*/
placeholderPoster: string;
width: number;
height: number;
type?: string;
className?: string;
style?: React.CSSProperties;
}) {
const [posterLoaded, setPosterLoaded] = useState(false);
// Handles cached poster images (same pattern as PixelatedImage)
const posterRef = useCallback((img: HTMLImageElement | null) => {
if (img?.complete && img.naturalWidth > 0) {
setPosterLoaded(true);
}
}, []);
return (
<div
className={className}
style={{
position: "relative",
width: "100%",
maxWidth: `${width}px`,
aspectRatio: `${width} / ${height}`,
overflow: "hidden",
...style,
}}
>
{/* Pixelated poster placeholder: loads instantly (~500 bytes) */}
<img
src={placeholderPoster}
alt=""
aria-hidden
width={width}
height={height}
style={{
position: "absolute",
inset: 0,
width: "100%",
height: "100%",
objectFit: "cover",
imageRendering: "pixelated",
zIndex: 0,
}}
/>
{/* Real poster: fades in over the pixelated placeholder */}
<img
ref={posterRef}
src={poster}
alt=""
aria-hidden
width={width}
height={height}
onLoad={() => {
setPosterLoaded(true);
}}
style={{
position: "absolute",
inset: 0,
width: "100%",
height: "100%",
objectFit: "cover",
opacity: posterLoaded ? 1 : 0,
transition: "opacity 0.4s ease",
zIndex: 1,
}}
/>
{/* Video: transparent until playing, native lazy + no preload.
Controls float on top of poster layers. No poster attr needed
because the img layers handle the visual placeholder.
loading="lazy" is a newer HTML attr not yet in React's TS types. */}
<video
controls
preload="none"
{...{ loading: "lazy" } as React.VideoHTMLAttributes<HTMLVideoElement>}
width={width}
height={height}
style={{
position: "relative",
width: "100%",
height: "100%",
objectFit: "cover",
zIndex: 2,
background: "transparent",
}}
>
<source src={src} type={type} />
</video>
</div>
);
}
/* =========================================================================
Chart placeholder (dark box with animated line)
========================================================================= */
export function ChartPlaceholder({ height = 200, label }: { height?: number; label?: string }) {
return (
<div className="bleed">
<div
className="w-full overflow-hidden relative"
style={{
height: `${height}px`,
background: "rgb(17, 17, 17)",
}}
>
<svg
viewBox="0 0 550 200"
className="absolute inset-0 w-full h-full"
preserveAspectRatio="none"
>
<defs>
<linearGradient id="chartFill" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor="#3b82f6" stopOpacity="0.3" />
<stop offset="100%" stopColor="#3b82f6" stopOpacity="0" />
</linearGradient>
</defs>
<path
d="M0,140 C30,135 60,120 90,125 C120,130 150,100 180,95 C210,90 240,110 270,105 C300,100 330,80 360,85 C390,90 420,70 450,65 C480,60 510,75 550,60"
fill="none"
stroke="#3b82f6"
strokeWidth="2"
/>
<path
d="M0,140 C30,135 60,120 90,125 C120,130 150,100 180,95 C210,90 240,110 270,105 C300,100 330,80 360,85 C390,90 420,70 450,65 C480,60 510,75 550,60 L550,200 L0,200 Z"
fill="url(#chartFill)"
/>
<circle cx="550" cy="60" r="4" fill="#3b82f6">
<animate attributeName="r" values="4;6;4" dur="2s" repeatCount="indefinite" />
<animate attributeName="opacity" values="1;0.6;1" dur="2s" repeatCount="indefinite" />
</circle>
</svg>
{label && (
<div
className="absolute top-3 right-3 px-2 py-1 rounded text-xs"
style={{
background: "rgba(59, 130, 246, 0.15)",
color: "#3b82f6",
fontFamily: "var(--font-code)",
fontWeight: 475,
fontSize: "11px",
}}
>
{label}
</div>
)}
</div>
</div>
);
}
/* =========================================================================
Comparison table
========================================================================= */
export function ComparisonTable({
title,
headers,
rows,
}: {
title?: string;
headers: [string, string, string];
rows: Array<[string, string, string]>;
}) {
return (
<div className="w-full max-w-full overflow-x-auto" style={{ padding: "8px 0" }}>
{title && (
<div
style={{
fontFamily: "var(--font-primary)",
fontSize: "11px",
fontWeight: 400,
color: "var(--text-muted)",
textTransform: "uppercase",
letterSpacing: "0.02em",
padding: "0 0 6px",
}}
>
{title}
</div>
)}
<table
className="w-full"
style={{
borderSpacing: 0,
borderCollapse: "collapse",
}}
>
<thead>
<tr>
{headers.map((header) => {
return (
<th
key={header}
className="text-left"
style={{
padding: "4.8px 12px 4.8px 0",
fontSize: "11px",
fontWeight: 400,
fontFamily: "var(--font-primary)",
color: "var(--text-muted)",
borderBottom: "1px solid var(--page-border)",
}}
>
{header}
</th>
);
})}
</tr>
</thead>
<tbody>
{rows.map(([feature, them, us]) => {
return (
<tr key={feature}>
<td
style={{
padding: "4.8px 12px 4.8px 0",
fontSize: "11px",
fontWeight: 500,
fontFamily: "var(--font-code)",
color: "var(--text-primary)",
borderBottom: "1px solid var(--page-border)",
whiteSpace: "nowrap",
}}
>
{feature}
</td>
<td
style={{
padding: "4.8px 12px 4.8px 0",
fontSize: "11px",
fontWeight: 500,
fontFamily: "var(--font-code)",
color: "var(--text-primary)",
borderBottom: "1px solid var(--page-border)",
whiteSpace: "nowrap",
}}
>
{them}
</td>
<td
style={{
padding: "4.8px 12px 4.8px 0",
fontSize: "11px",
fontWeight: 500,
fontFamily: "var(--font-code)",
color: "var(--text-primary)",
borderBottom: "1px solid var(--page-border)",
}}
>
{us}
</td>
</tr>
);
})}
</tbody>
</table>
</div>
);
}
/* =========================================================================
Page shell — wraps content with layout, TOC, back button, animations
========================================================================= */
export function EditorialPage({
toc,
logo,
children,
}: {
toc: Array<{ label: string; href: string }>;
logo?: string;
children: React.ReactNode;
}) {
return (
<div
className="editorial-page relative min-h-screen overflow-x-hidden"
style={{
background: "var(--bg)",
color: "var(--text-primary)",
fontFamily: "var(--font-primary)",
WebkitFontSmoothing: "antialiased",
textRendering: "optimizeLegibility",
}}
>
<TableOfContents items={toc} logo={logo} />
<div
className="mx-auto"
style={{ width: "550px", maxWidth: "calc(100% - 2rem)", padding: "0 1rem 6rem" }}
>
<div style={{ height: "80px" }} />
<article className="editorial-article flex flex-col gap-[32px]">
{children}
</article>
</div>
</div>
);
}