Skip to content
Merged
16 changes: 15 additions & 1 deletion packages/cli/src/embed.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,9 @@ import type { LaidOutDiagram } from '@stackmap/core';

/** The block the viewer template ships empty; the viewer reads it back by id. */
export const EMPTY_DATA_BLOCK = '<script type="application/json" id="stackmap-data"></script>';
/** The template carries every brand mark as data (~170 KB); a delivered file keeps the ones it draws. */
export const BRANDS_BLOCK_ID = 'stackmap-brands';
const BRANDS_OPEN = `<script type="application/json" id="${BRANDS_BLOCK_ID}">`;

const escapeHtml = (s: string) => s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');

Expand All @@ -23,5 +26,16 @@ export function embedDiagram(template: string, diagram: LaidOutDiagram): string
if (titleAt < 0) throw new Error('viewer template must have a <title> in its <head>');
const titleEnd = head.indexOf('</title>', titleAt) + '</title>'.length;
const title = `<title>${escapeHtml(diagram.draft.title)} · stackmap</title>`;
return parts[0]!.slice(0, titleAt) + title + parts[0]!.slice(titleEnd) + block + parts[1]!;
return keepBrands(parts[0]!.slice(0, titleAt) + title + parts[0]!.slice(titleEnd) + block + parts[1]!, diagram);
}

function keepBrands(html: string, diagram: LaidOutDiagram): string {
const start = html.indexOf(BRANDS_OPEN);
if (start < 0) return html;
const from = start + BRANDS_OPEN.length;
const end = html.indexOf('</script>', from);
const all = JSON.parse(html.slice(from, end)) as Record<string, unknown>;
const used = new Set(diagram.draft.nodes.flatMap((n) => (n.card.brand ? [n.card.brand] : [])));
const kept = Object.fromEntries(Object.entries(all).filter(([slug]) => used.has(slug)));
return html.slice(0, from) + toScriptJson(kept) + html.slice(end);
}
3 changes: 2 additions & 1 deletion packages/cli/src/io.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { randomBytes } from 'node:crypto';
import { readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs';
import { mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs';
import { basename, dirname, join } from 'node:path';

export class CliError extends Error {}
Expand All @@ -19,6 +19,7 @@ export function readText(path: string): string {
export function writeAtomic(path: string, data: string): void {
const tmp = join(dirname(path), `.${basename(path)}.${randomBytes(4).toString('hex')}.tmp`);
try {
mkdirSync(dirname(path), { recursive: true });
writeFileSync(tmp, data);
renameSync(tmp, path);
} catch (e) {
Expand Down
7 changes: 7 additions & 0 deletions packages/cli/test/commands.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,13 @@ describe('deliver', () => {
expect(r.stderr).toMatch(/^stackmap: internal error: viewer template must contain/);
});

it('creates the folders -o names', async () => {
const out = join(dir, 'new', 'deeper', 'd.html');
const r = await deliverCommand(file('d.json', commerceApi), { template: TEMPLATE, out });
expect(r.code).toBe(0);
expect(readFileSync(out, 'utf8')).toContain('stackmap-data');
});

it('keeps the previous file intact and leaves no temp file when the write fails', async () => {
const input = file('d.json', commerceApi);
const outDir = join(dir, 'out');
Expand Down
12 changes: 11 additions & 1 deletion packages/cli/test/embed.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { describe, expect, it } from 'vitest';
import type { LaidOutDiagram } from '@stackmap/core';
import { commerceApi } from '@stackmap/core/samples';
import { embedDiagram, EMPTY_DATA_BLOCK } from '../src/embed';
import { BRANDS_BLOCK_ID, embedDiagram, EMPTY_DATA_BLOCK } from '../src/embed';

const template = `<!doctype html><html><head><title>stackmap</title></head><body><div id="root"></div>${EMPTY_DATA_BLOCK}</body></html>`;
const laidOut = (title: string): LaidOutDiagram => ({ draft: { ...commerceApi, title }, nodes: {}, groups: {}, edges: {}, bounds: { width: 0, height: 0 } });
Expand Down Expand Up @@ -38,4 +38,14 @@ describe('embedDiagram', () => {
expect(() => embedDiagram(template + EMPTY_DATA_BLOCK, laidOut('T'))).toThrow(/template/);
expect(() => embedDiagram(`<head></head><script>"<title>x</title>"</script>${EMPTY_DATA_BLOCK}`, laidOut('T'))).toThrow(/<title>/);
});

it('keeps only the brand marks the diagram uses', () => {
const marks = { postgresql: { title: 'PostgreSQL', path: 'M1' }, redis: { title: 'Redis', path: 'M2' }, nginx: { title: 'NGINX', path: 'M3' } };
const withBrands = template.replace('</body>', `<script type="application/json" id="${BRANDS_BLOCK_ID}">${JSON.stringify(marks)}</script></body>`);
const html = embedDiagram(withBrands, laidOut('T'));
const m = html.match(new RegExp(`<script type="application/json" id="${BRANDS_BLOCK_ID}">([\\s\\S]*?)</script>`));
// commerceApi uses postgresql and redis.
expect(JSON.parse(m![1]!)).toEqual({ postgresql: marks.postgresql, redis: marks.redis });
expect(dataOf(html)).toEqual(laidOut('T'));
});
});
4 changes: 3 additions & 1 deletion packages/core/src/text-measure.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ export type { FontFace };
const FALLBACK_EM = 1.1;
const EMOJI_EM = 1.3;
const EMOJI = /[\p{Extended_Pictographic}\p{Regional_Indicator}]/u;
// Joiners, variation selectors and combining marks draw nothing themselves (ZWJ emoji, ❤️, é).
const ZERO_WIDTH = /[\p{Mn}\p{Me}\p{Cf}]/u;

const decoded = new Map<FontFace, KerningPairs>();
/** A face's pair adjustments in 1/1000 em, decoded from the shipped table on first use. */
Expand Down Expand Up @@ -38,7 +40,7 @@ export function textWidths(text: string, face: FontFace, sizePx: number): { exac
let prev = -1;
for (const ch of text) {
const cp = ch.codePointAt(0)!;
const advance = ((advances[cp] ?? (EMOJI.test(ch) ? EMOJI_EM : FALLBACK_EM) * em) * sizePx) / em;
const advance = ((advances[cp] ?? (EMOJI.test(ch) ? EMOJI_EM : ZERO_WIDTH.test(ch) ? 0 : FALLBACK_EM) * em) * sizePx) / em;
// Kerning pairs are ASCII-only, so the packed key never collides.
const kern = prev < 0 || prev > 0x7e ? 0 : ((kerning[prev * 0x10000 + cp] ?? 0) * sizePx) / em;
exact += advance + kern;
Expand Down
8 changes: 8 additions & 0 deletions packages/core/test/text-measure.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,14 @@ describe('measureText', () => {
expect(exact('a🚀', 'sans400', 10)).toBeCloseTo(13 + (faces.sans400[97]! * 10) / 1000, 6);
});

it('gives joiners, variation selectors and combining marks no width of their own', () => {
// Worst case, a sequence the system font can't join renders as its parts side by side.
expect(exact('👩‍👩‍👧', 'sans400', 10)).toBeCloseTo(3 * 13, 6);
expect(exact('❤️', 'sans400', 10)).toBeCloseTo(13, 6);
expect(exact('a​b', 'sans400', 10)).toBeCloseTo(exact('a', 'sans400', 10) + exact('b', 'sans400', 10), 6);
expect(exact('é', 'sans400', 10)).toBeCloseTo(exact('e', 'sans400', 10), 6);
});

// Linux Chromium snaps every glyph advance to whole pixels and skips kerning; these are the widths it
// rendered in CI (Playwright v1.63 image). The measure must never come in under either platform.
it.each([
Expand Down
35 changes: 34 additions & 1 deletion packages/schema/src/rules/semantics.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,9 @@ function cycles(nodes: string[], edges: [string, string][]): string[][] {
return out;
}

/** Duplicate ids, orphans, self-loops, dataflow cycles, empty views and groups. */
const LARGE_DIAGRAM = 60;

/** Size, duplicate ids, orphans, self-loops, parallel edges, dataflow cycles, empty views and groups. */
export function semanticsDiagnostics(d: DiagramDraft): Diagnostic[] {
const out: Diagnostic[] = [
...duplicates(d.nodes, 'nodes'),
Expand All @@ -85,6 +87,18 @@ export function semanticsDiagnostics(d: DiagramDraft): Diagnostic[] {
});
});

// The authoring contract aims for ~10–40 nodes; well past that cards are unreadable at fit, and a grouped
// layout of a few hundred nodes takes ELK tens of seconds.
if (d.nodes.length > LARGE_DIAGRAM)
out.push({
code: 'semantics/large-diagram',
severity: 'warning',
subject: '/nodes',
message: `The diagram has ${d.nodes.length} nodes; past ~${LARGE_DIAGRAM} its cards are too small to read on one screen`,
evidence: { nodes: d.nodes.length, limit: LARGE_DIAGRAM },
allowedFixes: ['split it into an overview (subsystems as single nodes) plus one diagram per subsystem', 'merge nodes the reader need not tell apart'],
});

const linked = new Set(d.edges.flatMap((e) => [e.from, e.to]));
if (d.nodes.length > 1) {
d.nodes.forEach((n, i) => {
Expand Down Expand Up @@ -113,6 +127,25 @@ export function semanticsDiagnostics(d: DiagramDraft): Diagnostic[] {
});
});

// ELK gives each card one in and one out port, so two edges a → b share one route and one label spot.
if (d.kind === 'architecture' || d.kind === 'dataflow') {
const first = new Map<string, string>();
d.edges.forEach((e, i) => {
if (e.from === e.to) return;
const key = `${e.from}\u0000${e.to}`;
const seen = first.get(key);
if (seen === undefined) return void first.set(key, e.id);
out.push({
code: 'semantics/parallel-edge',
severity: 'warning',
subject: `/edges/${i}`,
message: `Edges "${seen}" and "${e.id}" both run from "${e.from}" to "${e.to}"; they draw as one line, labels on top of each other`,
evidence: { id: e.id, first: seen, from: e.from, to: e.to },
allowedFixes: [`merge "${e.id}" into "${seen}" (one label for both)`, 'remove the edge'],
});
});
}

if (d.kind === 'dataflow') {
// An async or return edge is an explicit feedback path (retry, event back-channel), so it doesn't close a cycle.
const feedback = (e: { kind?: string }) => e.kind === 'async' || e.kind === 'return';
Expand Down
25 changes: 25 additions & 0 deletions packages/schema/test/validate.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -252,6 +252,31 @@ describe('validateDiagram', () => {
]);
});

it('a second edge between the same two nodes, same way, warns where the layout draws them as one line', () => {
const edges = [
{ id: 'ab', from: 'a', to: 'b', label: 'reads' },
{ id: 'ba', from: 'b', to: 'a', kind: 'return' as const },
{ id: 'ab2', from: 'a', to: 'b', label: 'writes' },
];
expect(only(base({ edges }), 'semantics/parallel-edge')).toMatchObject([
{ subject: '/edges/2', severity: 'warning', evidence: { id: 'ab2', first: 'ab', from: 'a', to: 'b' } },
]);
expect(only(base({ kind: 'dataflow', edges }), 'semantics/parallel-edge')).toHaveLength(1);
// Swimlanes route parallel edges apart; a sequence repeats messages over time.
const lane = { lane: 'l' };
expect(only(base({ kind: 'workflow', lanes: [{ id: 'l', label: 'L' }], nodes: [node('a', lane), node('b', lane)], edges }), 'semantics/parallel-edge')).toEqual([]);
expect(only(base({ kind: 'sequence', edges }), 'semantics/parallel-edge')).toEqual([]);
});

it('past 60 nodes the diagram warns once to split', () => {
const nodes = (n: number) => Array.from({ length: n }, (_, i) => node(`n${i}`));
const chain = (n: number) => Array.from({ length: n - 1 }, (_, i) => ({ id: `e${i}`, from: `n${i}`, to: `n${i + 1}` }));
expect(only(base({ nodes: nodes(60), edges: chain(60) }), 'semantics/large-diagram')).toEqual([]);
expect(only(base({ nodes: nodes(61), edges: chain(61) }), 'semantics/large-diagram')).toMatchObject([
{ subject: '/nodes', severity: 'warning', evidence: { nodes: 61, limit: 60 } },
]);
});

it('cycles warn in dataflow diagrams only', () => {
const edges = [
{ id: 'ab', from: 'a', to: 'b' },
Expand Down
22 changes: 22 additions & 0 deletions packages/viewer/e2e/explore.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -281,6 +281,28 @@ test('the inspector starts collapsed on a narrow window and can be opened', asyn
await expect(inspector(page).getByRole('list', { name: 'Legend' })).toBeVisible();
});

test('on a phone the inspector opens over the canvas, and the toolbar keeps to one row', async ({ page }) => {
await page.setViewportSize({ width: 390, height: 844 });
await page.reload();
await expect(page.locator('.sm-card')).toHaveCount(6);
const stage = page.getByRole('tabpanel', { name: 'Diagram' });
const full = (await stage.boundingBox())!;
expect(full.width).toBeGreaterThan(350);
const [first, last] = await Promise.all([page.getByRole('button', { name: 'Search nodes (/)' }).boundingBox(), page.getByRole('button', { name: 'Export' }).boundingBox()]);
expect(Math.abs(first!.y + first!.height / 2 - (last!.y + last!.height / 2))).toBeLessThan(2);
await inspector(page).getByRole('button', { name: 'Show inspector' }).click();
await expect(inspector(page).getByRole('list', { name: 'Legend' })).toBeVisible();
expect((await stage.boundingBox())!.width).toBe(full.width);
const sheet = (await inspector(page).boundingBox())!;
expect(sheet.x).toBeGreaterThanOrEqual(full.x);
expect(sheet.x + sheet.width).toBeLessThanOrEqual(full.x + full.width);
expect(sheet.y + sheet.height).toBeLessThanOrEqual(full.y + full.height);
await card(page, 'orders').click({ force: true });
await expect(inspector(page).getByRole('heading', { level: 2, name: 'Orders' })).toBeVisible();
await inspector(page).getByRole('button', { name: 'Hide inspector' }).click();
await expect(inspector(page).getByRole('button', { name: 'Show inspector' })).toBeFocused();
});

test('with reduced motion the camera jumps instead of animating', async ({ page }) => {
await page.emulateMedia({ reducedMotion: 'reduce' });
await page.reload();
Expand Down
2 changes: 1 addition & 1 deletion packages/viewer/e2e/metrics.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -108,7 +108,7 @@ test('a card slot never truncates text the measure said fits, and the measure st
test('text outside the Latin subset is never measured narrower than it renders', async ({ page }) => {
await page.goto('/?page=gallery');
await page.evaluate(() => document.fonts.ready);
const corpus = ['🚀🚀🚀 deploy', '✅ healthy', '🇩🇪 Frankfurt', '支付服务', '注文サービス', '결제 서비스', 'naïve café', 'Zürich — Ørsted'];
const corpus = ['🚀🚀🚀 deploy', '✅ healthy', '🇩🇪 Frankfurt', '支付服务', '注文サービス', '결제 서비스', 'naïve café', 'Zürich — Ørsted', '👩‍👩‍👧‍👦 family', '❤️ loved', '👍🏽 ok', 'cafe\u0301', 'zero\u200bwidth'];
const real = await page.evaluate((corpus) => {
const span = document.createElement('span');
span.style.cssText = "font:500 13.5px 'Geist';white-space:pre;position:absolute";
Expand Down
6 changes: 5 additions & 1 deletion packages/viewer/scripts/check-single-file.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { readdirSync, readFileSync, statSync } from 'node:fs';

// M3 measured ~600 KB (fonts ~52 KB, 146 brand paths ~157 KB, React + d3); ~15% headroom.
// M3 measured ~600 KB (fonts ~52 KB, 146 brand paths ~157 KB, React + d3); ~15% headroom. deliver drops unused brands.
const BUDGET_KB = 700;
const dist = new URL('../dist/', import.meta.url);
const fail = (msg: string): never => {
Expand All @@ -26,6 +26,10 @@ if (/elkjs|org\.eclipse\.elk/.test(html)) fail('elkjs leaked into the viewer bun
if (/@xyflow|react-flow__/.test(html)) fail('React Flow leaked into the viewer bundle');
// The headless metrics table (~120 KB) is for the CLI's card-fit rule; the viewer measures with the browser.
if (/sans500tnum/.test(html)) fail('Geist metrics table leaked into the viewer bundle');
// Brand marks travel as one data block for deliver to trim; a path left in the code would ship to every file.
const brands = html.match(/<script type="application\/json" id="stackmap-brands">([\s\S]*?)<\/script>/g) ?? [];
if (brands.length !== 1) fail(`expected one stackmap-brands block, found ${brands.length}`);
if (html.split('M12 0C5.445 0 .103').length !== 2) fail('brand paths leaked into the viewer code');
// Samples and gallery fixtures are dev-server pages; the template renders only embedded data.
if (/commerce-api-1|Boundary-text|Grouped tiers/.test(html)) fail('dev samples or gallery fixtures leaked into the template');
if (!html.includes('<script type="application/json" id="stackmap-data"></script>')) fail('template lacks the empty stackmap-data block');
Expand Down
11 changes: 11 additions & 0 deletions packages/viewer/src/canvas/viewport.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,17 @@ describe('viewport math', () => {
expect(t).toEqual({ x: 20, y: 20, k: 2 });
});

it('a diagram too big for the smallest zoom starts where it is read from, not mid-way', () => {
const inset = { top: 80, bottom: 76 };
const tall = fitTransform({ x: 10, y: 20, width: 600, height: 20000 }, { width: 800, height: 600 }, { inset });
expect(tall.k).toBe(MIN_ZOOM);
expect(tall.y + 20 * MIN_ZOOM).toBeCloseTo(80, 6);
expect(tall.x).toBeCloseTo((800 - 600 * MIN_ZOOM) / 2 - 10 * MIN_ZOOM, 6);
const wide = fitTransform({ x: 0, y: 0, width: 30000, height: 400 }, { width: 800, height: 600 }, { inset });
expect(wide.x).toBeCloseTo(24, 6);
expect(wide.y + (400 * MIN_ZOOM) / 2).toBeCloseTo((80 + 600 - 76) / 2, 6);
});

it('returns identity for a zero-size stage or empty content', () => {
expect(fitTransform({ x: 0, y: 0, width: 500, height: 300 }, { width: 0, height: 0 })).toEqual({ x: 0, y: 0, k: 1 });
expect(fitTransform({ x: 0, y: 0, width: 0, height: 0 }, { width: 800, height: 600 })).toEqual({ x: 0, y: 0, k: 1 });
Expand Down
10 changes: 5 additions & 5 deletions packages/viewer/src/canvas/viewport.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ export const MIN_ZOOM = 0.2;
export const MAX_ZOOM = 2;
export const ZOOM_STEP = 1.2;
export const FIT_PADDING = 0.15;
const OVERFLOW_GUTTER = 24;

export const clampZoom = (k: number) => Math.min(MAX_ZOOM, Math.max(MIN_ZOOM, k));

Expand Down Expand Up @@ -51,11 +52,10 @@ export function fitTransform(
);
const top = Math.max(inset.top, half * h * k);
const bottom = Math.max(inset.bottom, half * h * k);
return {
x: (W - w * k) / 2 - content.x * k,
y: top + (H - top - bottom - h * k) / 2 - content.y * k,
k,
};
// Still too big at the zoom floor: centring would open mid-way, so start where the diagram is read from.
const x = w * k > W ? OVERFLOW_GUTTER : (W - w * k) / 2;
const y = h * k > H - inset.top - inset.bottom ? inset.top : top + (H - top - bottom - h * k) / 2;
return { x: x - content.x * k, y: y - content.y * k, k };
}

/** The part of the diagram currently visible on stage, in diagram coordinates. */
Expand Down
5 changes: 3 additions & 2 deletions packages/viewer/src/chrome/ExportMenu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -137,10 +137,11 @@ export function ExportMenu() {
setOpen(true);
}
}}
className="sm-press flex h-9 items-center gap-2 rounded-full bg-primary px-4 text-[14px] font-medium text-primary-fg disabled:opacity-60"
className="sm-press flex h-9 items-center gap-2 rounded-full bg-primary px-4 text-[14px] font-medium text-primary-fg disabled:opacity-60 @max-md:px-2.5"
>
<Download size={16} strokeWidth={2} aria-hidden="true" />
{status.kind === 'busy' ? (status.text ?? 'Exporting…') : 'Export'}
{/* Phone width: the icon alone keeps the toolbar to one row. */}
<span className="@max-md:sr-only">{status.kind === 'busy' ? (status.text ?? 'Exporting…') : 'Export'}</span>
</button>
{open && (
<div
Expand Down
8 changes: 4 additions & 4 deletions packages/viewer/src/chrome/IdentityCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,10 @@ import { Ellipsis, Workflow } from 'lucide-react';
import type { DiagramDraft } from '@stackmap/core';
import { IconButton, PANEL_CLASS, PANEL_STYLE } from './ui';

export function IdentityCard({ draft }: { draft: DiagramDraft }) {
export function IdentityCard({ draft, onDetails }: { draft: DiagramDraft; onDetails?: () => void }) {
return (
<div className={`${PANEL_CLASS} flex items-center gap-3 py-2 pr-2 pl-3`} style={PANEL_STYLE}>
<div className="grid size-9 place-items-center rounded-xl bg-page text-fg-muted">
<div className={`${PANEL_CLASS} flex min-w-0 items-center gap-3 py-2 pr-2 pl-3`} style={PANEL_STYLE}>
<div className="grid size-9 shrink-0 place-items-center rounded-xl bg-page text-fg-muted">
<Workflow size={17} strokeWidth={1.75} aria-hidden="true" />
</div>
<div className="min-w-0 pr-4">
Expand All @@ -18,7 +18,7 @@ export function IdentityCard({ draft }: { draft: DiagramDraft }) {
</div>
)}
</div>
<IconButton label="Diagram menu">
<IconButton label="Diagram details" onClick={onDetails}>
<Ellipsis size={17} strokeWidth={1.75} />
</IconButton>
</div>
Expand Down
Loading
Loading