@@ -31,7 +31,7 @@ export default function ExamplesPage() {
{spell(EXAMPLES.length, true)} diagrams laid out by stackmap: {spell(EXAMPLES.length - agents)} across the {spell(DIAGRAM_KINDS.length)} kinds, and{' '}
{spell(agents)} that coding agents wrote from a plain request in the skill’s eval runs. Pick one to open it in the viewer.
-
+
diff --git a/site/app/page.tsx b/site/app/page.tsx
index 1fbdeca..238b1e3 100644
--- a/site/app/page.tsx
+++ b/site/app/page.tsx
@@ -1,4 +1,6 @@
import type { Metadata } from 'next';
+import { Landing } from '@/components/landing/Landing';
+import { CHECKOUT, DEMO } from '@/lib/diagrams';
import { SITE } from '@/lib/site-data';
import { OPEN_GRAPH, siteUrl, softwareApplication } from '@/lib/seo';
@@ -12,16 +14,9 @@ const jsonLd = JSON.stringify(softwareApplication(siteUrl(), SITE.version)).repl
export default function Home() {
return (
-
+
-
- Every layer of your stack, on one map.
-
-
Interactive system diagrams your coding agent writes, as one offline HTML file.
-
-
No coordinates. stackmap lays it out.
- npx skills add hyamero/stackmap
-
+
);
}
diff --git a/site/components/diagram/FitDiagram.tsx b/site/components/diagram/FitDiagram.tsx
index 3fe3ef6..00720ed 100644
--- a/site/components/diagram/FitDiagram.tsx
+++ b/site/components/diagram/FitDiagram.tsx
@@ -1,25 +1,24 @@
'use client';
-import { useCallback, useLayoutEffect, useMemo, useRef, useState } from 'react';
-import type { LaidOutDiagram } from '@stackmap/core';
-import { toScene } from '@stackmap/viewer/src/canvas/scene';
-import { StaticScene } from '@stackmap/viewer/src/canvas/StaticScene';
+import { useCallback, useLayoutEffect, useRef, useState } from 'react';
+import type { StaticDiagram } from '@/lib/data/static-html';
const GRID = 20;
/**
* A diagram at rest, scaled to fit its box and never past 100%. `width` is the box's width at the design
- * size, so the server's markup is already fitted; once hydrated it follows the box's real size.
+ * size, so the server's markup is already fitted; once hydrated it follows the box's real size. The
+ * diagram itself is HTML rendered at build time, so this ships none of the viewer's code.
*/
export function FitDiagram({
- diagram,
+ still,
width,
height,
pad = 24,
fill = false,
className = '',
}: {
- diagram: LaidOutDiagram;
+ still: StaticDiagram;
width: number;
height: number;
pad?: number;
@@ -27,7 +26,7 @@ export function FitDiagram({
fill?: boolean;
className?: string;
}) {
- const { content } = useMemo(() => toScene(diagram), [diagram]);
+ const { box: content } = still;
const fit = useCallback((w: number, h: number) => Math.min(1, (w - 2 * pad) / content.width, (h - 2 * pad) / content.height), [content, pad]);
const box = useRef(null);
const [k, setK] = useState(() => fit(width, height));
@@ -44,7 +43,7 @@ export function FitDiagram({
-
-
-
+
);
}
diff --git a/site/components/diagram/Still.tsx b/site/components/diagram/Still.tsx
new file mode 100644
index 0000000..086c4bf
--- /dev/null
+++ b/site/components/diagram/Still.tsx
@@ -0,0 +1,6 @@
+import type { StaticDiagram } from '@/lib/data/static-html';
+
+/** A diagram pre-rendered at build time, at its natural size. */
+export function Still({ still }: { still: StaticDiagram }) {
+ return ;
+}
diff --git a/site/components/examples/Gallery.tsx b/site/components/examples/Gallery.tsx
index fdd5edf..1ade94b 100644
--- a/site/components/examples/Gallery.tsx
+++ b/site/components/examples/Gallery.tsx
@@ -3,6 +3,7 @@
import dynamic from 'next/dynamic';
import { useState, type ReactNode } from 'react';
import type { LaidOutDiagram } from '@stackmap/core';
+import type { StaticDiagram } from '@/lib/data/static-html';
import { FitDiagram } from '@/components/diagram/FitDiagram';
import { ArrowIcon } from '@/components/ui/icons';
@@ -28,8 +29,8 @@ const matches = (f: Filter | undefined, i: GalleryItem) => !f || ((!f.kind || f.
// The viewer reads the window (hash, size, keys), so it mounts on the client; until then the frame
// shows the same diagram at rest, which is also what a reader without JavaScript gets.
-function Resting({ diagram }: { diagram: LaidOutDiagram }) {
- return ;
+function Resting({ still }: { still: StaticDiagram }) {
+ return ;
}
const Viewer = dynamic(() => import('./LiveViewer'), { ssr: false });
@@ -37,14 +38,18 @@ function Agent() {
return Written by an agent;
}
-export function Gallery({ items, thumbs, filters }: { items: GalleryItem[]; thumbs: Record; filters?: Filter[] }) {
+/** `rest` is the first item pre-rendered: the frame before the viewer opens. Stepping or picking opens it. */
+export function Gallery({ items, rest, thumbs, filters }: { items: GalleryItem[]; rest: StaticDiagram; thumbs: Record; filters?: Filter[] }) {
const [filter, setFilter] = useState(filters?.[0]?.id);
const shown = items.filter((i) => matches(filters?.find((f) => f.id === filter), i));
const [selected, setSelected] = useState(items[0]!.id);
const [live, setLive] = useState(false);
const at = Math.max(0, shown.findIndex((i) => i.id === selected));
const current = shown[at] ?? items[0]!;
- const step = (by: number) => setSelected(shown[(at + by + shown.length) % shown.length]!.id);
+ const step = (by: number) => {
+ setSelected(shown[(at + by + shown.length) % shown.length]!.id);
+ setLive(true);
+ };
const pick = (id: string) => {
setSelected(id);
setLive(true);
@@ -79,10 +84,9 @@ export function Gallery({ items, thumbs, filters }: { items: GalleryItem[]; thum
+ );
+}
diff --git a/site/components/landing/Landing.tsx b/site/components/landing/Landing.tsx
new file mode 100644
index 0000000..5a77901
--- /dev/null
+++ b/site/components/landing/Landing.tsx
@@ -0,0 +1,56 @@
+import type { LaidOutDiagram } from '@stackmap/core';
+import { Agent } from './desktop/Agent';
+import { Features } from './desktop/Features';
+import { Files } from './desktop/Files';
+import { Hero } from './desktop/Hero';
+import { Install } from './desktop/Install';
+import { Kinds } from './desktop/Kinds';
+import { Unmapped } from './desktop/Unmapped';
+import { Viewer } from './desktop/Viewer';
+import { HashAnchors } from './HashAnchors';
+import { LandingMotion } from './LandingMotion';
+import { Agent as MAgent } from './mobile/Agent';
+import { Features as MFeatures } from './mobile/Features';
+import { Files as MFiles } from './mobile/Files';
+import { Hero as MHero } from './mobile/Hero';
+import { Install as MInstall } from './mobile/Install';
+import { Kinds as MKinds } from './mobile/Kinds';
+import { Unmapped as MUnmapped } from './mobile/Unmapped';
+import { Viewer as MViewer } from './mobile/Viewer';
+import './desktop.css';
+import './mobile.css';
+import './site.css';
+
+/**
+ * The landing: the canvas's desktop composition (1440 × 900 frames), and its phone composition below 768px.
+ * Both render on the server as their resting frames; LandingMotion scrubs whichever one is on screen.
+ */
+export function Landing({ demo, checkout }: { demo: LaidOutDiagram; checkout: Record }) {
+ return (
+ <>
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ >
+ );
+}
diff --git a/site/components/landing/LandingMotion.tsx b/site/components/landing/LandingMotion.tsx
new file mode 100644
index 0000000..31d2554
--- /dev/null
+++ b/site/components/landing/LandingMotion.tsx
@@ -0,0 +1,185 @@
+'use client';
+
+import { useGSAP } from '@gsap/react';
+import { gsap } from 'gsap';
+import { CustomEase } from 'gsap/CustomEase';
+import { ScrollTrigger } from 'gsap/ScrollTrigger';
+import { TWEENS as DESKTOP } from './desktop/timelines';
+import { TWEENS as MOBILE } from './mobile/timelines';
+import { sceneExtras, sceneTimeline, type Tween } from './timeline';
+
+gsap.registerPlugin(useGSAP, ScrollTrigger, CustomEase);
+
+// Each pinned scene owns one GSAP timeline, scrubbed by ScrollTrigger through its pin (Motion rule 01).
+// All of it lives inside gsap.matchMedia: with reduced motion, on the other composition's widths, or
+// before any script runs, nothing is set and every scene stays the resting frame the server drew (rule 02).
+
+interface Route {
+ el: SVGPathElement;
+ a: number;
+ b: number;
+ head: SVGGElement | null;
+ len: number;
+ k0: number;
+}
+
+const clamp = (x: number) => (x < 0 ? 0 : x > 1 ? 1 : x);
+
+function routesOf(root: HTMLElement, owner: string): Route[] {
+ return [...root.querySelectorAll(`[data-route="${owner}"]`)].map((el) => ({
+ el,
+ a: parseFloat(el.dataset.r0 ?? '0'),
+ b: parseFloat(el.dataset.r1 ?? '1'),
+ head: el.dataset.head ? el.parentElement!.querySelector(`[data-route-head="${el.dataset.head}"]`) : null,
+ len: 0,
+ k0: 0,
+ }));
+}
+
+function measureRoute(r: Route) {
+ r.len = r.el.getTotalLength();
+ // A route entering at its frame's top starts drawn a little way in, so the line from the scene above carries on.
+ const top = r.len > 0 && r.el.getPointAtLength(0).y < 2;
+ r.k0 = top ? Math.min(0.5, Number(r.el.dataset.stub ?? 104) / r.len) : 0;
+}
+
+// The route through-line draws with its scene's progress, in the gutter, and its head rides the tip.
+function drawRoute(r: Route, p: number) {
+ const k = Math.max(r.k0, clamp((p - r.a) / Math.max(0.0001, r.b - r.a)));
+ r.el.style.strokeDashoffset = String(1 - k);
+ if (r.head && r.len) {
+ const at = r.el.getPointAtLength(k * r.len);
+ r.head.setAttribute('transform', `translate(${at.x} ${at.y})`);
+ r.head.style.opacity = k > 0.002 && k < 0.998 ? '1' : '0';
+ }
+}
+
+function animate(root: HTMLElement, tweens: Tween[]) {
+ const nav = document.querySelector('header[data-nav]');
+ const nav0 = nav?.dataset.theme;
+ root.classList.add('live');
+ const mark = root.querySelector('[data-mark-route]');
+ mark?.classList.remove('drawn');
+ const progress = new Map();
+ const triggers = new Map();
+ const allRoutes: Route[] = [];
+
+ for (const el of root.querySelectorAll('[data-scene]')) {
+ const id = el.dataset.scene!;
+ const stage = el.querySelector('[data-stage]')!;
+ const frame = el.querySelector('[data-frame]')!;
+ const fw = Number(el.dataset.fw) || 1440;
+ const fh = Number(el.dataset.fh) || 900;
+ const runway = parseFloat(el.dataset.runway ?? '0');
+ const routes = routesOf(root, id);
+ allRoutes.push(...routes);
+ // The frame keeps its design size and scales to fit the window, centred in the pinned stage.
+ const fit = () => Math.min(1, innerWidth / fw, innerHeight / fh);
+ gsap.set([el, stage], { height: '100vh' });
+ gsap.set(frame, { top: '50%', left: '50%', xPercent: -50, yPercent: -50, scale: fit });
+ const tl = sceneTimeline(el, [...tweens, ...sceneExtras(el)]);
+ const st = ScrollTrigger.create({
+ trigger: el,
+ pin: true,
+ start: 'top top',
+ end: () => `+=${runway * innerHeight}`,
+ scrub: true,
+ animation: tl,
+ invalidateOnRefresh: true,
+ onRefresh: () => gsap.set(frame, { scale: fit() }),
+ onUpdate: (self) => {
+ const p = self.progress;
+ progress.set(id, p);
+ for (const r of routes) drawRoute(r, p);
+ // The mark's route draws once, never scrubbed (Motion rule 03).
+ if (id === 'unmapped' && p >= 0.53) mark?.classList.add('drawn');
+ el.dispatchEvent(new CustomEvent('scene', { detail: p }));
+ },
+ });
+ triggers.set(id, st);
+ progress.set(id, 0);
+ }
+
+ // Sections that don't pin (features, install) draw their stretch of the route as they pass.
+ for (const el of root.querySelectorAll('[data-sec]')) {
+ const routes = routesOf(root, el.dataset.sec!);
+ allRoutes.push(...routes);
+ ScrollTrigger.create({ trigger: el, start: 'top 78%', end: 'bottom 78%', onUpdate: (self) => routes.forEach((r) => drawRoute(r, self.progress)) });
+ }
+ const measureRoutes = () => allRoutes.forEach(measureRoute);
+ measureRoutes();
+ ScrollTrigger.addEventListener('refresh', measureRoutes);
+
+ for (const el of root.querySelectorAll('[data-rv]')) {
+ ScrollTrigger.create({ trigger: el, start: 'top 90%', once: true, onEnter: () => el.classList.add('in') });
+ }
+
+ // The nav takes the theme of the section under it; the viewer's light, and the files scene's, can cover it first.
+ const themed = [...root.querySelectorAll('[data-theme-sec]')];
+ const files = root.querySelector('[data-scene="files"]');
+ const viewer = root.querySelector('[data-scene="viewer"]');
+ const under = (el: HTMLElement | null) => {
+ const r = el?.getBoundingClientRect();
+ return !!r && r.top <= 40 && r.bottom > 40;
+ };
+ const navTheme = () => {
+ if (!nav) return;
+ let theme = themed.find(under)?.dataset.themeSec ?? root.dataset.theme ?? 'dark';
+ if (under(files) && (progress.get('files') ?? 0) < Number(files!.dataset.lit ?? 0)) theme = 'light';
+ if (under(viewer) && (progress.get('viewer') ?? 0) < 0.07) theme = 'dark';
+ if (nav.dataset.theme !== theme) nav.dataset.theme = theme;
+ };
+ ScrollTrigger.create({ start: 0, end: 'max', onUpdate: navTheme, onRefresh: navTheme });
+
+ // The kind pills ask for a point in a scene's pin.
+ const onSeek = (e: Event) => {
+ const { id, p } = (e as CustomEvent<{ id: string; p: number }>).detail;
+ const st = triggers.get(id);
+ if (st) scrollTo({ top: Math.round(st.start + (st.end - st.start) * p), behavior: 'smooth' });
+ };
+ root.addEventListener('seek', onSeek);
+
+ // Hero parallax: the unmapped files drift against the pointer.
+ const hero = root.querySelector('[data-scene="top"]');
+ const heroFrame = hero?.querySelector('[data-frame]');
+ const onPointer = (e: PointerEvent) => {
+ const r = hero!.getBoundingClientRect();
+ heroFrame!.style.setProperty('--mx', (((e.clientX - r.left) / r.width - 0.5) * 2).toFixed(3));
+ heroFrame!.style.setProperty('--my', (((e.clientY - r.top) / r.height - 0.5) * 2).toFixed(3));
+ };
+ hero?.addEventListener('pointermove', onPointer);
+
+ return () => {
+ root.classList.remove('live');
+ root.removeEventListener('seek', onSeek);
+ hero?.removeEventListener('pointermove', onPointer);
+ ScrollTrigger.removeEventListener('refresh', measureRoutes);
+ for (const r of allRoutes) {
+ r.el.style.strokeDashoffset = '';
+ r.head?.removeAttribute('transform');
+ }
+ for (const el of root.querySelectorAll('[data-rv].in')) el.classList.remove('in');
+ // At rest the mark is simply drawn.
+ mark?.classList.add('drawn');
+ if (nav && nav0) nav.dataset.theme = nav0;
+ };
+}
+
+const COMPOSITIONS = {
+ desktop: { rootId: 'lp-d', media: '(min-width: 768px)', tweens: DESKTOP },
+ phone: { rootId: 'lp-m', media: '(max-width: 767px)', tweens: MOBILE },
+};
+
+/** Scrubs one composition, desktop or phone, while its widths match and motion is allowed. */
+export function LandingMotion({ composition }: { composition: keyof typeof COMPOSITIONS }) {
+ const { rootId, media, tweens } = COMPOSITIONS[composition];
+ useGSAP(() => {
+ const root = document.getElementById(rootId);
+ if (!root) return;
+ root.querySelector('[data-mark-route]')?.classList.add('drawn');
+ const mm = gsap.matchMedia();
+ mm.add(`${media} and (prefers-reduced-motion: no-preference)`, () => animate(root, tweens));
+ return () => mm.revert();
+ }, [rootId, media, tweens]);
+ return null;
+}
diff --git a/site/components/landing/LazyViewer.tsx b/site/components/landing/LazyViewer.tsx
new file mode 100644
index 0000000..ea27a4f
--- /dev/null
+++ b/site/components/landing/LazyViewer.tsx
@@ -0,0 +1,38 @@
+'use client';
+
+import dynamic from 'next/dynamic';
+import { useEffect, useRef, useState } from 'react';
+import type { LaidOutDiagram } from '@stackmap/core';
+import type { StaticDiagram } from '@/lib/data/static-html';
+import { FitDiagram } from '@/components/diagram/FitDiagram';
+
+const LiveViewer = dynamic(() => import('@/components/examples/LiveViewer'), { ssr: false });
+
+/**
+ * The real viewer, mounted once its box is on screen (and visible: the other composition's copy never mounts).
+ * Until then, and without script, the same diagram at rest.
+ */
+export function LazyViewer({ diagram, still, width, height }: { diagram: LaidOutDiagram; still: StaticDiagram; width: number; height: number }) {
+ const box = useRef(null);
+ const [mounted, setMounted] = useState(false);
+ useEffect(() => {
+ const el = box.current;
+ if (!el) return;
+ const io = new IntersectionObserver(
+ ([e]) => {
+ if (e?.isIntersecting && el.offsetParent !== null) {
+ setMounted(true);
+ io.disconnect();
+ }
+ },
+ { rootMargin: '50% 0px' },
+ );
+ io.observe(el);
+ return () => io.disconnect();
+ }, []);
+ return (
+
+
+ >
+ );
+}
diff --git a/site/components/landing/desktop/Kinds.tsx b/site/components/landing/desktop/Kinds.tsx
new file mode 100644
index 0000000..f11626f
--- /dev/null
+++ b/site/components/landing/desktop/Kinds.tsx
@@ -0,0 +1,60 @@
+import { DIAGRAM_KINDS, KIND_LABELS, type LaidOutDiagram } from '@stackmap/core';
+import { KindPills } from '../KindPills';
+import { KindsStage } from '../KindsStage';
+import { s } from '../style';
+
+const REST = DIAGRAM_KINDS.indexOf('workflow');
+
+const LINES = ['Components and what they call.', 'Data moving through stages.', 'Steps across owner lanes.', 'The states of one thing.', 'Messages over time.'];
+
+/** #kinds: one checkout, five ways. At rest it shows the workflow, as the canvas does. */
+export function Kinds({ checkout }: { checkout: Record }) {
+ return (
+
+
+
+
Five kinds of diagram: architecture, dataflow, workflow, lifecycle and sequence.