diff --git a/.gitignore b/.gitignore index 6f57d7f..4d6ec5c 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,4 @@ -docs/ +/docs/ .agents/ .superpowers/ node_modules/ diff --git a/packages/viewer/src/canvas/StaticScene.test.tsx b/packages/viewer/src/canvas/StaticScene.test.tsx new file mode 100644 index 0000000..7d40d81 --- /dev/null +++ b/packages/viewer/src/canvas/StaticScene.test.tsx @@ -0,0 +1,29 @@ +import { renderToString } from 'react-dom/server'; +import { describe, expect, it } from 'vitest'; +import { commerceApiLayout } from '../samples/commerce-api.layout'; +import { toScene } from './scene'; +import { StaticScene } from './StaticScene'; + +describe('StaticScene', () => { + it('renders every card and connection without the explorer or a camera', () => { + const html = renderToString(); + const doc = new DOMParser().parseFromString(html, 'text/html'); + expect(doc.querySelectorAll('.sm-card')).toHaveLength(commerceApiLayout.draft.nodes.length); + expect(doc.querySelectorAll('[data-edge-id]').length).toBeGreaterThanOrEqual(commerceApiLayout.draft.edges.length); + }); + + it('sizes itself to the diagram content, offset to its origin', () => { + const { content } = toScene(commerceApiLayout); + const html = renderToString(); + const root = new DOMParser().parseFromString(html, 'text/html').body.firstElementChild as HTMLElement; + expect(root.style.width).toBe(`${content.width}px`); + expect(root.style.height).toBe(`${content.height}px`); + expect((root.firstElementChild as HTMLElement).style.transform).toBe(`translate(${-content.x}px, ${-content.y}px)`); + }); + + it('keeps its cards out of the tab order', () => { + const html = renderToString(); + const root = new DOMParser().parseFromString(html, 'text/html').body.firstElementChild!; + expect(root.hasAttribute('inert')).toBe(true); + }); +}); diff --git a/packages/viewer/src/canvas/StaticScene.tsx b/packages/viewer/src/canvas/StaticScene.tsx new file mode 100644 index 0000000..0ba861d --- /dev/null +++ b/packages/viewer/src/canvas/StaticScene.tsx @@ -0,0 +1,32 @@ +import { useMemo } from 'react'; +import type { LaidOutDiagram } from '@stackmap/core'; +import type { Emphasis } from '../explore/emphasis'; +import { DispatchProvider } from '../explore/ExploreContext'; +import { toScene } from './scene'; +import { SceneLayers } from './SceneLayers'; +import type { Camera } from './useZoom'; +import { CameraProvider } from './ViewportContext'; + +const noop = () => {}; +const still: Camera = { zoomIn: noop, zoomOut: noop, fit: noop, centerOn: noop, fitRect: noop, panBy: noop, ensureVisible: noop }; +const calm: Emphasis = { active: false, nodes: new Map(), edges: new Map() }; + +/** + * The diagram as drawn at rest, at its natural size: no explorer, camera or motion, and nothing that + * reads the page (so it renders on a server). Inert, since its cards would otherwise be buttons that do nothing. + */ +export function StaticScene({ diagram }: { diagram: LaidOutDiagram }) { + const scene = useMemo(() => toScene(diagram), [diagram]); + const { content } = scene; + return ( + + +
+
+ +
+
+
+
+ ); +} diff --git a/packages/viewer/src/chrome/ViewerShell.test.tsx b/packages/viewer/src/chrome/ViewerShell.test.tsx index 20047d9..6e522cf 100644 --- a/packages/viewer/src/chrome/ViewerShell.test.tsx +++ b/packages/viewer/src/chrome/ViewerShell.test.tsx @@ -17,6 +17,12 @@ describe('ViewerShell', () => { expect(screen.getByText('6 nodes · 9 connections')).toBeInTheDocument(); }); + it('titles the diagram one level down when embedded in a page with its own h1', () => { + const { container } = render( {}} titleAs="h2" />); + expect(within(container.querySelector('header')!).getByRole('heading', { level: 2, name: 'Commerce API' })).toBeInTheDocument(); + expect(screen.queryByRole('heading', { level: 1 })).not.toBeInTheDocument(); + }); + it('lists Overview first, then agent views, as tabs', () => { render( {}} />); const tabs = within(screen.getByRole('tablist')).getAllByRole('tab'); diff --git a/packages/viewer/src/chrome/ViewerShell.tsx b/packages/viewer/src/chrome/ViewerShell.tsx index 6ff148a..7a01ab9 100644 --- a/packages/viewer/src/chrome/ViewerShell.tsx +++ b/packages/viewer/src/chrome/ViewerShell.tsx @@ -88,10 +88,13 @@ export function ViewerShell({ diagram, theme, onToggleTheme, + titleAs: Title = 'h1', }: { diagram: LaidOutDiagram; theme: ThemeChoice; onToggleTheme: () => void; + /** h2 when the shell sits in a page that has its own h1 (the website) */ + titleAs?: 'h1' | 'h2'; }) { const { draft } = diagram; const [inspectorCollapsed, setInspectorCollapsed] = useState(() => innerWidth < INSPECTOR_BREAKPOINT); @@ -141,7 +144,7 @@ export function ViewerShell({ {!presenting && (
-

{draft.title}

+ {draft.title} (null); // `dispatch` never changes; cards read it from here so explorer state changes don't re-render them all. const DispatchContext = createContext | null>(null); +/** For scenes drawn without an explorer (StaticScene): cards dispatch into this instead. */ +export const DispatchProvider = DispatchContext.Provider; export function useExploreDispatch(): Dispatch { const d = useContext(DispatchContext); diff --git a/site/app/docs/page.tsx b/site/app/docs/page.tsx new file mode 100644 index 0000000..6535dab --- /dev/null +++ b/site/app/docs/page.tsx @@ -0,0 +1,187 @@ +import type { Metadata } from 'next'; +import { BRAND_SLUGS, INFRA_TYPES, STATE_TYPES } from '@stackmap/core'; +import { FitDiagram } from '@/components/diagram/FitDiagram'; +import { DocsShell, NextLink } from '@/components/docs/DocsShell'; +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 { OPEN_GRAPH } from '@/lib/seo'; +import { SITE } from '@/lib/site-data'; + +export const metadata: Metadata = { + title: 'Quick start', + description: 'Install the stackmap skill, ask your coding agent for a diagram, and open the one HTML file it delivers.', + alternates: { canonical: '/docs' }, + openGraph: { ...OPEN_GRAPH, url: '/docs' }, +}; + +const TOC = [ + { id: 'quick-start', label: 'Quick start' }, + { id: 'json', label: 'What the agent writes' }, + { id: 'cli', label: 'The CLI' }, +]; + +const CLI = [ + { usage: 'validate diagram.json', flags: '[--json]', does: 'Lists every problem with a code, its evidence and the allowed fixes, including card text that won’t fit.' }, + { usage: 'deliver diagram.json', flags: '[-o out.html]', does: 'Validates, lays out and writes one offline HTML file. The same JSON always gives the same file, byte for byte.' }, + { + usage: 'serve diagram.json', + flags: '[--port 4400]', + does: 'A live viewer that reloads on every save. It binds 127.0.0.1 only, keeps the last good version when a save is invalid, and keeps your camera and selection across reloads.', + }, +]; + +const EXIT_CODES = [ + { code: 0, meaning: 'OK. Warnings are allowed.' }, + { code: 1, meaning: 'The diagram has errors.' }, + { code: 2, meaning: 'A usage, file or internal error. Never a stack trace.' }, +]; + +// The agent's file pins the schema to the installed CLI, so the example does too. +const json = SOURCES.quickStart.replace('{\n', `{\n "$schema": "https://unpkg.com/@hyamero/stackmap@${SITE.version}/dist/stackmap.schema.json",\n`); + +function Step({ n, title, children }: { n: number; title: string; children: React.ReactNode }) { + return ( +
  • + {n} +

    {title}

    + {children} +
  • + ); +} + +function Transcript() { + const { broken, clean } = SITE.repair; + const { file, sha256, bytes } = SITE.receipt; + const prompt = (cmd: string) => ( + + stackmap · {cmd} .stackmap/commerce-api/diagram.json + + ); + const line = (l: string, i: number) => { + const tone = l.startsWith('error') || l.startsWith('✗') ? 'text-[#f0806e]' : l.startsWith('✓') ? 'text-[#a6d97a]' : ''; + return ( + + {l} + + ); + }; + return ( +
    +
    A repair round, as the agent sees it
    +
    +        {prompt('validate')}
    +        {broken.map(line)}
    +         
    +        {prompt('validate')}
    +        {clean.map(line)}
    +         
    +        {prompt('deliver')}
    +        
    +          delivered {file} · sha256 {sha256.slice(0, 12)}… · {bytes} bytes
    +        
    +      
    +
    + ); +} + +export default function DocsPage() { + return ( + +

    Docs › Get started

    +

    + Quick start. +

    +

    Install the skill, ask your coding agent for a diagram, and open the file it delivers.

    +
      + + +

      It installs into Claude Code, Cursor, Codex and the other agents that skills supports.

      +
      + + Make an architecture diagram of this repository, backed by evidence from the code. +

      + The agent writes .stackmap/<name>/diagram.json, validates it, repairs what the diagnostics name, and delivers{' '} + .stackmap/<name>/diagram.html. +

      +
      + +

      + Open diagram.html in any browser. There is no server, no account and nothing to install. To change the diagram, ask again: the agent edits + the JSON and delivers it again. The viewer itself is read-only. +

      +
      +
    + +

    + What the agent writes. +

    +

    + One small typed JSON file. The agent names the nodes, connections, groups and views; stackmap validates it and lays it out. There are no coordinates to write. +

    + +
    +
    + {[0, 1, 2].map((i) => ( +