) : (
@@ -426,26 +419,24 @@ const GalleryCard = ({ example, index, model, onOpen }: { example: typeof EXAMPL
)}
{/* Hover Overlay for Navigation */}
-
+ {/* Content */}
+
{example.categories.map((cat: string) => (
@@ -453,16 +444,13 @@ const GalleryCard = ({ example, index, model, onOpen }: { example: typeof EXAMPL
))}
-
+
{example.label}
"{example.prompt}"
-
- {/* Decorative Corner */}
-
);
};
diff --git a/doc/public/widgets/bar_chart_revenue.js b/doc/public/widgets/bar_chart_revenue.js
new file mode 100644
index 0000000..fa66eee
--- /dev/null
+++ b/doc/public/widgets/bar_chart_revenue.js
@@ -0,0 +1,186 @@
+export default function BarChartRevenue({ model, React }) {
+ const { useState, useEffect, useRef, useCallback } = React;
+
+ const data = [
+ { region: 'North America', revenue: 4200 },
+ { region: 'Europe', revenue: 3100 },
+ { region: 'Asia Pacific', revenue: 2800 },
+ { region: 'Latin America', revenue: 1400 },
+ { region: 'Middle East', revenue: 950 },
+ ];
+
+ const palette = ['#f97316', '#4a7c8a', '#c4a35a', '#a0522d', '#6b7b8d'];
+
+ const [hovered, setHovered] = useState(null);
+ const [animProgress, setAnimProgress] = useState(0);
+ const animRef = useRef(null);
+
+ useEffect(() => {
+ const start = performance.now();
+ const duration = 900;
+ function tick(now) {
+ const t = Math.min((now - start) / duration, 1);
+ const ease = 1 - Math.pow(1 - t, 3);
+ setAnimProgress(ease);
+ if (t < 1) animRef.current = requestAnimationFrame(tick);
+ }
+ animRef.current = requestAnimationFrame(tick);
+ return () => cancelAnimationFrame(animRef.current);
+ }, []);
+
+ const maxRevenue = Math.max(...data.map(d => d.revenue));
+ const margin = { top: 32, right: 24, bottom: 64, left: 100 };
+ const width = 560;
+ const height = 340;
+ const plotW = width - margin.left - margin.right;
+ const plotH = height - margin.top - margin.bottom;
+ const barH = Math.min(36, (plotH / data.length) - 8);
+ const gap = (plotH - barH * data.length) / (data.length + 1);
+
+ const ticks = [0, 1000, 2000, 3000, 4000, 5000];
+
+ const fmt = v => v >= 1000 ? `$${(v / 1000).toFixed(v % 1000 === 0 ? 0 : 1)}k` : `$${v}`;
+
+ return React.createElement('div', {
+ style: {
+ width: '100%', height: '100%',
+ background: '#f7f0e6',
+ display: 'flex', alignItems: 'center', justifyContent: 'center',
+ fontFamily: "'JetBrains Mono', 'SF Mono', 'Fira Code', monospace",
+ position: 'relative', overflow: 'hidden',
+ }
+ },
+ React.createElement('svg', {
+ viewBox: `0 0 ${width} ${height}`,
+ style: { width: '100%', maxWidth: width, height: 'auto' },
+ },
+ // Title
+ React.createElement('text', {
+ x: margin.left, y: 20,
+ style: { fontSize: 14, fontWeight: 700, fill: '#1a1a1a', fontFamily: "'Space Grotesk', sans-serif" }
+ }, 'Revenue by Region'),
+
+ // Grid lines
+ ...ticks.map(tick =>
+ React.createElement('line', {
+ key: `grid-${tick}`,
+ x1: margin.left + (tick / 5000) * plotW,
+ x2: margin.left + (tick / 5000) * plotW,
+ y1: margin.top,
+ y2: margin.top + plotH,
+ stroke: '#1a1a1a',
+ strokeOpacity: 0.08,
+ strokeWidth: 1,
+ strokeDasharray: tick === 0 ? 'none' : '3,3',
+ })
+ ),
+
+ // X axis ticks
+ ...ticks.map(tick =>
+ React.createElement('text', {
+ key: `xtick-${tick}`,
+ x: margin.left + (tick / 5000) * plotW,
+ y: margin.top + plotH + 20,
+ textAnchor: 'middle',
+ style: { fontSize: 10, fill: '#1a1a1a', opacity: 0.5, fontFamily: 'inherit' }
+ }, fmt(tick))
+ ),
+
+ // Axis line
+ React.createElement('line', {
+ x1: margin.left, x2: margin.left,
+ y1: margin.top, y2: margin.top + plotH,
+ stroke: '#1a1a1a', strokeWidth: 2,
+ }),
+
+ // Bars
+ ...data.map((d, i) => {
+ const y = margin.top + gap + i * (barH + gap);
+ const barWidth = (d.revenue / 5000) * plotW * animProgress;
+ const isHov = hovered === i;
+
+ return React.createElement('g', { key: d.region },
+ // Region label
+ React.createElement('text', {
+ x: margin.left - 8,
+ y: y + barH / 2 + 1,
+ textAnchor: 'end',
+ dominantBaseline: 'middle',
+ style: {
+ fontSize: 11, fill: '#1a1a1a',
+ fontWeight: isHov ? 700 : 400,
+ fontFamily: 'inherit',
+ transition: 'font-weight 0.15s',
+ },
+ }, d.region),
+
+ // Bar shadow
+ React.createElement('rect', {
+ x: margin.left + 3,
+ y: y + 3,
+ width: barWidth,
+ height: barH,
+ rx: 3,
+ fill: '#1a1a1a',
+ opacity: 0.12,
+ }),
+
+ // Bar
+ React.createElement('rect', {
+ x: margin.left,
+ y: y,
+ width: barWidth,
+ height: barH,
+ rx: 3,
+ fill: palette[i],
+ stroke: isHov ? '#1a1a1a' : 'none',
+ strokeWidth: isHov ? 2 : 0,
+ style: { cursor: 'pointer', transition: 'stroke 0.15s, filter 0.15s', filter: isHov ? 'brightness(1.08)' : 'none' },
+ onMouseEnter: () => setHovered(i),
+ onMouseLeave: () => setHovered(null),
+ }),
+
+ // Value label
+ animProgress > 0.5 && React.createElement('text', {
+ x: margin.left + barWidth + 8,
+ y: y + barH / 2 + 1,
+ dominantBaseline: 'middle',
+ style: {
+ fontSize: 11,
+ fill: '#1a1a1a',
+ fontWeight: 600,
+ fontFamily: 'inherit',
+ opacity: Math.min(1, (animProgress - 0.5) * 4),
+ }
+ }, fmt(d.revenue)),
+ );
+ }),
+
+ // X axis label
+ React.createElement('text', {
+ x: margin.left + plotW / 2,
+ y: height - 8,
+ textAnchor: 'middle',
+ style: { fontSize: 10, fill: '#1a1a1a', opacity: 0.4, fontFamily: 'inherit', textTransform: 'uppercase', letterSpacing: 1.5 },
+ }, 'Revenue (USD)'),
+ ),
+
+ // Tooltip
+ hovered !== null && React.createElement('div', {
+ style: {
+ position: 'absolute', top: 12, right: 16,
+ background: '#f7f0e6',
+ border: '2px solid #1a1a1a',
+ borderRadius: 6,
+ padding: '8px 12px',
+ boxShadow: '3px 3px 0 0 #1a1a1a',
+ fontFamily: 'inherit',
+ fontSize: 11,
+ zIndex: 10,
+ }
+ },
+ React.createElement('div', { style: { fontWeight: 700, marginBottom: 2 } }, data[hovered].region),
+ React.createElement('div', { style: { color: palette[hovered] } }, fmt(data[hovered].revenue)),
+ ),
+ );
+}
diff --git a/doc/public/widgets/scatter_actions.js b/doc/public/widgets/scatter_actions.js
new file mode 100644
index 0000000..d847699
--- /dev/null
+++ b/doc/public/widgets/scatter_actions.js
@@ -0,0 +1,183 @@
+export default function ScatterActions({ model, React }) {
+ const { useState, useEffect, useMemo, useCallback } = React;
+
+ const basePoints = useMemo(() => {
+ const seed = 77;
+ const rng = (() => { let s = seed; return () => { s = (s * 16807 + 0) % 2147483647; return s / 2147483647; }; })();
+ return Array.from({ length: 40 }, (_, i) => ({
+ x: 10 + rng() * 80,
+ y: 10 + rng() * 80,
+ id: i,
+ }));
+ }, []);
+
+ const [zoom, setZoom] = useState({ x: 0, y: 0, scale: 1 });
+ const [hovered, setHovered] = useState(null);
+ const [animProgress, setAnimProgress] = useState(0);
+ const [resetFlash, setResetFlash] = useState(false);
+
+ useEffect(() => {
+ const start = performance.now();
+ let raf;
+ function tick(now) {
+ const t = Math.min((now - start) / 700, 1);
+ setAnimProgress(1 - Math.pow(1 - t, 3));
+ if (t < 1) raf = requestAnimationFrame(tick);
+ }
+ raf = requestAnimationFrame(tick);
+ return () => cancelAnimationFrame(raf);
+ }, []);
+
+ const m = { top: 36, right: 20, bottom: 44, left: 48 };
+ const w = 480, h = 340;
+ const pw = w - m.left - m.right;
+ const ph = h - m.top - m.bottom;
+
+ const zoomIn = useCallback((cx, cy) => {
+ setZoom(z => ({
+ x: cx - (cx - z.x) / 1.4,
+ y: cy - (cy - z.y) / 1.4,
+ scale: Math.min(z.scale * 1.4, 5),
+ }));
+ }, []);
+
+ const resetView = useCallback(() => {
+ setZoom({ x: 0, y: 0, scale: 1 });
+ setResetFlash(true);
+ setTimeout(() => setResetFlash(false), 400);
+ }, []);
+
+ const sx = v => m.left + ((v / 100) * pw * zoom.scale) + zoom.x;
+ const sy = v => m.top + ph - ((v / 100) * ph * zoom.scale) - zoom.y;
+
+ return React.createElement('div', {
+ style: {
+ width: '100%', height: '100%',
+ background: '#f7f0e6',
+ display: 'flex', alignItems: 'center', justifyContent: 'center',
+ fontFamily: "'JetBrains Mono', 'SF Mono', monospace",
+ position: 'relative',
+ }
+ },
+ React.createElement('svg', {
+ viewBox: `0 0 ${w} ${h}`,
+ style: { width: '100%', maxWidth: w, height: 'auto' },
+ },
+ // Title
+ React.createElement('text', {
+ x: m.left, y: 22,
+ style: { fontSize: 13, fontWeight: 700, fill: '#1a1a1a', fontFamily: "'Space Grotesk', sans-serif" },
+ }, 'Interactive Scatter — Click to Zoom'),
+
+ // Clip path for plot area
+ React.createElement('defs', null,
+ React.createElement('clipPath', { id: 'plot-clip' },
+ React.createElement('rect', { x: m.left, y: m.top, width: pw, height: ph }),
+ ),
+ ),
+
+ // Background flash on reset
+ resetFlash && React.createElement('rect', {
+ x: m.left, y: m.top, width: pw, height: ph,
+ fill: '#f97316', fillOpacity: 0.06,
+ style: { transition: 'fill-opacity 0.4s' },
+ }),
+
+ // Grid (clipped)
+ React.createElement('g', { clipPath: 'url(#plot-clip)' },
+ ...[0, 25, 50, 75, 100].flatMap(v => [
+ React.createElement('line', {
+ key: `gx-${v}`, x1: sx(v), x2: sx(v), y1: m.top, y2: m.top + ph,
+ stroke: '#1a1a1a', strokeOpacity: 0.06, strokeDasharray: '2,3',
+ }),
+ React.createElement('line', {
+ key: `gy-${v}`, x1: m.left, x2: m.left + pw, y1: sy(v), y2: sy(v),
+ stroke: '#1a1a1a', strokeOpacity: 0.06, strokeDasharray: '2,3',
+ }),
+ ]),
+ ),
+
+ // Axes
+ React.createElement('line', { x1: m.left, x2: m.left + pw, y1: m.top + ph, y2: m.top + ph, stroke: '#1a1a1a', strokeWidth: 2 }),
+ React.createElement('line', { x1: m.left, x2: m.left, y1: m.top, y2: m.top + ph, stroke: '#1a1a1a', strokeWidth: 2 }),
+
+ // Tick labels
+ ...[0, 25, 50, 75, 100].flatMap(v => [
+ React.createElement('text', { key: `xt-${v}`, x: sx(v), y: m.top + ph + 16, textAnchor: 'middle', style: { fontSize: 9, fill: '#1a1a1a', opacity: 0.4 } }, v),
+ React.createElement('text', { key: `yt-${v}`, x: m.left - 6, y: sy(v) + 3, textAnchor: 'end', style: { fontSize: 9, fill: '#1a1a1a', opacity: 0.4 } }, v),
+ ]),
+
+ // Points (clipped)
+ React.createElement('g', { clipPath: 'url(#plot-clip)' },
+ ...basePoints.map((p, i) =>
+ React.createElement('circle', {
+ key: p.id,
+ cx: sx(p.x),
+ cy: sy(p.y) + (1 - animProgress) * 25,
+ r: hovered === i ? 7 : 5,
+ fill: hovered === i ? '#f97316' : '#4a7c8a',
+ fillOpacity: hovered === i ? 0.95 : 0.7 * animProgress,
+ stroke: hovered === i ? '#1a1a1a' : '#4a7c8a',
+ strokeWidth: hovered === i ? 2 : 0.5,
+ strokeOpacity: 0.3,
+ style: { cursor: 'pointer', transition: 'cx 0.4s, cy 0.4s, r 0.12s, fill 0.15s' },
+ onMouseEnter: () => setHovered(i),
+ onMouseLeave: () => setHovered(null),
+ onClick: () => zoomIn(sx(p.x), sy(p.y)),
+ })
+ ),
+ ),
+
+ // Zoom level indicator
+ zoom.scale > 1 && React.createElement('g', null,
+ React.createElement('rect', {
+ x: w - m.right - 54, y: m.top + 2, width: 48, height: 18, rx: 9,
+ fill: '#f97316', fillOpacity: 0.12, stroke: '#f97316', strokeWidth: 1,
+ }),
+ React.createElement('text', {
+ x: w - m.right - 30, y: m.top + 14, textAnchor: 'middle',
+ style: { fontSize: 9, fill: '#f97316', fontWeight: 700 },
+ }, `${zoom.scale.toFixed(1)}x`),
+ ),
+ ),
+
+ // Action button — Reset View
+ React.createElement('button', {
+ onClick: resetView,
+ style: {
+ position: 'absolute',
+ bottom: 16, right: 16,
+ background: zoom.scale > 1 ? '#f97316' : '#f7f0e6',
+ color: zoom.scale > 1 ? '#fff' : '#1a1a1a',
+ border: '2px solid #1a1a1a',
+ borderRadius: 6,
+ padding: '6px 14px',
+ fontSize: 11,
+ fontWeight: 700,
+ fontFamily: "'JetBrains Mono', monospace",
+ cursor: 'pointer',
+ boxShadow: '2px 2px 0 0 #1a1a1a',
+ transition: 'background 0.2s, color 0.2s, transform 0.1s',
+ },
+ onMouseDown: (e) => { e.currentTarget.style.transform = 'translate(1px, 1px)'; e.currentTarget.style.boxShadow = '1px 1px 0 0 #1a1a1a'; },
+ onMouseUp: (e) => { e.currentTarget.style.transform = ''; e.currentTarget.style.boxShadow = '2px 2px 0 0 #1a1a1a'; },
+ }, 'Reset View'),
+
+ // Tooltip
+ hovered !== null && React.createElement('div', {
+ style: {
+ position: 'absolute', top: 12, right: 16,
+ background: '#f7f0e6',
+ border: '2px solid #1a1a1a',
+ borderRadius: 6,
+ padding: '6px 10px',
+ boxShadow: '3px 3px 0 0 #1a1a1a',
+ fontSize: 11,
+ }
+ },
+ React.createElement('div', { style: { opacity: 0.5 } }, `Point ${basePoints[hovered].id}`),
+ React.createElement('div', null, `x: ${basePoints[hovered].x.toFixed(1)} y: ${basePoints[hovered].y.toFixed(1)}`),
+ React.createElement('div', { style: { color: '#f97316', fontSize: 9, marginTop: 2 } }, 'Click to zoom'),
+ ),
+ );
+}
diff --git a/doc/public/widgets/scatter_brush.js b/doc/public/widgets/scatter_brush.js
new file mode 100644
index 0000000..b8f2101
--- /dev/null
+++ b/doc/public/widgets/scatter_brush.js
@@ -0,0 +1,165 @@
+export default function ScatterBrush({ model, React }) {
+ const { useState, useEffect, useRef, useCallback, useMemo } = React;
+
+ const points = useMemo(() => {
+ const seed = 17;
+ const rng = (() => { let s = seed; return () => { s = (s * 16807 + 0) % 2147483647; return s / 2147483647; }; })();
+ return Array.from({ length: 50 }, (_, i) => ({
+ x: 10 + rng() * 80,
+ y: 10 + rng() * 80,
+ size: 3 + rng() * 4,
+ id: i,
+ }));
+ }, []);
+
+ const [brush, setBrush] = useState(null);
+ const [brushing, setBrushing] = useState(false);
+ const [brushStart, setBrushStart] = useState(null);
+ const [animProgress, setAnimProgress] = useState(0);
+ const svgRef = useRef(null);
+
+ useEffect(() => {
+ const start = performance.now();
+ let raf;
+ function tick(now) {
+ const t = Math.min((now - start) / 800, 1);
+ setAnimProgress(1 - Math.pow(1 - t, 3));
+ if (t < 1) raf = requestAnimationFrame(tick);
+ }
+ raf = requestAnimationFrame(tick);
+ return () => cancelAnimationFrame(raf);
+ }, []);
+
+ const m = { top: 32, right: 20, bottom: 40, left: 44 };
+ const w = 480, h = 340;
+ const pw = w - m.left - m.right;
+ const ph = h - m.top - m.bottom;
+
+ const sx = v => m.left + (v / 100) * pw;
+ const sy = v => m.top + ph - (v / 100) * ph;
+
+ const selected = useMemo(() => {
+ if (!brush) return new Set();
+ const [x1, y1, x2, y2] = [
+ Math.min(brush.x1, brush.x2), Math.min(brush.y1, brush.y2),
+ Math.max(brush.x1, brush.x2), Math.max(brush.y1, brush.y2),
+ ];
+ return new Set(points.filter(p => {
+ const px = sx(p.x), py = sy(p.y);
+ return px >= x1 && px <= x2 && py >= y1 && py <= y2;
+ }).map(p => p.id));
+ }, [brush, points]);
+
+ const getSvgPoint = useCallback((e) => {
+ const svg = svgRef.current;
+ if (!svg) return null;
+ const rect = svg.getBoundingClientRect();
+ return {
+ x: ((e.clientX - rect.left) / rect.width) * w,
+ y: ((e.clientY - rect.top) / rect.height) * h,
+ };
+ }, []);
+
+ const onMouseDown = useCallback((e) => {
+ const pt = getSvgPoint(e);
+ if (!pt) return;
+ setBrushing(true);
+ setBrushStart(pt);
+ setBrush({ x1: pt.x, y1: pt.y, x2: pt.x, y2: pt.y });
+ }, [getSvgPoint]);
+
+ const onMouseMove = useCallback((e) => {
+ if (!brushing || !brushStart) return;
+ const pt = getSvgPoint(e);
+ if (!pt) return;
+ setBrush({ x1: brushStart.x, y1: brushStart.y, x2: pt.x, y2: pt.y });
+ }, [brushing, brushStart, getSvgPoint]);
+
+ const onMouseUp = useCallback(() => {
+ setBrushing(false);
+ if (brush && Math.abs(brush.x2 - brush.x1) < 4 && Math.abs(brush.y2 - brush.y1) < 4) {
+ setBrush(null);
+ }
+ }, [brush]);
+
+ return React.createElement('div', {
+ style: {
+ width: '100%', height: '100%',
+ background: '#f7f0e6',
+ display: 'flex', alignItems: 'center', justifyContent: 'center',
+ fontFamily: "'JetBrains Mono', 'SF Mono', monospace",
+ }
+ },
+ React.createElement('svg', {
+ ref: svgRef,
+ viewBox: `0 0 ${w} ${h}`,
+ style: { width: '100%', maxWidth: w, height: 'auto', cursor: 'crosshair', userSelect: 'none' },
+ onMouseDown, onMouseMove, onMouseUp,
+ onMouseLeave: () => { if (brushing) onMouseUp(); },
+ },
+ React.createElement('text', {
+ x: m.left, y: 18,
+ style: { fontSize: 13, fontWeight: 700, fill: '#1a1a1a', fontFamily: "'Space Grotesk', sans-serif" },
+ }, 'Scatter Plot — Brush Selection'),
+
+ // Selection count badge
+ selected.size > 0 && React.createElement('g', null,
+ React.createElement('rect', {
+ x: w - m.right - 80, y: 6, width: 74, height: 20, rx: 10,
+ fill: '#f97316', fillOpacity: 0.12, stroke: '#f97316', strokeWidth: 1,
+ }),
+ React.createElement('text', {
+ x: w - m.right - 43, y: 19, textAnchor: 'middle',
+ style: { fontSize: 9, fill: '#f97316', fontWeight: 700 },
+ }, `${selected.size} selected`),
+ ),
+
+ // Grid
+ ...[0, 25, 50, 75, 100].flatMap(v => [
+ React.createElement('line', {
+ key: `gx-${v}`, x1: sx(v), x2: sx(v), y1: m.top, y2: m.top + ph,
+ stroke: '#1a1a1a', strokeOpacity: 0.06, strokeDasharray: '2,3',
+ }),
+ React.createElement('line', {
+ key: `gy-${v}`, x1: m.left, x2: m.left + pw, y1: sy(v), y2: sy(v),
+ stroke: '#1a1a1a', strokeOpacity: 0.06, strokeDasharray: '2,3',
+ }),
+ ]),
+
+ // Axes
+ React.createElement('line', { x1: m.left, x2: m.left + pw, y1: m.top + ph, y2: m.top + ph, stroke: '#1a1a1a', strokeWidth: 2 }),
+ React.createElement('line', { x1: m.left, x2: m.left, y1: m.top, y2: m.top + ph, stroke: '#1a1a1a', strokeWidth: 2 }),
+
+ // Tick labels
+ ...[0, 25, 50, 75, 100].flatMap(v => [
+ React.createElement('text', { key: `xt-${v}`, x: sx(v), y: m.top + ph + 16, textAnchor: 'middle', style: { fontSize: 9, fill: '#1a1a1a', opacity: 0.4 } }, v),
+ React.createElement('text', { key: `yt-${v}`, x: m.left - 6, y: sy(v) + 3, textAnchor: 'end', style: { fontSize: 9, fill: '#1a1a1a', opacity: 0.4 } }, v),
+ ]),
+
+ // Points
+ ...points.map(p => {
+ const sel = selected.size > 0;
+ const isSel = selected.has(p.id);
+ return React.createElement('circle', {
+ key: p.id,
+ cx: sx(p.x),
+ cy: sy(p.y) + (1 - animProgress) * 30,
+ r: sel ? (isSel ? p.size + 1 : p.size - 1) : p.size,
+ fill: isSel ? '#f97316' : '#4a7c8a',
+ fillOpacity: sel ? (isSel ? 0.85 : 0.12) : 0.65 * animProgress,
+ stroke: isSel ? '#1a1a1a' : 'none',
+ strokeWidth: 1.5,
+ style: { transition: 'fill-opacity 0.2s, r 0.15s, fill 0.2s, cy 0.4s', pointerEvents: 'none' },
+ });
+ }),
+
+ // Brush rect
+ brush && React.createElement('rect', {
+ x: Math.min(brush.x1, brush.x2), y: Math.min(brush.y1, brush.y2),
+ width: Math.abs(brush.x2 - brush.x1), height: Math.abs(brush.y2 - brush.y1),
+ fill: '#f97316', fillOpacity: 0.07,
+ stroke: '#f97316', strokeWidth: 1.5, strokeDasharray: '4,2', rx: 2,
+ }),
+ ),
+ );
+}
diff --git a/doc/public/widgets/scatter_brush_linked.js b/doc/public/widgets/scatter_brush_linked.js
new file mode 100644
index 0000000..b5479aa
--- /dev/null
+++ b/doc/public/widgets/scatter_brush_linked.js
@@ -0,0 +1,262 @@
+export default function ScatterBrushLinked({ model, React }) {
+ const { useState, useEffect, useRef, useCallback, useMemo } = React;
+
+ // Generate sample data
+ const points = useMemo(() => {
+ const seed = 42;
+ const rng = (() => { let s = seed; return () => { s = (s * 16807 + 0) % 2147483647; return s / 2147483647; }; })();
+ const cats = ['Group A', 'Group B', 'Group C'];
+ const colors = ['#f97316', '#4a7c8a', '#c4a35a'];
+ return Array.from({ length: 60 }, (_, i) => {
+ const cat = Math.floor(rng() * 3);
+ const cx = [35, 60, 75][cat];
+ const cy = [65, 40, 70][cat];
+ return {
+ x: cx + (rng() - 0.5) * 40,
+ y: cy + (rng() - 0.5) * 35,
+ cat: cats[cat],
+ color: colors[cat],
+ id: i,
+ };
+ });
+ }, []);
+
+ const [brush, setBrush] = useState(null);
+ const [brushing, setBrushing] = useState(false);
+ const [brushStart, setBrushStart] = useState(null);
+ const [animProgress, setAnimProgress] = useState(0);
+ const svgRef = useRef(null);
+
+ useEffect(() => {
+ const start = performance.now();
+ const duration = 700;
+ let raf;
+ function tick(now) {
+ const t = Math.min((now - start) / duration, 1);
+ setAnimProgress(1 - Math.pow(1 - t, 3));
+ if (t < 1) raf = requestAnimationFrame(tick);
+ }
+ raf = requestAnimationFrame(tick);
+ return () => cancelAnimationFrame(raf);
+ }, []);
+
+ const scatterM = { top: 28, right: 12, bottom: 36, left: 40 };
+ const histM = { top: 28, right: 12, bottom: 36, left: 40 };
+ const sw = 300, sh = 280;
+ const hw = 220, hh = 280;
+ const spw = sw - scatterM.left - scatterM.right;
+ const sph = sh - scatterM.top - scatterM.bottom;
+
+ const sx = v => scatterM.left + (v / 100) * spw;
+ const sy = v => scatterM.top + sph - (v / 100) * sph;
+
+ const selected = useMemo(() => {
+ if (!brush) return new Set();
+ const [x1, y1, x2, y2] = [
+ Math.min(brush.x1, brush.x2), Math.min(brush.y1, brush.y2),
+ Math.max(brush.x1, brush.x2), Math.max(brush.y1, brush.y2),
+ ];
+ return new Set(points.filter(p => {
+ const px = sx(p.x), py = sy(p.y);
+ return px >= x1 && px <= x2 && py >= y1 && py <= y2;
+ }).map(p => p.id));
+ }, [brush, points]);
+
+ const activePoints = selected.size > 0 ? points.filter(p => selected.has(p.id)) : points;
+
+ // Histogram bins for y values
+ const bins = useMemo(() => {
+ const nBins = 8;
+ const binSize = 100 / nBins;
+ const b = Array.from({ length: nBins }, (_, i) => ({
+ lo: i * binSize, hi: (i + 1) * binSize, count: 0,
+ }));
+ activePoints.forEach(p => {
+ const idx = Math.min(Math.floor(p.y / binSize), nBins - 1);
+ b[idx].count++;
+ });
+ return b;
+ }, [activePoints]);
+
+ const maxBin = Math.max(...bins.map(b => b.count), 1);
+ const hpw = hw - histM.left - histM.right;
+ const hph = hh - histM.top - histM.bottom;
+
+ const getSvgPoint = useCallback((e) => {
+ const svg = svgRef.current;
+ if (!svg) return null;
+ const rect = svg.getBoundingClientRect();
+ return {
+ x: ((e.clientX - rect.left) / rect.width) * sw,
+ y: ((e.clientY - rect.top) / rect.height) * sh,
+ };
+ }, []);
+
+ const onMouseDown = useCallback((e) => {
+ const pt = getSvgPoint(e);
+ if (!pt) return;
+ setBrushing(true);
+ setBrushStart(pt);
+ setBrush({ x1: pt.x, y1: pt.y, x2: pt.x, y2: pt.y });
+ }, [getSvgPoint]);
+
+ const onMouseMove = useCallback((e) => {
+ if (!brushing || !brushStart) return;
+ const pt = getSvgPoint(e);
+ if (!pt) return;
+ setBrush({ x1: brushStart.x, y1: brushStart.y, x2: pt.x, y2: pt.y });
+ }, [brushing, brushStart, getSvgPoint]);
+
+ const onMouseUp = useCallback(() => {
+ setBrushing(false);
+ if (brush && Math.abs(brush.x2 - brush.x1) < 4 && Math.abs(brush.y2 - brush.y1) < 4) {
+ setBrush(null);
+ }
+ }, [brush]);
+
+ return React.createElement('div', {
+ style: {
+ width: '100%', height: '100%',
+ background: '#f7f0e6',
+ display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 8,
+ fontFamily: "'JetBrains Mono', 'SF Mono', monospace",
+ padding: '8px 16px',
+ boxSizing: 'border-box',
+ }
+ },
+ // Scatter plot
+ React.createElement('svg', {
+ ref: svgRef,
+ viewBox: `0 0 ${sw} ${sh}`,
+ style: { flex: '1 1 58%', maxWidth: sw, height: 'auto', cursor: 'crosshair', userSelect: 'none' },
+ onMouseDown,
+ onMouseMove,
+ onMouseUp,
+ onMouseLeave: () => { if (brushing) onMouseUp(); },
+ },
+ React.createElement('text', {
+ x: scatterM.left, y: 16,
+ style: { fontSize: 12, fontWeight: 700, fill: '#1a1a1a', fontFamily: "'Space Grotesk', sans-serif" },
+ }, 'Scatter — drag to select'),
+
+ // Grid
+ ...[0, 25, 50, 75, 100].map(v =>
+ React.createElement('line', {
+ key: `gx-${v}`,
+ x1: sx(v), x2: sx(v), y1: scatterM.top, y2: scatterM.top + sph,
+ stroke: '#1a1a1a', strokeOpacity: 0.06, strokeDasharray: '2,3',
+ })
+ ),
+ ...[0, 25, 50, 75, 100].map(v =>
+ React.createElement('line', {
+ key: `gy-${v}`,
+ x1: scatterM.left, x2: scatterM.left + spw, y1: sy(v), y2: sy(v),
+ stroke: '#1a1a1a', strokeOpacity: 0.06, strokeDasharray: '2,3',
+ })
+ ),
+
+ // Axes
+ React.createElement('line', { x1: scatterM.left, x2: scatterM.left + spw, y1: scatterM.top + sph, y2: scatterM.top + sph, stroke: '#1a1a1a', strokeWidth: 2 }),
+ React.createElement('line', { x1: scatterM.left, x2: scatterM.left, y1: scatterM.top, y2: scatterM.top + sph, stroke: '#1a1a1a', strokeWidth: 2 }),
+
+ // Axis labels
+ ...[0, 50, 100].map(v =>
+ React.createElement('text', { key: `xl-${v}`, x: sx(v), y: scatterM.top + sph + 16, textAnchor: 'middle', style: { fontSize: 9, fill: '#1a1a1a', opacity: 0.4 } }, v)
+ ),
+ ...[0, 50, 100].map(v =>
+ React.createElement('text', { key: `yl-${v}`, x: scatterM.left - 6, y: sy(v) + 3, textAnchor: 'end', style: { fontSize: 9, fill: '#1a1a1a', opacity: 0.4 } }, v)
+ ),
+
+ // Points
+ ...points.map(p =>
+ React.createElement('circle', {
+ key: p.id,
+ cx: sx(p.x),
+ cy: sy(p.y) + (1 - animProgress) * 20,
+ r: selected.size > 0 ? (selected.has(p.id) ? 5 : 3) : 4.5,
+ fill: p.color,
+ fillOpacity: selected.size > 0 ? (selected.has(p.id) ? 0.9 : 0.15) : 0.75 * animProgress,
+ stroke: selected.has(p.id) ? '#1a1a1a' : 'none',
+ strokeWidth: 1.5,
+ style: { transition: 'fill-opacity 0.2s, r 0.2s, cy 0.3s', pointerEvents: 'none' },
+ })
+ ),
+
+ // Brush rect
+ brush && React.createElement('rect', {
+ x: Math.min(brush.x1, brush.x2),
+ y: Math.min(brush.y1, brush.y2),
+ width: Math.abs(brush.x2 - brush.x1),
+ height: Math.abs(brush.y2 - brush.y1),
+ fill: '#f97316',
+ fillOpacity: 0.08,
+ stroke: '#f97316',
+ strokeWidth: 1.5,
+ strokeDasharray: '4,2',
+ rx: 2,
+ }),
+
+ // X axis title
+ React.createElement('text', {
+ x: scatterM.left + spw / 2, y: sh - 4, textAnchor: 'middle',
+ style: { fontSize: 9, fill: '#1a1a1a', opacity: 0.35, textTransform: 'uppercase', letterSpacing: 1.2 },
+ }, 'x value'),
+ ),
+
+ // Histogram
+ React.createElement('svg', {
+ viewBox: `0 0 ${hw} ${hh}`,
+ style: { flex: '1 1 38%', maxWidth: hw, height: 'auto' },
+ },
+ React.createElement('text', {
+ x: histM.left, y: 16,
+ style: { fontSize: 12, fontWeight: 700, fill: '#1a1a1a', fontFamily: "'Space Grotesk', sans-serif" },
+ }, selected.size > 0 ? `Distribution (${selected.size} selected)` : 'Distribution'),
+
+ // Axes
+ React.createElement('line', { x1: histM.left, x2: histM.left + hpw, y1: histM.top + hph, y2: histM.top + hph, stroke: '#1a1a1a', strokeWidth: 2 }),
+ React.createElement('line', { x1: histM.left, x2: histM.left, y1: histM.top, y2: histM.top + hph, stroke: '#1a1a1a', strokeWidth: 2 }),
+
+ // Bars
+ ...bins.map((b, i) => {
+ const barH = (b.count / Math.max(maxBin, 1)) * hph * animProgress;
+ const barW = (hpw / bins.length) - 3;
+ const bx = histM.left + i * (hpw / bins.length) + 1.5;
+ const by = histM.top + hph - barH;
+ return React.createElement('g', { key: i },
+ React.createElement('rect', {
+ x: bx + 2, y: by + 2, width: barW, height: barH,
+ rx: 2, fill: '#1a1a1a', opacity: 0.08,
+ }),
+ React.createElement('rect', {
+ x: bx, y: by, width: barW, height: barH,
+ rx: 2,
+ fill: selected.size > 0 ? '#f97316' : '#4a7c8a',
+ style: { transition: 'height 0.3s, y 0.3s, fill 0.3s' },
+ }),
+ b.count > 0 && React.createElement('text', {
+ x: bx + barW / 2, y: by - 4, textAnchor: 'middle',
+ style: { fontSize: 8, fill: '#1a1a1a', opacity: 0.5 },
+ }, b.count),
+ );
+ }),
+
+ // Bin labels
+ ...bins.filter((_, i) => i % 2 === 0).map((b, idx) => {
+ const i = idx * 2;
+ return React.createElement('text', {
+ key: `bl-${i}`,
+ x: histM.left + i * (hpw / bins.length) + (hpw / bins.length) / 2,
+ y: histM.top + hph + 14,
+ textAnchor: 'middle',
+ style: { fontSize: 8, fill: '#1a1a1a', opacity: 0.4 },
+ }, Math.round(b.lo));
+ }),
+
+ React.createElement('text', {
+ x: histM.left + hpw / 2, y: hh - 4, textAnchor: 'middle',
+ style: { fontSize: 9, fill: '#1a1a1a', opacity: 0.35, textTransform: 'uppercase', letterSpacing: 1.2 },
+ }, 'y value'),
+ ),
+ );
+}
diff --git a/doc/public/widgets/scatter_themed_ft.js b/doc/public/widgets/scatter_themed_ft.js
new file mode 100644
index 0000000..90adb54
--- /dev/null
+++ b/doc/public/widgets/scatter_themed_ft.js
@@ -0,0 +1,160 @@
+export default function ScatterThemedFT({ model, React }) {
+ const { useState, useEffect, useMemo } = React;
+
+ // Financial Times inspired: salmon pink, muted palette, clean axes
+ const ftPalette = ['#eb5a5a', '#1a6c8b', '#e8a83e', '#6b7b8d'];
+ const categories = ['Equities', 'Bonds', 'Commodities', 'FX'];
+
+ const points = useMemo(() => {
+ const seed = 31;
+ const rng = (() => { let s = seed; return () => { s = (s * 16807 + 0) % 2147483647; return s / 2147483647; }; })();
+ return Array.from({ length: 48 }, (_, i) => {
+ const cat = Math.floor(rng() * 4);
+ const cx = [25, 55, 70, 40][cat];
+ const cy = [60, 35, 65, 45][cat];
+ return {
+ x: cx + (rng() - 0.5) * 35,
+ y: cy + (rng() - 0.5) * 30,
+ cat,
+ id: i,
+ };
+ });
+ }, []);
+
+ const [hovered, setHovered] = useState(null);
+ const [animProgress, setAnimProgress] = useState(0);
+
+ useEffect(() => {
+ const start = performance.now();
+ let raf;
+ function tick(now) {
+ const t = Math.min((now - start) / 800, 1);
+ setAnimProgress(1 - Math.pow(1 - t, 3));
+ if (t < 1) raf = requestAnimationFrame(tick);
+ }
+ raf = requestAnimationFrame(tick);
+ return () => cancelAnimationFrame(raf);
+ }, []);
+
+ const m = { top: 36, right: 120, bottom: 44, left: 48 };
+ const w = 520, h = 340;
+ const pw = w - m.left - m.right;
+ const ph = h - m.top - m.bottom;
+
+ const sx = v => m.left + (v / 100) * pw;
+ const sy = v => m.top + ph - (v / 100) * ph;
+
+ const hovPt = hovered !== null ? points[hovered] : null;
+
+ return React.createElement('div', {
+ style: {
+ width: '100%', height: '100%',
+ background: '#fff1e5',
+ display: 'flex', alignItems: 'center', justifyContent: 'center',
+ fontFamily: "'Georgia', 'Times New Roman', serif",
+ position: 'relative',
+ }
+ },
+ React.createElement('svg', {
+ viewBox: `0 0 ${w} ${h}`,
+ style: { width: '100%', maxWidth: w, height: 'auto' },
+ },
+ // FT-style top rule
+ React.createElement('line', { x1: 0, x2: w, y1: 0, y2: 0, stroke: '#1a1a1a', strokeWidth: 4 }),
+
+ // Title
+ React.createElement('text', {
+ x: m.left, y: 24,
+ style: { fontSize: 15, fontWeight: 700, fill: '#1a1a1a', fontFamily: "'Georgia', serif" },
+ }, 'Asset Performance Overview'),
+
+ // Grid lines (FT uses light horizontal lines, no vertical)
+ ...[0, 25, 50, 75, 100].map(v =>
+ React.createElement('line', {
+ key: `g-${v}`,
+ x1: m.left, x2: m.left + pw,
+ y1: sy(v), y2: sy(v),
+ stroke: '#1a1a1a', strokeOpacity: v === 0 ? 0.2 : 0.07,
+ strokeWidth: v === 0 ? 1.5 : 1,
+ })
+ ),
+
+ // Y axis labels
+ ...[0, 25, 50, 75, 100].map(v =>
+ React.createElement('text', {
+ key: `yl-${v}`, x: m.left - 8, y: sy(v) + 3, textAnchor: 'end',
+ style: { fontSize: 10, fill: '#66605c', fontFamily: "'JetBrains Mono', monospace" },
+ }, v)
+ ),
+
+ // X axis labels
+ ...[0, 25, 50, 75, 100].map(v =>
+ React.createElement('text', {
+ key: `xl-${v}`, x: sx(v), y: m.top + ph + 18, textAnchor: 'middle',
+ style: { fontSize: 10, fill: '#66605c', fontFamily: "'JetBrains Mono', monospace" },
+ }, v)
+ ),
+
+ // Points
+ ...points.map((p, i) =>
+ React.createElement('circle', {
+ key: p.id,
+ cx: sx(p.x),
+ cy: sy(p.y) + (1 - animProgress) * 25,
+ r: hovered === i ? 6.5 : 4.5,
+ fill: ftPalette[p.cat],
+ fillOpacity: hovered === i ? 1 : 0.7 * animProgress,
+ stroke: hovered === i ? '#1a1a1a' : 'none',
+ strokeWidth: 1.5,
+ style: { cursor: 'pointer', transition: 'r 0.12s, fill-opacity 0.15s, cy 0.4s' },
+ onMouseEnter: () => setHovered(i),
+ onMouseLeave: () => setHovered(null),
+ })
+ ),
+
+ // Legend (FT-style, right side)
+ ...categories.map((cat, i) =>
+ React.createElement('g', { key: cat },
+ React.createElement('circle', {
+ cx: m.left + pw + 20,
+ cy: m.top + 10 + i * 22,
+ r: 5, fill: ftPalette[i],
+ }),
+ React.createElement('text', {
+ x: m.left + pw + 30,
+ y: m.top + 14 + i * 22,
+ style: { fontSize: 11, fill: '#1a1a1a', fontFamily: "'Georgia', serif" },
+ }, cat),
+ )
+ ),
+
+ // Axis titles
+ React.createElement('text', {
+ x: m.left + pw / 2, y: h - 4, textAnchor: 'middle',
+ style: { fontSize: 10, fill: '#66605c', fontStyle: 'italic' },
+ }, 'Risk Score'),
+ React.createElement('text', {
+ x: 14, y: m.top + ph / 2, textAnchor: 'middle',
+ style: { fontSize: 10, fill: '#66605c', fontStyle: 'italic' },
+ transform: `rotate(-90, 14, ${m.top + ph / 2})`,
+ }, 'Return (%)'),
+ ),
+
+ // Tooltip
+ hovPt && React.createElement('div', {
+ style: {
+ position: 'absolute', top: 48, right: 20,
+ background: '#fff1e5',
+ border: '1px solid #1a1a1a',
+ borderTop: '3px solid ' + ftPalette[hovPt.cat],
+ padding: '6px 10px',
+ fontSize: 11,
+ fontFamily: "'JetBrains Mono', monospace",
+ boxShadow: '2px 2px 0 0 rgba(26,26,26,0.1)',
+ }
+ },
+ React.createElement('div', { style: { fontWeight: 700, fontFamily: "'Georgia', serif", marginBottom: 2 } }, categories[hovPt.cat]),
+ React.createElement('div', { style: { color: '#66605c' } }, `Risk: ${hovPt.x.toFixed(1)} Return: ${hovPt.y.toFixed(1)}%`),
+ ),
+ );
+}
diff --git a/doc/public/widgets/scatter_tooltips_legend.js b/doc/public/widgets/scatter_tooltips_legend.js
new file mode 100644
index 0000000..0bf6652
--- /dev/null
+++ b/doc/public/widgets/scatter_tooltips_legend.js
@@ -0,0 +1,204 @@
+export default function ScatterTooltipsLegend({ model, React }) {
+ const { useState, useEffect, useMemo } = React;
+
+ const categories = ['Revenue', 'Growth', 'Margin'];
+ const palette = ['#f97316', '#4a7c8a', '#c4a35a'];
+
+ const points = useMemo(() => {
+ const seed = 53;
+ const rng = (() => { let s = seed; return () => { s = (s * 16807 + 0) % 2147483647; return s / 2147483647; }; })();
+ return Array.from({ length: 45 }, (_, i) => {
+ const cat = Math.floor(rng() * 3);
+ const cx = [30, 55, 75][cat];
+ const cy = [55, 70, 40][cat];
+ return {
+ x: cx + (rng() - 0.5) * 40,
+ y: cy + (rng() - 0.5) * 35,
+ value: Math.round(20 + rng() * 80),
+ cat,
+ id: i,
+ };
+ });
+ }, []);
+
+ const [hovered, setHovered] = useState(null);
+ const [hovPos, setHovPos] = useState({ x: 0, y: 0 });
+ const [animProgress, setAnimProgress] = useState(0);
+ const [activeCats, setActiveCats] = useState(new Set([0, 1, 2]));
+
+ useEffect(() => {
+ const start = performance.now();
+ let raf;
+ function tick(now) {
+ const t = Math.min((now - start) / 800, 1);
+ setAnimProgress(1 - Math.pow(1 - t, 3));
+ if (t < 1) raf = requestAnimationFrame(tick);
+ }
+ raf = requestAnimationFrame(tick);
+ return () => cancelAnimationFrame(raf);
+ }, []);
+
+ const m = { top: 32, right: 20, bottom: 44, left: 48 };
+ const w = 500, h = 340;
+ const pw = w - m.left - m.right;
+ const ph = h - m.top - m.bottom;
+
+ const sx = v => m.left + (v / 100) * pw;
+ const sy = v => m.top + ph - (v / 100) * ph;
+
+ const toggleCat = (cat) => {
+ setActiveCats(prev => {
+ const next = new Set(prev);
+ if (next.has(cat)) { if (next.size > 1) next.delete(cat); }
+ else next.add(cat);
+ return next;
+ });
+ };
+
+ const hovPt = hovered !== null ? points[hovered] : null;
+
+ return React.createElement('div', {
+ style: {
+ width: '100%', height: '100%',
+ background: '#f7f0e6',
+ display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center',
+ fontFamily: "'JetBrains Mono', 'SF Mono', monospace",
+ position: 'relative',
+ padding: '8px 0',
+ }
+ },
+ React.createElement('svg', {
+ viewBox: `0 0 ${w} ${h}`,
+ style: { width: '100%', maxWidth: w, height: 'auto' },
+ },
+ // Title
+ React.createElement('text', {
+ x: m.left, y: 18,
+ style: { fontSize: 13, fontWeight: 700, fill: '#1a1a1a', fontFamily: "'Space Grotesk', sans-serif" },
+ }, 'Performance Metrics'),
+
+ // Subtle grid
+ ...[0, 25, 50, 75, 100].flatMap(v => [
+ React.createElement('line', {
+ key: `gx-${v}`, x1: sx(v), x2: sx(v), y1: m.top, y2: m.top + ph,
+ stroke: '#1a1a1a', strokeOpacity: 0.05, strokeDasharray: '2,4',
+ }),
+ React.createElement('line', {
+ key: `gy-${v}`, x1: m.left, x2: m.left + pw, y1: sy(v), y2: sy(v),
+ stroke: '#1a1a1a', strokeOpacity: 0.05, strokeDasharray: '2,4',
+ }),
+ ]),
+
+ // Axes
+ React.createElement('line', { x1: m.left, x2: m.left + pw, y1: m.top + ph, y2: m.top + ph, stroke: '#1a1a1a', strokeWidth: 2 }),
+ React.createElement('line', { x1: m.left, x2: m.left, y1: m.top, y2: m.top + ph, stroke: '#1a1a1a', strokeWidth: 2 }),
+
+ // Tick labels
+ ...[0, 25, 50, 75, 100].flatMap(v => [
+ React.createElement('text', { key: `xt-${v}`, x: sx(v), y: m.top + ph + 16, textAnchor: 'middle', style: { fontSize: 9, fill: '#1a1a1a', opacity: 0.4 } }, v),
+ React.createElement('text', { key: `yt-${v}`, x: m.left - 6, y: sy(v) + 3, textAnchor: 'end', style: { fontSize: 9, fill: '#1a1a1a', opacity: 0.4 } }, v),
+ ]),
+
+ // Points
+ ...points.map((p, i) => {
+ const active = activeCats.has(p.cat);
+ return React.createElement('circle', {
+ key: p.id,
+ cx: sx(p.x),
+ cy: sy(p.y) + (1 - animProgress) * 20,
+ r: hovered === i ? 7 : 4.5,
+ fill: palette[p.cat],
+ fillOpacity: active ? (hovered === i ? 0.95 : 0.65 * animProgress) : 0.08,
+ stroke: hovered === i ? '#1a1a1a' : 'none',
+ strokeWidth: 2,
+ style: { cursor: 'pointer', transition: 'r 0.12s, fill-opacity 0.25s, cy 0.4s' },
+ onMouseEnter: (e) => { setHovered(i); setHovPos({ x: e.clientX, y: e.clientY }); },
+ onMouseLeave: () => setHovered(null),
+ });
+ }),
+
+ // Axis titles
+ React.createElement('text', {
+ x: m.left + pw / 2, y: h - 4, textAnchor: 'middle',
+ style: { fontSize: 9, fill: '#1a1a1a', opacity: 0.35, textTransform: 'uppercase', letterSpacing: 1.2 },
+ }, 'Efficiency'),
+ React.createElement('text', {
+ x: 14, y: m.top + ph / 2, textAnchor: 'middle',
+ style: { fontSize: 9, fill: '#1a1a1a', opacity: 0.35, textTransform: 'uppercase', letterSpacing: 1.2 },
+ transform: `rotate(-90, 14, ${m.top + ph / 2})`,
+ }, 'Score'),
+ ),
+
+ // Legend bar (interactive, below chart)
+ React.createElement('div', {
+ style: {
+ display: 'flex', gap: 12, marginTop: 4,
+ background: '#f7f0e6',
+ border: '2px solid #1a1a1a',
+ borderRadius: 8,
+ padding: '5px 14px',
+ boxShadow: '2px 2px 0 0 #1a1a1a',
+ }
+ },
+ ...categories.map((cat, i) =>
+ React.createElement('div', {
+ key: cat,
+ onClick: () => toggleCat(i),
+ style: {
+ display: 'flex', alignItems: 'center', gap: 5,
+ cursor: 'pointer', userSelect: 'none',
+ opacity: activeCats.has(i) ? 1 : 0.3,
+ transition: 'opacity 0.2s',
+ }
+ },
+ React.createElement('div', {
+ style: {
+ width: 10, height: 10, borderRadius: 2,
+ background: palette[i],
+ border: '1.5px solid #1a1a1a',
+ }
+ }),
+ React.createElement('span', {
+ style: { fontSize: 10, fontWeight: 600, color: '#1a1a1a' },
+ }, cat),
+ )
+ ),
+ ),
+
+ // Floating tooltip
+ hovPt && activeCats.has(hovPt.cat) && React.createElement('div', {
+ style: {
+ position: 'absolute',
+ top: 40, right: 16,
+ background: '#f7f0e6',
+ border: '2px solid #1a1a1a',
+ borderRadius: 6,
+ padding: '8px 12px',
+ boxShadow: '3px 3px 0 0 #1a1a1a',
+ fontSize: 11,
+ zIndex: 10,
+ minWidth: 120,
+ }
+ },
+ React.createElement('div', {
+ style: {
+ fontWeight: 700, marginBottom: 4, paddingBottom: 4,
+ borderBottom: `2px solid ${palette[hovPt.cat]}`,
+ fontFamily: "'Space Grotesk', sans-serif",
+ }
+ }, categories[hovPt.cat]),
+ React.createElement('div', { style: { display: 'flex', justifyContent: 'space-between', gap: 12 } },
+ React.createElement('span', { style: { opacity: 0.5 } }, 'Efficiency'),
+ React.createElement('span', { style: { fontWeight: 600 } }, hovPt.x.toFixed(1)),
+ ),
+ React.createElement('div', { style: { display: 'flex', justifyContent: 'space-between', gap: 12 } },
+ React.createElement('span', { style: { opacity: 0.5 } }, 'Score'),
+ React.createElement('span', { style: { fontWeight: 600 } }, hovPt.y.toFixed(1)),
+ ),
+ React.createElement('div', { style: { display: 'flex', justifyContent: 'space-between', gap: 12 } },
+ React.createElement('span', { style: { opacity: 0.5 } }, 'Value'),
+ React.createElement('span', { style: { fontWeight: 600, color: palette[hovPt.cat] } }, hovPt.value),
+ ),
+ ),
+ );
+}
diff --git a/doc/scripts/build-static-docs.mjs b/doc/scripts/build-static-docs.mjs
index ba82a56..4ed4e0a 100644
--- a/doc/scripts/build-static-docs.mjs
+++ b/doc/scripts/build-static-docs.mjs
@@ -163,10 +163,7 @@ const buildLlmsTxt = () => {
`- ${DOC_SITE.securitySummary}`,
'',
'Examples:',
- '- /docs/examples/cross-widget',
- '- /docs/examples/tictactoe',
- '- /docs/examples/pdf-web',
- '- /docs/examples/edit',
+ '- /gallery',
'',
'Docs export:',
`- ${DOC_SITE.docsTextPath}`,
diff --git a/doc/scripts/mdxStaticComponents.js b/doc/scripts/mdxStaticComponents.js
index 5a0f777..2052a29 100644
--- a/doc/scripts/mdxStaticComponents.js
+++ b/doc/scripts/mdxStaticComponents.js
@@ -20,10 +20,18 @@ export const ExampleNotebook = ({ title }) => (
)
);
+export const WidgetPreview = ({ src }) => (
+ React.createElement('div', { className: 'placeholder' },
+ React.createElement('div', { className: 'placeholder-label' }, 'Widget Preview'),
+ React.createElement('div', { className: 'placeholder-caption' }, 'Open the live docs to see this interactive widget.')
+ )
+);
+
const mdxStaticComponents = {
MediaPlaceholder,
InstallCommand,
ExampleNotebook,
+ WidgetPreview,
};
export default mdxStaticComponents;