Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,17 @@ jobs:
- run: bun run --filter @stackmap/site test
- run: bun run --filter @stackmap/site typecheck
- run: bun run --filter @stackmap/site build
- name: Site smoke against the production build
working-directory: site
run: |
bunx playwright install --with-deps chromium
bunx playwright test
- name: Upload site Playwright results on failure
if: failure()
uses: actions/upload-artifact@v4
with:
name: site-playwright-results
path: site/test-results/

e2e:
name: e2e · visual
Expand Down
7 changes: 7 additions & 0 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

6 changes: 3 additions & 3 deletions site/app/docs/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import { Ask, Code, docs } from '@/components/docs/prose';
import { CodeBlock, Command } from '@/components/ui/CodeBlock';
import { TypeChip } from '@/components/ui/TypeChip';
import { spell } from '@/lib/words';
import { QUICK_START, SOURCES } from '@/lib/diagrams';
import { SOURCES, stillOf } from '@/lib/diagrams';
import { OPEN_GRAPH } from '@/lib/seo';
import { SITE } from '@/lib/site-data';

Expand Down Expand Up @@ -46,7 +46,7 @@ function Step({ n, title, children }: { n: number; title: string; children: Reac
return (
<li className="relative pl-14">
<span className="absolute top-0 left-0 grid size-7 place-items-center rounded-full bg-page font-mono text-[12.5px] shadow-[inset_0_0_0_1.5px_var(--sm-text)]">{n}</span>
<h3 className="mt-0.5 text-[19px] leading-[1.35] font-semibold tracking-[-0.015em]">{title}</h3>
<h2 className="mt-0.5 text-[19px] leading-[1.35] font-semibold tracking-[-0.015em]">{title}</h2>
{children}
</li>
);
Expand Down Expand Up @@ -129,7 +129,7 @@ export default function DocsPage() {
))}
<code className="ml-1.5 truncate font-mono">file:///…/.stackmap/bookshop/diagram.html</code>
</div>
<FitDiagram diagram={QUICK_START} width={680} height={400} />
<FitDiagram still={stillOf('quickStart')} width={680} height={400} />
<figcaption className="sr-only">The same JSON, laid out by stackmap.</figcaption>
</figure>
<div className="mt-7 flex flex-col gap-[18px]">
Expand Down
4 changes: 2 additions & 2 deletions site/app/examples/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ const FILTERS: Filter[] = [
const agents = EXAMPLES.filter((e) => e.prompt).length;

export default function ExamplesPage() {
const { items, thumbs } = galleryProps(EXAMPLES);
const { items, thumbs, rest } = galleryProps(EXAMPLES);
return (
<main data-theme="light" className="bg-page text-fg">
<div className="mx-auto max-w-[1440px] px-5 pt-28 pb-28 md:px-10 lg:pt-[120px] xl:px-24">
Expand All @@ -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.
</p>
<Gallery items={items} thumbs={thumbs} filters={FILTERS} />
<Gallery items={items} rest={rest} thumbs={thumbs} filters={FILTERS} />
</div>
</main>
);
Expand Down
4 changes: 2 additions & 2 deletions site/app/kinds/[kind]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ export default async function KindPage({ params }: PageProps<'/kinds/[kind]'>) {
const { kind } = await params;
if (!isKind(kind)) notFound();
const page = KIND_PAGES[kind];
const { items, thumbs } = galleryProps(kindEntries(kind));
const { items, thumbs, rest } = galleryProps(kindEntries(kind));
const file = `${SKILL_EXAMPLE[kind]}.json`;
const next = DIAGRAM_KINDS[(DIAGRAM_KINDS.indexOf(kind) + 1) % DIAGRAM_KINDS.length]!;
return (
Expand All @@ -53,7 +53,7 @@ export default async function KindPage({ params }: PageProps<'/kinds/[kind]'>) {
<p className="text-lede">{page.lede}</p>
<p className="text-base leading-[1.65] text-fg-muted">{page.use}</p>
</div>
<Gallery items={items} thumbs={thumbs} />
<Gallery items={items} rest={rest} thumbs={thumbs} />
<Parts kind={kind} page={page} />
<div className="mt-28 grid items-start gap-8 lg:grid-cols-[400px_minmax(0,1fr)] lg:gap-12">
<div>
Expand Down
13 changes: 4 additions & 9 deletions site/app/page.tsx
Original file line number Diff line number Diff line change
@@ -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';

Expand All @@ -12,16 +14,9 @@ const jsonLd = JSON.stringify(softwareApplication(siteUrl(), SITE.version)).repl

export default function Home() {
return (
<main className="mx-auto max-w-5xl px-6 py-24">
<main>
<script type="application/ld+json" dangerouslySetInnerHTML={{ __html: jsonLd }} />
<h1 className="text-hero">
Every layer of your stack, <span className="text-accent">on one map.</span>
</h1>
<p className="text-lede mt-6 text-fg-muted">Interactive system diagrams your coding agent writes, as one offline HTML file.</p>
<section data-theme="dark" className="mt-16 rounded-2xl bg-page p-10 text-fg">
<p className="text-section">No coordinates. stackmap lays it out.</p>
<code className="text-code font-mono">npx skills add hyamero/stackmap</code>
</section>
<Landing demo={DEMO} checkout={CHECKOUT} />
</main>
);
}
21 changes: 9 additions & 12 deletions site/components/diagram/FitDiagram.tsx
Original file line number Diff line number Diff line change
@@ -1,33 +1,32 @@
'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;
/** take the parent's height instead of `height`, which then only sets the first fit */
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<HTMLDivElement>(null);
const [k, setK] = useState(() => fit(width, height));
Expand All @@ -44,7 +43,7 @@ export function FitDiagram({
<div
ref={box}
role="img"
aria-label={`${diagram.draft.title}: ${diagram.draft.nodes.length} nodes, ${diagram.draft.edges.length} connections`}
aria-label={`${still.title}: ${still.nodes} nodes, ${still.edges} connections`}
className={`relative overflow-hidden bg-stage ${className}`}
style={{
height: fill ? '100%' : height,
Expand All @@ -53,9 +52,7 @@ export function FitDiagram({
backgroundPosition: 'center',
}}
>
<div className="absolute top-1/2 left-1/2" style={{ transform: `translate(-50%, -50%) scale(${k})` }}>
<StaticScene diagram={diagram} />
</div>
<div className="absolute top-1/2 left-1/2" style={{ transform: `translate(-50%, -50%) scale(${k})` }} dangerouslySetInnerHTML={{ __html: still.html }} />
</div>
);
}
6 changes: 6 additions & 0 deletions site/components/diagram/Still.tsx
Original file line number Diff line number Diff line change
@@ -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 <div dangerouslySetInnerHTML={{ __html: still.html }} />;
}
16 changes: 10 additions & 6 deletions site/components/examples/Gallery.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand All @@ -28,23 +29,27 @@ 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 <FitDiagram diagram={diagram} width={1248} height={680} pad={48} fill />;
function Resting({ still }: { still: StaticDiagram }) {
return <FitDiagram still={still} width={1248} height={680} pad={48} fill />;
}
const Viewer = dynamic(() => import('./LiveViewer'), { ssr: false });

function Agent() {
return <span className="inline-flex items-center gap-1.5 text-fg before:size-1.5 before:rounded-full before:bg-fg">Written by an agent</span>;
}

export function Gallery({ items, thumbs, filters }: { items: GalleryItem[]; thumbs: Record<string, ReactNode>; 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<string, ReactNode>; 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);
Expand Down Expand Up @@ -79,10 +84,9 @@ export function Gallery({ items, thumbs, filters }: { items: GalleryItem[]; thum
<button
type="button"
onClick={() => setLive(true)}
aria-label={`Open ${current.title} in the viewer`}
className="group relative block size-full cursor-pointer text-left"
>
<Resting diagram={current.diagram} />
<Resting still={rest} />
<span className="absolute top-5 left-5 max-w-[70%] rounded-2xl bg-panel px-4 py-3 shadow-panel">
<span className="block truncate text-[17px] font-semibold tracking-tight text-fg">{current.title}</span>
<span className="mt-0.5 block text-[13px] text-fg-muted">Select to explore it in the viewer</span>
Expand Down
4 changes: 2 additions & 2 deletions site/components/examples/gallery-props.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { KIND_LABELS } from '@stackmap/core';
import { Thumbnail } from '@/components/diagram/Thumbnail';
import { id, type Entry } from '@/lib/catalog';
import { diagramOf } from '@/lib/diagrams';
import { diagramOf, stillOf } from '@/lib/diagrams';
import type { GalleryItem } from './Gallery';

/** Server side: each entry's laid-out diagram and its thumbnail, drawn here so the island ships no layout code. */
Expand All @@ -11,5 +11,5 @@ export function galleryProps(entries: Entry[]) {
return { id: id(e), title: diagram.draft.title, kind: KIND_LABELS[e.kind], nodes: diagram.draft.nodes.length, prompt: e.prompt, diagram };
});
const thumbs = Object.fromEntries(items.map((i) => [i.id, <Thumbnail key={i.id} diagram={i.diagram} />]));
return { items, thumbs };
return { items, thumbs, rest: stillOf(`${entries[0]!.source}/${entries[0]!.key}`) };
}
29 changes: 29 additions & 0 deletions site/components/landing/BoardCopy.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
'use client';

import { useEffect, useRef, useState } from 'react';

/** The landing's copy button: the board's `.ib.cp`, which swaps its icon to a check while `.done`. */
export function BoardCopy({ text, label }: { text: string; label: string }) {
const [done, setDone] = useState(false);
const timer = useRef<ReturnType<typeof setTimeout>>(undefined);
useEffect(() => () => clearTimeout(timer.current), []);
const copy = () => {
const finish = () => {
setDone(true);
clearTimeout(timer.current);
timer.current = setTimeout(() => setDone(false), 1600);
};
navigator.clipboard?.writeText(text).then(finish, finish) ?? finish();
};
return (
<button className={`ib cp ${done ? 'done' : ''}`} type="button" aria-label={done ? 'Copied' : label} onClick={copy}>
<svg className="ic" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.75" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true" data-i="copy">
<rect width="14" height="14" x="8" y="8" rx="2" ry="2" />
<path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2" />
</svg>
<svg className="ic" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.75" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true" data-i="check">
<path d="M20 6 9 17l-5-5" />
</svg>
</button>
);
}
22 changes: 22 additions & 0 deletions site/components/landing/HashAnchors.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
'use client';

import { useEffect } from 'react';

/**
* The nav's #viewer, #kinds and #install point at the desktop scenes' ids. On a phone those are hidden,
* so the hash goes to the phone scene that carries the same data-anchor instead.
*/
export function HashAnchors() {
useEffect(() => {
const go = () => {
const id = decodeURIComponent(location.hash.slice(1));
const desktop = id && document.getElementById(id);
if (!desktop || desktop.offsetParent !== null || desktop.closest('.pin-spacer')) return;
document.querySelector(`.lp-m [data-anchor="${CSS.escape(id)}"]`)?.scrollIntoView();
};
go();
addEventListener('hashchange', go);
return () => removeEventListener('hashchange', go);
}, []);
return null;
}
25 changes: 25 additions & 0 deletions site/components/landing/KindCount.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
'use client';

import { useEffect, useRef, useState } from 'react';
import { HOLDS } from './kinds-geometry';

/** The phone's "3 / 5 · one checkout, five ways", following the scroll while the scene scrubs. */
export function KindCount({ rest, total }: { rest: number; total: number }) {
const ref = useRef<HTMLParagraphElement>(null);
const [kind, setKind] = useState(rest);
useEffect(() => {
const scene = ref.current?.closest<HTMLElement>('[data-scene]');
if (!scene) return;
const onScene = (e: Event) => {
const pct = (e as CustomEvent<number>).detail * 100;
setKind(Math.max(0, HOLDS.findLastIndex(([a]) => pct >= a - 3.5)));
};
scene.addEventListener('scene', onScene);
return () => scene.removeEventListener('scene', onScene);
}, []);
return (
<p ref={ref} className="kcount mono tnum">
{kind + 1} / {total} · one checkout, five ways
</p>
);
}
Loading
Loading