diff --git a/packages/cli/src/embed.ts b/packages/cli/src/embed.ts index 3fcd553..5117838 100644 --- a/packages/cli/src/embed.ts +++ b/packages/cli/src/embed.ts @@ -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 = ''; +/** 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 = `', from); + const all = JSON.parse(html.slice(from, end)) as Record; + 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); } diff --git a/packages/cli/src/io.ts b/packages/cli/src/io.ts index b459d63..80d8fe2 100644 --- a/packages/cli/src/io.ts +++ b/packages/cli/src/io.ts @@ -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 {} @@ -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) { diff --git a/packages/cli/test/commands.test.ts b/packages/cli/test/commands.test.ts index 02695f4..f170b7a 100644 --- a/packages/cli/test/commands.test.ts +++ b/packages/cli/test/commands.test.ts @@ -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'); diff --git a/packages/cli/test/embed.test.ts b/packages/cli/test/embed.test.ts index 7112804..635d9fd 100644 --- a/packages/cli/test/embed.test.ts +++ b/packages/cli/test/embed.test.ts @@ -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 = `stackmap
${EMPTY_DATA_BLOCK}`; const laidOut = (title: string): LaidOutDiagram => ({ draft: { ...commerceApi, title }, nodes: {}, groups: {}, edges: {}, bounds: { width: 0, height: 0 } }); @@ -38,4 +38,14 @@ describe('embedDiagram', () => { expect(() => embedDiagram(template + EMPTY_DATA_BLOCK, laidOut('T'))).toThrow(/template/); expect(() => embedDiagram(`${EMPTY_DATA_BLOCK}`, laidOut('T'))).toThrow(//); }); + + 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')); + }); }); diff --git a/packages/core/src/text-measure.ts b/packages/core/src/text-measure.ts index 3eef2c1..4a77ea0 100644 --- a/packages/core/src/text-measure.ts +++ b/packages/core/src/text-measure.ts @@ -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. */ @@ -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; diff --git a/packages/core/test/text-measure.test.ts b/packages/core/test/text-measure.test.ts index c46d3fa..6ccffd0 100644 --- a/packages/core/test/text-measure.test.ts +++ b/packages/core/test/text-measure.test.ts @@ -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([ diff --git a/packages/schema/src/rules/semantics.ts b/packages/schema/src/rules/semantics.ts index aec7b12..53154f1 100644 --- a/packages/schema/src/rules/semantics.ts +++ b/packages/schema/src/rules/semantics.ts @@ -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'), @@ -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) => { @@ -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'; diff --git a/packages/schema/test/validate.test.ts b/packages/schema/test/validate.test.ts index 63a44b3..51f8809 100644 --- a/packages/schema/test/validate.test.ts +++ b/packages/schema/test/validate.test.ts @@ -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' }, diff --git a/packages/viewer/e2e/explore.spec.ts b/packages/viewer/e2e/explore.spec.ts index 2dc75e0..4042c9c 100644 --- a/packages/viewer/e2e/explore.spec.ts +++ b/packages/viewer/e2e/explore.spec.ts @@ -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(); diff --git a/packages/viewer/e2e/metrics.spec.ts b/packages/viewer/e2e/metrics.spec.ts index c485244..2476127 100644 --- a/packages/viewer/e2e/metrics.spec.ts +++ b/packages/viewer/e2e/metrics.spec.ts @@ -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"; diff --git a/packages/viewer/scripts/check-single-file.ts b/packages/viewer/scripts/check-single-file.ts index 8aeadad..1bcb37f 100644 --- a/packages/viewer/scripts/check-single-file.ts +++ b/packages/viewer/scripts/check-single-file.ts @@ -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 => { @@ -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'); diff --git a/packages/viewer/src/canvas/viewport.test.ts b/packages/viewer/src/canvas/viewport.test.ts index 3907e50..165e834 100644 --- a/packages/viewer/src/canvas/viewport.test.ts +++ b/packages/viewer/src/canvas/viewport.test.ts @@ -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 }); diff --git a/packages/viewer/src/canvas/viewport.ts b/packages/viewer/src/canvas/viewport.ts index 9208e3a..ab0c8c7 100644 --- a/packages/viewer/src/canvas/viewport.ts +++ b/packages/viewer/src/canvas/viewport.ts @@ -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)); @@ -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. */ diff --git a/packages/viewer/src/chrome/ExportMenu.tsx b/packages/viewer/src/chrome/ExportMenu.tsx index 072d35a..da1ebb6 100644 --- a/packages/viewer/src/chrome/ExportMenu.tsx +++ b/packages/viewer/src/chrome/ExportMenu.tsx @@ -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 diff --git a/packages/viewer/src/chrome/IdentityCard.tsx b/packages/viewer/src/chrome/IdentityCard.tsx index 3ce533c..0ac4298 100644 --- a/packages/viewer/src/chrome/IdentityCard.tsx +++ b/packages/viewer/src/chrome/IdentityCard.tsx @@ -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"> @@ -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> diff --git a/packages/viewer/src/chrome/Inspector.tsx b/packages/viewer/src/chrome/Inspector.tsx index ec5d07e..cab329a 100644 --- a/packages/viewer/src/chrome/Inspector.tsx +++ b/packages/viewer/src/chrome/Inspector.tsx @@ -300,6 +300,11 @@ function RouteDetail({ toggle }: { toggle: ReactNode }) { ); } +// Narrow, a 300px column would leave the canvas a sliver: the inspector floats over the stage instead, +// its toggle where the stage's bottom-right panel would sit and its body as a sheet along the bottom. +const NARROW_COLLAPSED = '@max-2xl:absolute @max-2xl:right-[27px] @max-2xl:bottom-[27px] @max-2xl:z-20'; +const NARROW_OPEN = '@max-2xl:absolute @max-2xl:inset-x-5 @max-2xl:bottom-5 @max-2xl:z-20 @max-2xl:max-h-[min(60%,480px)] @max-2xl:w-auto'; + function Toggle({ collapsed, onToggle, ref }: { collapsed: boolean; onToggle: () => void; ref: Ref<HTMLButtonElement> }) { return ( <IconButton ref={ref} label={collapsed ? 'Show inspector' : 'Hide inspector'} expanded={!collapsed} onClick={onToggle}> @@ -339,13 +344,13 @@ export function Inspector({ collapsed, onToggle }: { collapsed: boolean; onToggl ); if (collapsed) { return ( - <aside aria-label="Inspector" className="shrink-0 rounded-[20px] bg-panel p-1.5" style={PANEL_STYLE}> + <aside aria-label="Inspector" className={`shrink-0 rounded-[20px] bg-panel p-1.5 ${NARROW_COLLAPSED}`} style={PANEL_STYLE}> {toggle} </aside> ); } return ( - <aside aria-label="Inspector" className="relative w-[300px] shrink-0 overflow-y-auto rounded-[20px] bg-panel p-5" style={PANEL_STYLE}> + <aside aria-label="Inspector" className={`relative w-[300px] shrink-0 overflow-y-auto rounded-[20px] bg-panel p-5 ${NARROW_OPEN}`} style={PANEL_STYLE}> <div ref={body}> {state.route || state.routing ? ( <RouteDetail toggle={toggle} /> diff --git a/packages/viewer/src/chrome/Toolbar.tsx b/packages/viewer/src/chrome/Toolbar.tsx index 779d03e..b40e9af 100644 --- a/packages/viewer/src/chrome/Toolbar.tsx +++ b/packages/viewer/src/chrome/Toolbar.tsx @@ -76,7 +76,7 @@ export function Toolbar({ theme, onToggleTheme, onPresent }: { theme: ThemeChoic return ( <div ref={root} - className={`${PANEL_CLASS} relative flex items-center gap-0.5 p-1.5 @max-md:flex-wrap`} + className={`${PANEL_CLASS} relative flex shrink-0 items-center gap-0.5 p-1.5 @max-md:shrink @max-md:flex-wrap`} style={PANEL_STYLE} onKeyDown={(e) => { if (e.key !== 'Escape' || !lensOpen) return; diff --git a/packages/viewer/src/chrome/ViewerShell.test.tsx b/packages/viewer/src/chrome/ViewerShell.test.tsx index a98f8d5..d2e78a1 100644 --- a/packages/viewer/src/chrome/ViewerShell.test.tsx +++ b/packages/viewer/src/chrome/ViewerShell.test.tsx @@ -59,6 +59,16 @@ describe('ViewerShell', () => { expect(screen.getByText('100%')).toBeInTheDocument(); // zero-size stage in jsdom → identity transform }); + it('the identity card’s details button opens the inspector on the diagram, leaving any selection', () => { + innerWidth = 900; + render(<ViewerShell diagram={commerceApiLayout} theme="light" onToggleTheme={() => {}} />); + fireEvent.click(screen.getByRole('button', { name: 'Orders, Database' })); + fireEvent.click(screen.getByRole('button', { name: 'Diagram details' })); + const inspector = screen.getByRole('complementary', { name: 'Inspector' }); + expect(within(inspector).getByRole('heading', { level: 2, name: 'Commerce API' })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Orders, Database' })).toHaveAttribute('aria-pressed', 'false'); + }); + it('has no editor controls', () => { render(<ViewerShell diagram={commerceApiLayout} theme="light" onToggleTheme={() => {}} />); for (const name of [/add/i, /undo/i, /redo/i, /lock/i]) expect(screen.queryByRole('button', { name })).toBeNull(); diff --git a/packages/viewer/src/chrome/ViewerShell.tsx b/packages/viewer/src/chrome/ViewerShell.tsx index 0c1eed2..a7e302f 100644 --- a/packages/viewer/src/chrome/ViewerShell.tsx +++ b/packages/viewer/src/chrome/ViewerShell.tsx @@ -78,6 +78,20 @@ function ViewTabs() { ); } +/** The identity card's details button shows the diagram itself in the inspector, whatever was selected. */ +function DiagramIdentity({ onDetails }: { onDetails: () => void }) { + const { draft, dispatch } = useExplore(); + return ( + <IdentityCard + draft={draft} + onDetails={() => { + dispatch({ type: 'clear' }); + onDetails(); + }} + /> + ); +} + function ViewCaption() { const { draft, state } = useExplore(); const caption = draft.views?.find((v) => v.id === state.view)?.caption; @@ -143,7 +157,7 @@ export function ViewerShell({ <ExploreProvider draft={draft}> <div ref={shell} data-presenting={presenting || undefined} className="@container flex h-full flex-col bg-page font-sans text-fg"> {!presenting && ( - <header className="mx-8 mt-4 flex gap-8 border-b border-divider @max-md:mx-4 @max-md:mt-2 @max-md:flex-wrap @max-md:gap-x-4 @max-md:gap-y-0"> + <header className="mx-8 mt-4 flex gap-8 border-b border-divider @max-2xl:mx-4 @max-2xl:mt-2 @max-md:flex-wrap @max-md:gap-x-4 @max-md:gap-y-0"> <div className="flex max-w-[45%] min-w-0 items-center gap-3 py-3 @max-md:max-w-full @max-md:basis-full @max-md:pb-1"> <Title className="truncate text-[20px] leading-7 font-semibold tracking-tight">{draft.title} )} {!presenting && } -
+
- + {/* Never wider than the stage: the identity card gives way (truncating, then hidden) before the toolbar. */} + {/* Narrow, the header already names the diagram: the toolbar gets the room. */} -
- +
+ setInspectorCollapsed(false)} />
present(true)} /> diff --git a/packages/viewer/src/icons/brands.runtime.ts b/packages/viewer/src/icons/brands.runtime.ts new file mode 100644 index 0000000..66d49e2 --- /dev/null +++ b/packages/viewer/src/icons/brands.runtime.ts @@ -0,0 +1,6 @@ +// The built template's stand-in for brands.gen (see vite.config.ts): the marks travel as a data block, +// which `stackmap deliver` trims to the brands the diagram uses. +export const BRANDS_ELEMENT_ID = 'stackmap-brands'; + +const text = typeof document === 'undefined' ? null : document.getElementById(BRANDS_ELEMENT_ID)?.textContent; +export const BRANDS: Record = text ? JSON.parse(text) : {}; diff --git a/packages/viewer/vite.config.ts b/packages/viewer/vite.config.ts index b985446..b149ffc 100644 --- a/packages/viewer/vite.config.ts +++ b/packages/viewer/vite.config.ts @@ -1,10 +1,26 @@ +import { fileURLToPath } from 'node:url'; import react from '@vitejs/plugin-react'; import tailwindcss from '@tailwindcss/vite'; import { viteSingleFile } from 'vite-plugin-singlefile'; -import { defineConfig } from 'vitest/config'; +import { defineConfig, type Plugin } from 'vitest/config'; +import { BRANDS } from './src/icons/brands.gen'; +import { BRANDS_ELEMENT_ID } from './src/icons/brands.runtime'; + +// The built template carries the brand marks as JSON rather than code, so deliver can drop the ~170 KB a +// diagram doesn't draw. The dev server, tests and the website keep importing brands.gen directly. +const brandsAsData = (): Plugin => ({ + name: 'stackmap-brands-as-data', + apply: 'build', + enforce: 'pre', + resolveId(source, importer) { + if (source === './brands.gen' && importer?.endsWith('/icons/BrandIcon.tsx')) return fileURLToPath(new URL('./src/icons/brands.runtime.ts', import.meta.url)); + }, + transformIndexHtml: (html) => + html.replace('', `\n `), +}); export default defineConfig({ - plugins: [react(), tailwindcss(), viteSingleFile()], + plugins: [brandsAsData(), react(), tailwindcss(), viteSingleFile()], test: { environment: 'jsdom', include: ['src/**/*.test.{ts,tsx}'], diff --git a/skill/references/authoring-contract.md b/skill/references/authoring-contract.md index c825d96..d6b278b 100644 --- a/skill/references/authoring-contract.md +++ b/skill/references/authoring-contract.md @@ -14,7 +14,7 @@ How to model a system as a stackmap diagram. The [schema reference](schema.md) s - `kind: "async"` for queues, events, fire-and-forget and callbacks — it renders dashed. Don't use it for "happens at build time" or "optional"; say that in a label or in the node's subtitle. - `kind: "return"` for a reply, a roll back or a retry that loops back to an earlier step — it renders dotted, and layout never lets it push its target to a later column. - **`tone`** marks the few edges the reader must tell apart: `main` for the happy path (drawn in ink), `security` for a trust crossing or a policy check, `error` for a failure path. Leave the rest untoned; a diagram where everything has a tone has none. -- **Edge labels** (≤ 24 chars) only where the relationship isn't obvious from the two ends: a protocol (`gRPC`), a verb (`enqueue`), a topic. Most edges need none — except in sequences, where every message is labelled. +- **Edge labels** (≤ 24 chars) only where the relationship isn't obvious from the two ends: a protocol (`gRPC`), a verb (`enqueue`), a topic. Most edges need none — except in sequences, where every message is labelled. In an architecture or dataflow, keep **one edge per direction** between two nodes: a second `a → b` edge draws on the first, so fold both relationships into one label (`reads, writes`). - **Direction.** `RIGHT` (default) reads as a request path and suits 2–4 stages. Prefer `DOWN` for tiered or grouped systems (edge → app → data) and for anything with more than ~5 stages; it avoids long wrap-around edges. - **Groups** are boundaries a reader should see: tiers, trust zones, VPCs, clusters, teams. Nest with `parent`. A group with one node is usually noise. - **Stages** (`phases` with `nodes`) in `architecture` and `dataflow` are an ordered pipeline (sources → ingest → process → store → consume): each stage is drawn as a band, in flow order. Stage members must be ungrouped; use stages *or* groups for a node, not both. @@ -23,7 +23,7 @@ How to model a system as a stackmap diagram. The [schema reference](schema.md) s ## Size -A diagram is read on one screen. Aim for **~10–40 nodes**; past that, cards get too small at fit and the picture stops answering a question. For a larger system, deliver an **overview** diagram (subsystems as single nodes) plus **one diagram per subsystem** in sibling folders (`.stackmap/-overview/`, `.stackmap/-payments/`, …). Views don't reduce size — every node is still drawn. The schema's hard caps (500 nodes, 2000 edges) are a safety limit, not a target; if you hit one, split rather than delete. +A diagram is read on one screen. Aim for **~10–40 nodes**; past that, cards get too small at fit and the picture stops answering a question. For a larger system, deliver an **overview** diagram (subsystems as single nodes) plus **one diagram per subsystem** in sibling folders (`.stackmap/-overview/`, `.stackmap/-payments/`, …). Views don't reduce size — every node is still drawn. `validate` warns past 60 nodes. The schema's hard caps (500 nodes, 2000 edges) are a safety limit, not a target; if you hit one, split rather than delete. ## From Mermaid