ee948930fe
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
856 lines
24 KiB
TypeScript
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>
|
|
);
|
|
}
|