Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
4a84b3d
docs(brand): redraw the social card with the real viewer, add a GitHu…
hyamero Oct 2, 2026
e581708
docs(site): drop Kinds from the site nav
hyamero Oct 2, 2026
9a01963
docs(brand): keep the GitHub social preview inside GitHub's 80px crop…
hyamero Oct 2, 2026
070e03b
ci: publish to npm with trusted publishing instead of a token
hyamero Oct 2, 2026
5ab4ec8
Merge pull request #36 from hyamero/docs/link-previews
hyamero Oct 2, 2026
0c45a89
Merge pull request #37 from hyamero/ci/npm-trusted-publishing
hyamero Oct 2, 2026
506ec28
fix(layout): keep edge labels clear of other labels, cards and other …
hyamero Oct 2, 2026
fc449ef
feat(viewer): slow the flow to a calmer pace and add a 0.5×, 1×, 2× s…
hyamero Oct 2, 2026
f6ba798
docs(site): document the flow speed control and the speed link parameter
hyamero Oct 2, 2026
635eafc
feat(cli): open the delivered viewer in the browser with deliver --op…
hyamero Oct 2, 2026
e18a2cc
docs(site): document deliver --open
hyamero Oct 2, 2026
8161a8d
test(viewer): update the lifecycle and stages baselines for the moved…
hyamero Oct 2, 2026
2030fdb
Merge pull request #38 from hyamero/fix/labels-flow-speed-open
hyamero Oct 2, 2026
45984b7
feat(viewer): add a focus mode that leaves only the canvas, with a to…
hyamero Oct 2, 2026
19c91c8
docs(site): document focus mode and its Z and H keys
hyamero Oct 2, 2026
84da542
test(viewer): update the visual baselines for the toolbar's focus button
hyamero Oct 2, 2026
54b3a7c
Merge pull request #40 from hyamero/feat/focus-mode
hyamero Oct 2, 2026
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
5 changes: 3 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -141,7 +141,7 @@ jobs:
contents: write # tags and the GitHub release
issues: write # release comments on issues
pull-requests: write # and on pull requests
id-token: write # npm provenance
id-token: write # npm trusted publishing (OIDC) and provenance
steps:
# The deploy key pushes the release commit and the back-merge past branch protection.
- uses: actions/checkout@v4
Expand All @@ -155,13 +155,14 @@ jobs:
with:
node-version: 24
registry-url: https://registry.npmjs.org
# Trusted publishing needs npm 11.5.1+, newer than some Node 24 images bundle.
- run: npm install -g npm@^11.5.1
- run: bun install --frozen-lockfile
# The CLI's prepack bundles the viewer template, which this fresh checkout hasn't built yet.
- run: bun run build
- run: npx --no-install semantic-release
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
GIT_AUTHOR_NAME: github-actions[bot]
GIT_AUTHOR_EMAIL: 41898282+github-actions[bot]@users.noreply.github.com
GIT_COMMITTER_NAME: github-actions[bot]
Expand Down
8 changes: 4 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ Then ask:
</picture>
</p>

The agent writes `.stackmap/<name>/diagram.json`, validates and repairs it, and delivers `.stackmap/<name>/diagram.html`. Open that file in any browser. To change the diagram, ask again: the agent edits the JSON and delivers it again. The viewer itself is read-only.
The agent writes `.stackmap/<name>/diagram.json`, validates and repairs it, and delivers `.stackmap/<name>/diagram.html`, opening it in your browser. It opens in any browser later too. To change the diagram, ask again: the agent edits the JSON and delivers it again. The viewer itself is read-only.

## The viewer

Expand Down Expand Up @@ -90,9 +90,9 @@ Full reference: [skill/references/schema.md](skill/references/schema.md), or the
The skill runs the CLI for you, and it works on its own too (Node 22.12 or later):

```bash
npx @hyamero/stackmap validate diagram.json [--json] # diagnostics with fixes; exit 1 on errors
npx @hyamero/stackmap deliver diagram.json [-o out.html] # validate → layout → one offline HTML file
npx @hyamero/stackmap serve diagram.json [--port 4400] # live viewer that reloads on every save
npx @hyamero/stackmap validate diagram.json [--json] # diagnostics with fixes; exit 1 on errors
npx @hyamero/stackmap deliver diagram.json [-o out.html] [--open] # validate → layout → one offline HTML file
npx @hyamero/stackmap serve diagram.json [--port 4400] # live viewer that reloads on every save
```

- **validate** lists every problem with a code, its evidence and the allowed fixes, including card text that won't fit. It exits `1` when the diagram has errors and `2` on usage, IO or internal errors.
Expand Down
3 changes: 2 additions & 1 deletion assets/brand/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,8 @@ Every file carries explicit colours, so pick the light file or its `-dark` twin
| `stackmap-app-icon.svg`, `-512.png`, `stackmap-apple-touch-icon-180.png` | The full mark on the near-black icon tile |
| `stackmap-favicon.svg`, `-32.png`, `-16.png` | The compact mark on the icon tile (inlined in the viewer) |
| `stackmap-readme-header.png`, `-dark` | The 1280×400 README header at 2×, in a `<picture>` at the full column width |
| `stackmap-social-card.png`, `.svg`, `-light.svg` | The 1200×630 link preview (`og:image`, the GitHub social preview) |
| `stackmap-social-card.png`, `-light.png` | The 1200×630 link preview (`og:image`, `twitter:image`); the site serves the dark one |
| `stackmap-github-preview.png` | The 1280×640 GitHub social preview (Settings › General › Social preview), with everything that matters inside GitHub's 80px crop border |

## Rules

Expand Down
Binary file added assets/brand/stackmap-github-preview.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added assets/brand/stackmap-social-card-light.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
1 change: 0 additions & 1 deletion assets/brand/stackmap-social-card-light.svg

This file was deleted.

Binary file modified assets/brand/stackmap-social-card.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
1 change: 0 additions & 1 deletion assets/brand/stackmap-social-card.svg

This file was deleted.

9 changes: 7 additions & 2 deletions packages/cli/src/cli.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { readFileSync } from 'node:fs';
import { parseArgs } from 'node:util';
import { deliverCommand, validateCommand, type CommandResult } from './commands';
import { openInBrowser } from './open';
import { serve } from './serve';
import { styleFor } from './style';

Expand All @@ -12,12 +13,14 @@ const HELP = `stackmap ${VERSION}: validate agent-authored diagrams and deliver

Usage:
stackmap validate <diagram.json> [--json] check the diagram; exit 1 on errors
stackmap deliver <diagram.json> [-o out.html] validate, lay out and write the viewer
stackmap deliver <diagram.json> [-o out.html] [--open]
validate, lay out and write the viewer
stackmap serve <diagram.json> [--port 4400] live viewer that reloads when the file changes

Options:
--json machine-readable diagnostics (validate)
-o, --out output path (deliver; default: next to the input, .html)
--open open the written viewer in the default browser (deliver)
--port port for serve (default 4400; the next free one is used if taken)
-h, --help show this help
-v, --version show the version
Expand All @@ -41,6 +44,7 @@ async function run(argv: string[]): Promise<CommandResult> {
options: {
json: { type: 'boolean' },
out: { type: 'string', short: 'o' },
open: { type: 'boolean' },
port: { type: 'string' },
help: { type: 'boolean', short: 'h' },
version: { type: 'boolean', short: 'v' },
Expand All @@ -58,6 +62,7 @@ async function run(argv: string[]): Promise<CommandResult> {
if (command !== 'validate' && command !== 'deliver' && command !== 'serve') return usage(`unknown command "${command}"`);
if (!file || extra.length) return usage(`${command} takes exactly one diagram file`);
if (values.port !== undefined && command !== 'serve') return usage('--port applies to serve');
if (values.open && command !== 'deliver') return usage('--open applies to deliver');
if (command === 'validate') {
if (values.out !== undefined) return usage('-o/--out applies to deliver, not validate');
return values.json ? validateCommand(file, { json: true }) : signed(command, await validateCommand(file, { json: false }));
Expand All @@ -70,7 +75,7 @@ async function run(argv: string[]): Promise<CommandResult> {
} catch (e) {
return { code: 2, stdout: '', stderr: `stackmap: internal error: viewer template missing (${(e as Error).message})\n` };
}
if (command === 'deliver') return signed(command, await deliverCommand(file, { template, out: values.out, style }));
if (command === 'deliver') return signed(command, await deliverCommand(file, { template, out: values.out, style, open: values.open ? openInBrowser : undefined }));

const port = values.port === undefined ? 4400 : Number(values.port);
if (!Number.isInteger(port) || port < 0 || port > 65535) return usage(`--port must be 0-65535, got "${values.port}"`);
Expand Down
18 changes: 16 additions & 2 deletions packages/cli/src/commands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,18 @@ export async function buildHtml(path: string, template: string): Promise<Validat

export async function deliverCommand(
path: string,
{ template, out, style = plain }: { template: string; out?: string; style?: Style },
{
template,
out,
style = plain,
open,
}: {
template: string;
out?: string;
style?: Style;
/** opens the written file (deliver --open): null once it did, else why it couldn't */
open?: (file: string) => Promise<string | null>;
},
): Promise<CommandResult> {
try {
const target = out ?? path.slice(0, path.length - extname(path).length) + '.html';
Expand All @@ -82,7 +93,10 @@ export async function deliverCommand(
writeAtomic(target, html);
const bytes = Buffer.byteLength(html);
const sha = createHash('sha256').update(html).digest('hex');
return { code: 0, stdout: `delivered ${style.path(target)} · sha256 ${sha} · ${bytes} bytes\n`, stderr: report(diagnostics) };
// The file is written either way, so a browser that won't open is a warning, not a failure.
const unopened = open ? await open(target) : null;
const warning = unopened ? `warning: couldn't open ${target} in a browser (${unopened})\n` : '';
return { code: 0, stdout: `delivered ${style.path(target)} · sha256 ${sha} · ${bytes} bytes\n`, stderr: report(diagnostics) + warning };
} catch (e) {
return failure(e);
}
Expand Down
28 changes: 28 additions & 0 deletions packages/cli/src/open.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import { spawn } from 'node:child_process';

/** The command that opens a file in the default browser on `platform`. */
export function browserCommand(file: string, platform: NodeJS.Platform = process.platform): { cmd: string; args: string[] } {
if (platform === 'darwin') return { cmd: 'open', args: [file] };
// `start` is a cmd builtin, and its first quoted argument is the window title.
if (platform === 'win32') return { cmd: 'cmd', args: ['/c', 'start', '""', file] };
return { cmd: 'xdg-open', args: [file] };
}

/**
* Opens `file` in the default browser; resolves null once the opener has handed it off, or why it couldn't (no
* opener installed, a headless machine). An opener that is still running after a moment is taken as success.
*/
export function openInBrowser(file: string): Promise<string | null> {
const { cmd, args } = browserCommand(file);
return new Promise((resolve) => {
const child = spawn(cmd, args, { stdio: 'ignore', detached: true, windowsVerbatimArguments: cmd === 'cmd' });
const timer = setTimeout(() => resolve(null), 3000);
const done = (why: string | null) => {
clearTimeout(timer);
resolve(why);
};
child.once('error', (e) => done((e as NodeJS.ErrnoException).code === 'ENOENT' ? `${cmd} not found` : e.message));
child.once('exit', (code) => done(code ? `${cmd} exited with ${code}` : null));
child.unref();
});
}
20 changes: 20 additions & 0 deletions packages/cli/test/commands.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,26 @@ describe('validate', () => {
});

describe('deliver', () => {
it('opens the written file when asked, and still delivers with a warning when it can\'t', async () => {
const input = file('platform.json', groupedPlatform);
const opened: string[] = [];
const ok = await deliverCommand(input, { template: TEMPLATE, open: async (f) => (opened.push(f), null) });
expect(ok.code).toBe(0);
expect(opened).toEqual([join(dir, 'platform.html')]);
expect(ok.stderr).toBe('');
const failed = await deliverCommand(input, { template: TEMPLATE, open: async () => 'xdg-open not found' });
expect(failed.code).toBe(0);
expect(failed.stdout).toMatch(/^delivered /);
expect(failed.stderr).toBe(`warning: couldn't open ${join(dir, 'platform.html')} in a browser (xdg-open not found)\n`);
});

it('opens nothing when validation fails', async () => {
let opened = false;
const r = await deliverCommand(file('broken.json', broken), { template: TEMPLATE, open: async () => ((opened = true), null) });
expect(r.code).toBe(1);
expect(opened).toBe(false);
});

it('writes the HTML next to the input and prints a sha256 receipt', async () => {
const input = file('platform.json', groupedPlatform);
const r = await deliverCommand(input, { template: TEMPLATE });
Expand Down
10 changes: 10 additions & 0 deletions packages/cli/test/open.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import { describe, expect, it } from 'vitest';
import { browserCommand } from '../src/open';

describe('browserCommand', () => {
it("uses each platform's own opener", () => {
expect(browserCommand('/tmp/a b.html', 'darwin')).toEqual({ cmd: 'open', args: ['/tmp/a b.html'] });
expect(browserCommand('C:\\d\\a.html', 'win32')).toEqual({ cmd: 'cmd', args: ['/c', 'start', '""', 'C:\\d\\a.html'] });
expect(browserCommand('/tmp/a.html', 'linux')).toEqual({ cmd: 'xdg-open', args: ['/tmp/a.html'] });
});
});
26 changes: 5 additions & 21 deletions packages/layout/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import ElkApi from 'elkjs/lib/elk-api.js';
import ElkBundled from 'elkjs/lib/elk.bundled.js';
import type { ElkExtendedEdge, ElkNode, ElkPort } from 'elkjs/lib/elk-api';
import { cardSize, isLaneKind, usesCompactCards, type DiagramDraft, type Direction, type LaidOutDiagram, type Point, type Rect } from '@stackmap/core';
import { findLabelSpot, labelWidth, placeLabel } from './labels';
import { placeLabels } from './labels';
import { layoutLanes } from './lanes';
import { layoutSequence } from './sequence';

Expand Down Expand Up @@ -158,35 +158,19 @@ export async function layoutDiagram(draft: DiagramDraft): Promise<LaidOutDiagram
return collect(draft, result, direction, stages !== null);
};
let laid = await run();
if (!compact) return laid;
const place = (l: LaidOutDiagram) => placeLabels(draft.edges, l.edges, Object.values(l.nodes), !compact);
let spots = place(laid);
// Compact layouts start with tight layer gaps and widen them once if a label found no room to sit.
let spots = compactLabels(draft, laid);
if (spots.misfit > 0) {
if (compact && spots.misfit > 0) {
laid = await run(Math.min(COMPACT_GAP.max, Math.max(Number(root.layoutOptions!['elk.layered.spacing.nodeNodeBetweenLayers']), spots.misfit + 24)));
spots = compactLabels(draft, laid);
spots = place(laid);
}
return { ...laid, labels: spots.labels };
} finally {
dispose();
}
}

/** Widest label (px) that found no free spot on its route; 0 when all fit. Labels are placed either way. */
function compactLabels(draft: DiagramDraft, laid: LaidOutDiagram): { labels: Record<string, Point>; misfit: number } {
const cards = Object.values(laid.nodes);
const taken: Rect[] = [];
const labels: Record<string, Point> = {};
let misfit = 0;
const pending = draft.edges.filter((e) => e.label && laid.edges[e.id]);
for (const e of pending) {
const spot = findLabelSpot(laid.edges[e.id]!, e.label!, cards, taken);
if (spot) labels[e.id] = spot;
else misfit = Math.max(misfit, labelWidth(e.label!));
}
for (const e of pending) if (!labels[e.id]) labels[e.id] = placeLabel(laid.edges[e.id]!, e.label!, cards, taken);
return { labels, misfit };
}

function collect(draft: DiagramDraft, result: ElkNode, direction: Direction, staged: boolean): LaidOutDiagram {
const nodes: Record<string, Rect> = {};
const groups: Record<string, Rect> = {};
Expand Down
66 changes: 52 additions & 14 deletions packages/layout/src/labels.ts
Original file line number Diff line number Diff line change
@@ -1,26 +1,65 @@
import { measureText, type Point, type Rect } from '@stackmap/core';
import { measureText, polylineMidpoint, type Point, type Rect } from '@stackmap/core';

// Edge label placement shared by the lane and compact ELK layouts: the viewer's pill (11px Geist, 8px padding
// each side, 20px tall) on a run of the route, clear of cards and of other labels.
// Edge label placement shared by the lane and ELK layouts: the viewer's pill (11px Geist, 8px padding each side,
// 20px tall) on a run of the route, clear of cards, other labels and other edges' lines.

const LABEL_H = 20;
export const labelWidth = (text: string) => Math.ceil(measureText(text, 'sans400', 11)) + 16;
const round = (n: number) => Math.round(n * 100) / 100;

const overlaps = (a: Rect, b: Rect) => a.x < b.x + b.width && b.x < a.x + a.width && a.y < b.y + b.height && b.y < a.y + a.height;
/** Whether any axis-aligned segment of a route runs through the rectangle. */
const crossed = (r: Rect, lines: Point[][]) =>
lines.some((pts) =>
pts.slice(1).some((b, i) => {
const a = pts[i]!;
return Math.max(a.x, b.x) > r.x && Math.min(a.x, b.x) < r.x + r.width && Math.max(a.y, b.y) > r.y && Math.min(a.y, b.y) < r.y + r.height;
}),
);
const pillAt = (p: Point, w: number): Rect => ({ x: p.x - w / 2, y: p.y - LABEL_H / 2, width: w, height: LABEL_H });

/**
* The label goes on the longest run that holds its pill without touching a card or another label; along a run
* it may slide off the midpoint to clear a neighbour's pill. Horizontal runs are preferred.
* Every label of a laid-out diagram, in draft order. A label goes on the longest run of its route that holds its
* pill clear of cards, other labels and other edges' lines; along a run it may slide off the midpoint to clear a
* neighbour. Horizontal runs are preferred. Where no spot clears the lines, one that clears cards and labels does,
* and failing that the middle of the longest run. With `keepMidpoints`, a label whose route midpoint is already
* clear stays there (where the viewer drew it before full layouts placed labels), so only the crowded ones move.
* `misfit` is the widest label that found no fully clear spot, 0 when all did.
*/
export function placeLabel(points: Point[], text: string, cards: Rect[], taken: Rect[]): Point {
return findLabelSpot(points, text, cards, taken) ?? claimFallback(points, text, taken);
export function placeLabels(
edges: { id: string; label?: string }[],
routes: Record<string, Point[]>,
cards: Rect[],
keepMidpoints = false,
): { labels: Record<string, Point>; misfit: number } {
const labelled = edges.filter((e) => e.label && routes[e.id]);
const others = (id: string) => Object.entries(routes).flatMap(([k, pts]) => (k === id ? [] : [pts]));
const taken: Rect[] = [];
const labels: Record<string, Point> = {};
if (keepMidpoints) {
for (const e of labelled) {
const p = polylineMidpoint(routes[e.id]!);
const pill = pillAt(p, labelWidth(e.label!));
if (cards.some((r) => overlaps(pill, r)) || taken.some((r) => overlaps(pill, r)) || crossed(pill, others(e.id))) continue;
// Claimed now, so a later label whose midpoint lands here is the one that moves.
taken.push(pill);
labels[e.id] = { x: round(p.x), y: round(p.y) };
}
}
let misfit = 0;
for (const e of labelled) {
if (labels[e.id]) continue;
const spot = findLabelSpot(routes[e.id]!, e.label!, cards, taken, others(e.id));
if (spot) labels[e.id] = spot;
else misfit = Math.max(misfit, labelWidth(e.label!));
}
for (const e of labelled) if (!labels[e.id]) labels[e.id] = findLabelSpot(routes[e.id]!, e.label!, cards, taken) ?? claimFallback(routes[e.id]!, e.label!, taken);
return { labels, misfit };
}

/** A spot that fits, claimed in `taken`; null when none does. */
export function findLabelSpot(points: Point[], text: string, cards: Rect[], taken: Rect[]): Point | null {
export function findLabelSpot(points: Point[], text: string, cards: Rect[], taken: Rect[], lines: Point[][] = []): Point | null {
const w = labelWidth(text);
const pillAt = (p: Point): Rect => ({ x: p.x - w / 2, y: p.y - LABEL_H / 2, width: w, height: LABEL_H });
let best: { p: Point; score: number } | null = null;
for (let i = 1; i < points.length; i++) {
const a = points[i - 1]!;
Expand All @@ -36,8 +75,8 @@ export function findLabelSpot(points: Point[], text: string, cards: Rect[], take
search: for (const [dx, dy] of offsets) {
for (const t of [0.5, 0.35, 0.65, 0.2, 0.8]) {
const p = { x: round(a.x + (b.x - a.x) * t + dx), y: round(a.y + (b.y - a.y) * t + dy) };
const pill = pillAt(p);
if (cards.some((r) => overlaps(pill, r)) || taken.some((r) => overlaps(pill, r))) continue;
const pill = pillAt(p, w);
if (cards.some((r) => overlaps(pill, r)) || taken.some((r) => overlaps(pill, r)) || crossed(pill, lines)) continue;
// Longer and horizontal runs first; off-centre and off-line spots lose a little.
const score = (horizontal ? 2 : 1) * len - Math.abs(t - 0.5) * 40 - (dx || dy ? 30 : 0);
if (!best || score > best.score) best = { p, score };
Expand All @@ -46,7 +85,7 @@ export function findLabelSpot(points: Point[], text: string, cards: Rect[], take
}
}
if (!best) return null;
taken.push(pillAt(best.p));
taken.push(pillAt(best.p, w));
return best.p;
}

Expand All @@ -59,8 +98,7 @@ function claimFallback(points: Point[], text: string, taken: Rect[]): Point {
const len = Math.abs(a.x - b.x) + Math.abs(a.y - b.y);
if (len > best.len) best = { p: { x: round((a.x + b.x) / 2), y: round((a.y + b.y) / 2) }, len };
}
const w = labelWidth(text);
taken.push({ x: best.p.x - w / 2, y: best.p.y - LABEL_H / 2, width: w, height: LABEL_H });
taken.push(pillAt(best.p, labelWidth(text)));
return best.p;
}

Loading
Loading