diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
new file mode 100644
index 0000000..f967033
--- /dev/null
+++ b/.github/workflows/ci.yml
@@ -0,0 +1,72 @@
+name: CI
+
+on:
+ push:
+ branches: [main]
+ pull_request:
+ workflow_dispatch:
+ inputs:
+ update-visual:
+ description: Regenerate the visual baselines and upload them as an artifact
+ type: boolean
+ default: false
+
+concurrency:
+ group: ci-${{ github.ref }}
+ cancel-in-progress: true
+
+jobs:
+ check:
+ name: test · typecheck · build
+ runs-on: ubuntu-latest
+ timeout-minutes: 15
+ steps:
+ - uses: actions/checkout@v4
+ - uses: oven-sh/setup-bun@v2
+ with:
+ bun-version: 1.3.14
+ - uses: actions/setup-node@v4
+ with:
+ node-version: 24
+ - run: bun install --frozen-lockfile
+ - run: bun run test
+ - run: bun run typecheck
+ - run: bun run build
+ - name: CLI smoke (deliver is deterministic)
+ run: node packages/cli/scripts/smoke.mjs
+
+ e2e:
+ name: e2e · visual
+ runs-on: ubuntu-latest
+ timeout-minutes: 20
+ # The same image the visual baselines were generated in, so fonts rasterise identically.
+ container: mcr.microsoft.com/playwright:v1.63.0-noble
+ env:
+ HOME: /root
+ steps:
+ - uses: actions/checkout@v4
+ - run: npm install -g bun@1.3.14
+ - run: bun install --frozen-lockfile
+ - run: bun run build
+ - name: Viewer e2e and visual regression
+ working-directory: packages/viewer
+ run: bunx playwright test ${{ inputs.update-visual && '--update-snapshots=all' || '' }}
+ - name: Delivered-file and live-serve e2e
+ if: success() || failure()
+ working-directory: packages/cli
+ run: bunx playwright test
+ # Always: a missing baseline is written by Playwright, and this is how it gets back into the repo.
+ - name: Upload visual snapshots
+ if: always()
+ uses: actions/upload-artifact@v4
+ with:
+ name: visual-baselines
+ path: packages/viewer/e2e/visual.spec.ts-snapshots/
+ - name: Upload Playwright results on failure
+ if: failure()
+ uses: actions/upload-artifact@v4
+ with:
+ name: playwright-results
+ path: |
+ packages/viewer/test-results/
+ packages/cli/test-results/
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..b0ae2df
--- /dev/null
+++ b/README.md
@@ -0,0 +1,97 @@
+# stackmap
+
+**Architecture and data-flow diagrams that your coding agent writes, delivered as one offline HTML file.**
+
+You ask your agent to diagram a codebase or a system. It writes a small typed JSON, `stackmap` validates it with repair hints the agent acts on, lays it out, and hands you a single self-contained viewer — pan and zoom, search, trace upstream and downstream, guided views, dark and light themes, PNG/SVG export. No server, no account, nothing to install to open it.
+
+
+
+
+
+
+## Quick start
+
+Install the skill into your agent (Claude Code, Cursor, Codex and [others](https://github.com/vercel-labs/skills)):
+
+```bash
+npx skills add hyamero/stackmap
+```
+
+Then ask:
+
+> Make an architecture diagram of this repository, backed by evidence from the code.
+
+The agent writes `.stackmap//diagram.json`, validates and repairs it, and delivers `.stackmap//diagram.html`. Open that file in any browser. To change the diagram, just ask again — the agent edits the JSON and re-delivers; the viewer itself is read-only.
+
+## The CLI
+
+The skill drives the CLI for you, but it works on its own too (Node ≥ 22.12):
+
+```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
+```
+
+- **validate** reports every problem as `{ code, severity, subject, message, evidence, allowedFixes }` — schema errors, dangling references, group cycles, duplicate ids, orphans, and card text that wouldn't fit (measured with the real font). Exit codes: `0` ok, `1` the diagram has errors, `2` usage/IO/internal.
+- **deliver** writes atomically and prints `delivered · sha256 · bytes`. The same input always gives byte-identical output.
+- **serve** binds `127.0.0.1` only, keeps the last good version on screen when a save is invalid (the errors appear in a toast), and preserves the camera, selection and theme across reloads.
+
+## The diagram
+
+```json
+{
+ "$schema": "https://unpkg.com/@hyamero/stackmap@0.1.0/dist/stackmap.schema.json",
+ "kind": "architecture",
+ "title": "Bookshop",
+ "direction": "DOWN",
+ "groups": [{ "id": "data", "label": "Data tier" }],
+ "nodes": [
+ { "id": "api", "type": "service", "card": { "title": "shop-api", "subtitle": "REST API" },
+ "evidence": [{ "file": "services/api/src/server.ts", "line": 12 }] },
+ { "id": "db", "type": "database", "group": "data", "card": { "title": "Orders DB", "subtitle": "PostgreSQL", "brand": "postgresql" } }
+ ],
+ "edges": [{ "id": "api-db", "from": "api", "to": "db" }],
+ "views": [{ "id": "state", "label": "State", "nodes": ["db"] }]
+}
+```
+
+Nine node types set the colour (`client`, `gateway`, `service`, `database`, `cache`, `queue`, `storage`, `external`, `security`); cards can carry rows, stats, a footer and a link; `brand` adds one of 146 [Simple Icons](https://simpleicons.org) logos. There are no coordinates — [ELK](https://eclipse.dev/elk/) lays everything out. Full reference: [skill/references/schema.md](skill/references/schema.md); modelling guidance: [authoring contract](skill/references/authoring-contract.md).
+
+## The viewer
+
+- **Explore:** click a card for its details, connections and source evidence (linked to your repo when `source.url` is set). **Trace** keeps a node's upstream and downstream and dims the rest.
+- **Find:** `/` searches titles, subtitles, ids and types; the **lens** dims node types you don't care about.
+- **Guided views:** tabs the agent defines (“Checkout path”, “Data tier”) that dim everything else and fit the camera.
+- **Share:** state lives in the URL hash (`#view=…&node=…&lens=…`); export PNG (1×/2×), copy PNG, or an SVG snapshot.
+- **Keyboard:** Tab reaches the toolbar, then the canvas; arrows move between cards, Enter selects, Esc clears; with the canvas focused, arrows pan and `+`/`-`/`0` zoom. Honours reduced motion.
+
+## Development
+
+Bun workspaces monorepo; tests run on Node via Vitest and Playwright.
+
+```bash
+bun install
+bun run test && bun run typecheck && bun run build # unit tests, types, viewer template + CLI bundle
+cd packages/viewer && bunx playwright test # viewer e2e (dev server)
+cd packages/cli && bunx playwright test # delivered-file + live-serve e2e
+```
+
+| Package | What it does |
+|---|---|
+| `packages/core` | types, theme tokens, card metrics, headless text measurement (generated Geist metrics), samples |
+| `packages/schema` | Zod schema → `stackmap.schema.json`, the validator and its diagnostics |
+| `packages/layout` | ELK wrapper: diagram → absolute node/group/edge geometry |
+| `packages/viewer` | React viewer, built into one self-contained HTML template |
+| `packages/cli` | `@hyamero/stackmap`: validate · deliver · serve |
+| `skill/` | the agent skill that `npx skills add` installs |
+
+Visual regression baselines are Linux-only and are checked in CI. Regenerate them with the CI workflow's *update-visual* run (it uploads them as an artifact), or locally with `bun run --filter @stackmap/viewer visual:update` (Playwright's Linux image in Docker). `bun run --filter @stackmap/viewer metrics:gen` regenerates the font metrics after a font change.
+
+## Credits
+
+stackmap is a successor to [archify](https://github.com/tt-a1i/archify) by tt-a1i, itself based on [Cocoon AI's architecture-diagram-generator](https://github.com/Cocoon-AI/architecture-diagram-generator) — it keeps archify's agent → JSON → validate → single-HTML model and repair-loop contract, with a new renderer and visual language. Layout by [Eclipse ELK](https://eclipse.dev/elk/) (elkjs, EPL-2.0), fonts [Geist](https://vercel.com/font) (OFL-1.1), logos [Simple Icons](https://simpleicons.org) (CC0).
+
+## License
+
+[MIT](LICENSE). Third-party components and their licenses: [THIRD_PARTY_NOTICES.md](THIRD_PARTY_NOTICES.md).
diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md
new file mode 100644
index 0000000..49fc941
--- /dev/null
+++ b/THIRD_PARTY_NOTICES.md
@@ -0,0 +1,147 @@
+# Third-party notices
+
+stackmap is MIT-licensed (see [LICENSE](LICENSE)). It builds on the work below.
+
+## Shipped inside the viewer (every delivered HTML file, and `dist/viewer.html` in the npm package)
+
+| Component | Version | License | Copyright |
+|---|---|---|---|
+| React, React DOM, scheduler | 19.3.0 / 0.28.0 | MIT | Meta Platforms, Inc. and affiliates |
+| d3-zoom, d3-selection, d3-transition, d3-dispatch, d3-drag, d3-interpolate, d3-color, d3-timer | 3.x | ISC | 2010–2022 Mike Bostock |
+| d3-ease | 3.0.1 | BSD-3-Clause | 2010–2021 Mike Bostock; 2001 Robert Penner |
+| lucide-react | 1.48.0 | ISC | 2026 Lucide Icons and Contributors |
+| html-to-image | 1.11.13 | MIT | 2017–2025 W.Y. |
+| Simple Icons (146 marks) | 16.x | CC0-1.0 | public domain dedication; marks remain trademarks of their owners |
+| Geist, Geist Mono (Latin subset, via @fontsource-variable) | 5.3.0 | SIL OFL 1.1 | 2024 The Geist Project Authors — full license below |
+
+## Shipped inside the CLI bundle (`dist/cli.js`)
+
+| Component | Version | License | Copyright |
+|---|---|---|---|
+| zod | 4.6.5 | MIT | 2025 Colin McDonnell |
+
+## Runtime dependency (installed by npm, not bundled)
+
+| Component | Version | License |
+|---|---|---|
+| elkjs (Eclipse Layout Kernel) | 0.12.x | EPL-2.0 OR GPL-3.0-or-later — used unmodified as a separate package; source at https://github.com/kieler/elkjs |
+
+## Adapted documentation
+
+Parts of `skill/references/authoring-contract.md` are adapted from [archify](https://github.com/tt-a1i/archify), MIT, Copyright (c) 2026 tt-a1i (Archify), Copyright (c) 2025 Cocoon AI. The full notice is included in [LICENSE](LICENSE) and [skill/LICENSE](skill/LICENSE).
+
+## MIT license text (React, html-to-image, zod)
+
+Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+
+## ISC license text (d3 modules, lucide)
+
+Permission to use, copy, modify, and/or distribute this software for any purpose with or without fee is hereby granted, provided that the above copyright notice and this permission notice appear in all copies.
+
+THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
+
+## BSD-3-Clause (d3-ease)
+
+Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: (1) redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer; (2) redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution; (3) neither the name of the copyright holder nor the names of its contributors may be used to endorse or promote products derived from this software without specific prior written permission. THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES ARE DISCLAIMED.
+
+## SIL Open Font License 1.1 (Geist, Geist Mono)
+
+```
+Copyright 2024 The Geist Project Authors (https://github.com/vercel/geist-font) Geist-Italic[wght].ttf: Copyright 2024 The Geist Project Authors (https://github.com/vercel/geist-font)
+
+This Font Software is licensed under the SIL Open Font License, Version 1.1.
+This license is copied below, and is also available with a FAQ at:
+http://scripts.sil.org/OFL
+
+
+-----------------------------------------------------------
+SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
+-----------------------------------------------------------
+
+PREAMBLE
+The goals of the Open Font License (OFL) are to stimulate worldwide
+development of collaborative font projects, to support the font creation
+efforts of academic and linguistic communities, and to provide a free and
+open framework in which fonts may be shared and improved in partnership
+with others.
+
+The OFL allows the licensed fonts to be used, studied, modified and
+redistributed freely as long as they are not sold by themselves. The
+fonts, including any derivative works, can be bundled, embedded,
+redistributed and/or sold with any software provided that any reserved
+names are not used by derivative works. The fonts and derivatives,
+however, cannot be released under any other type of license. The
+requirement for fonts to remain under this license does not apply
+to any document created using the fonts or their derivatives.
+
+DEFINITIONS
+"Font Software" refers to the set of files released by the Copyright
+Holder(s) under this license and clearly marked as such. This may
+include source files, build scripts and documentation.
+
+"Reserved Font Name" refers to any names specified as such after the
+copyright statement(s).
+
+"Original Version" refers to the collection of Font Software components as
+distributed by the Copyright Holder(s).
+
+"Modified Version" refers to any derivative made by adding to, deleting,
+or substituting -- in part or in whole -- any of the components of the
+Original Version, by changing formats or by porting the Font Software to a
+new environment.
+
+"Author" refers to any designer, engineer, programmer, technical
+writer or other person who contributed to the Font Software.
+
+PERMISSION & CONDITIONS
+Permission is hereby granted, free of charge, to any person obtaining
+a copy of the Font Software, to use, study, copy, merge, embed, modify,
+redistribute, and sell modified and unmodified copies of the Font
+Software, subject to the following conditions:
+
+1) Neither the Font Software nor any of its individual components,
+in Original or Modified Versions, may be sold by itself.
+
+2) Original or Modified Versions of the Font Software may be bundled,
+redistributed and/or sold with any software, provided that each copy
+contains the above copyright notice and this license. These can be
+included either as stand-alone text files, human-readable headers or
+in the appropriate machine-readable metadata fields within text or
+binary files as long as those fields can be easily viewed by the user.
+
+3) No Modified Version of the Font Software may use the Reserved Font
+Name(s) unless explicit written permission is granted by the corresponding
+Copyright Holder. This restriction only applies to the primary font name as
+presented to the users.
+
+4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
+Software shall not be used to promote, endorse or advertise any
+Modified Version, except to acknowledge the contribution(s) of the
+Copyright Holder(s) and the Author(s) or with their explicit written
+permission.
+
+5) The Font Software, modified or unmodified, in part or in whole,
+must be distributed entirely under this license, and must not be
+distributed under any other license. The requirement for fonts to
+remain under this license does not apply to any document created
+using the Font Software.
+
+TERMINATION
+This license becomes null and void if any of the above conditions are
+not met.
+
+DISCLAIMER
+THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
+MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
+OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
+COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
+INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
+DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
+OTHER DEALINGS IN THE FONT SOFTWARE.
+```
diff --git a/assets/readme/viewer-dark.png b/assets/readme/viewer-dark.png
new file mode 100644
index 0000000..99a8569
Binary files /dev/null and b/assets/readme/viewer-dark.png differ
diff --git a/assets/readme/viewer-light.png b/assets/readme/viewer-light.png
new file mode 100644
index 0000000..f167032
Binary files /dev/null and b/assets/readme/viewer-light.png differ
diff --git a/packages/cli/.gitignore b/packages/cli/.gitignore
new file mode 100644
index 0000000..d6376af
--- /dev/null
+++ b/packages/cli/.gitignore
@@ -0,0 +1,3 @@
+README.md
+LICENSE
+THIRD_PARTY_NOTICES.md
diff --git a/packages/cli/package.json b/packages/cli/package.json
index 49c053b..a1fbb1d 100644
--- a/packages/cli/package.json
+++ b/packages/cli/package.json
@@ -2,23 +2,48 @@
"name": "@hyamero/stackmap",
"version": "0.1.0",
"description": "Validate agent-authored architecture diagrams and deliver them as one offline HTML viewer",
+ "keywords": [
+ "architecture",
+ "diagram",
+ "agent",
+ "skill",
+ "claude",
+ "elk",
+ "offline",
+ "html",
+ "dataflow"
+ ],
+ "homepage": "https://github.com/hyamero/stackmap#readme",
+ "repository": {
+ "type": "git",
+ "url": "git+https://github.com/hyamero/stackmap.git",
+ "directory": "packages/cli"
+ },
+ "bugs": "https://github.com/hyamero/stackmap/issues",
"license": "MIT",
"type": "module",
"bin": {
- "stackmap": "./dist/cli.js"
+ "stackmap": "dist/cli.js"
},
"files": [
- "dist"
+ "dist",
+ "README.md",
+ "LICENSE",
+ "THIRD_PARTY_NOTICES.md"
],
"engines": {
"node": "^22.12.0 || >=24.0.0"
},
+ "publishConfig": {
+ "access": "public"
+ },
"scripts": {
"build": "tsup && node scripts/copy-template.mjs",
"test": "vitest run",
"typecheck": "tsc --noEmit",
"smoke": "bun run --filter @stackmap/viewer build && bun run build && node scripts/smoke.mjs",
- "e2e": "bun run --filter @stackmap/viewer build && bun run build && playwright test"
+ "e2e": "bun run --filter @stackmap/viewer build && bun run build && playwright test",
+ "prepack": "bun run build && node scripts/prepack.mjs"
},
"dependencies": {
"elkjs": "^0.12.0"
diff --git a/packages/cli/scripts/prepack.mjs b/packages/cli/scripts/prepack.mjs
new file mode 100644
index 0000000..c829d1f
--- /dev/null
+++ b/packages/cli/scripts/prepack.mjs
@@ -0,0 +1,4 @@
+// The npm tarball carries the repo's README, license and third-party notices (they live at the repo root).
+import { copyFileSync } from 'node:fs';
+
+for (const f of ['README.md', 'LICENSE', 'THIRD_PARTY_NOTICES.md']) copyFileSync(new URL(`../../../${f}`, import.meta.url), new URL(`../${f}`, import.meta.url));
diff --git a/packages/cli/test/skill.test.ts b/packages/cli/test/skill.test.ts
index aaa708a..71b73f7 100644
--- a/packages/cli/test/skill.test.ts
+++ b/packages/cli/test/skill.test.ts
@@ -22,3 +22,10 @@ describe('skill ↔ CLI version', () => {
expect(JSON.parse(skill(`examples/${f}`)).$schema, f).toBe(SCHEMA_ID);
});
});
+
+describe('package.json', () => {
+ it('declares bin paths the way npm publish accepts them (no "./" prefix, or npm drops the bin)', () => {
+ const { bin } = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8')) as { bin: Record };
+ expect(bin).toEqual({ stackmap: 'dist/cli.js' });
+ });
+});
diff --git a/packages/core/src/text-measure.ts b/packages/core/src/text-measure.ts
index 1806af0..449ab5a 100644
--- a/packages/core/src/text-measure.ts
+++ b/packages/core/src/text-measure.ts
@@ -9,19 +9,33 @@ const FALLBACK_EM = 1.1;
const EMOJI_EM = 1.3;
const EMOJI = /[\p{Extended_Pictographic}\p{Regional_Indicator}]/u;
-/** Rendered width in CSS px, headless, with pair kerning. */
+/**
+ * Rendered width in CSS px, headless: the wider of how macOS/Windows Chromium lays the text out
+ * (fractional advances + pair kerning) and how Linux Chromium does (each advance snapped to whole
+ * pixels, no kerning — up to ~6% wider for small text). Card-fit must hold on both.
+ */
export function measureText(text: string, face: FontFace, sizePx: number): number {
+ const { exact, snapped } = textWidths(text, face, sizePx);
+ return Math.max(exact, snapped);
+}
+
+/** Both platform models, for tests and diagnostics: `exact` (kerned, fractional) and Linux's `snapped`. */
+export function textWidths(text: string, face: FontFace, sizePx: number): { exact: number; snapped: number } {
const advances: Record = FONT_METRICS.faces[face];
const kerning: Record = FONT_METRICS.kerning[face];
const em = FONT_METRICS.unitsPerEm;
- let units = 0;
+ let exact = 0;
+ let snapped = 0;
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;
+ const advance = ((advances[cp] ?? (EMOJI.test(ch) ? EMOJI_EM : FALLBACK_EM) * em) * sizePx) / em;
// Kerning pairs are ASCII-only, so the packed key never collides.
- units += advance + (prev < 0 || prev > 0x7e ? 0 : (kerning[prev * 0x10000 + cp] ?? 0));
+ const kern = prev < 0 || prev > 0x7e ? 0 : ((kerning[prev * 0x10000 + cp] ?? 0) * sizePx) / em;
+ exact += advance + kern;
+ snapped += Math.round(advance);
prev = cp;
}
- return (units * sizePx) / em;
+ // Hinting can push a glyph's snapped advance a pixel past rounding (CI saw "OpenShip Edge" at 83 for 82).
+ return { exact, snapped: snapped > 0 ? snapped + 1 : 0 };
}
diff --git a/packages/core/test/text-measure.test.ts b/packages/core/test/text-measure.test.ts
index 894b022..e409bb8 100644
--- a/packages/core/test/text-measure.test.ts
+++ b/packages/core/test/text-measure.test.ts
@@ -1,6 +1,8 @@
import { describe, expect, it } from 'vitest';
import { FONT_METRICS } from '../src/font-metrics.gen';
-import { measureText } from '../src/text-measure';
+import { measureText, textWidths } from '../src/text-measure';
+
+const exact = (t: string, f: Parameters[1], size: number) => textWidths(t, f, size).exact;
const { faces, kerning } = FONT_METRICS;
@@ -10,17 +12,19 @@ describe('measureText', () => {
});
it('scales a glyph advance to the font size', () => {
- expect(measureText('H', 'sans500', 10)).toBeCloseTo((faces.sans500[72]! * 10) / 1000, 6);
+ expect(exact('H', 'sans500', 10)).toBeCloseTo((faces.sans500[72]! * 10) / 1000, 6);
});
it('applies the pair kerning between adjacent glyphs', () => {
const av = kerning.sans400[65 * 0x10000 + 86]!;
expect(av).toBeLessThan(0);
- expect(measureText('AV', 'sans400', 1000)).toBeCloseTo(faces.sans400[65]! + faces.sans400[86]! + av, 6);
+ expect(exact('AV', 'sans400', 1000)).toBeCloseTo(faces.sans400[65]! + faces.sans400[86]! + av, 6);
});
- it('scales linearly with size', () => {
- expect(measureText('orders-svc', 'sans400', 24)).toBeCloseTo(2 * measureText('orders-svc', 'sans400', 12), 6);
+ it('the fractional model scales linearly with size; the Linux one snaps each glyph', () => {
+ expect(exact('orders-svc', 'sans400', 24)).toBeCloseTo(2 * exact('orders-svc', 'sans400', 12), 6);
+ expect(Number.isInteger(textWidths('orders-svc', 'sans400', 11.5).snapped)).toBe(true);
+ expect(measureText('orders-svc', 'sans400', 12)).toBe(Math.max(exact('orders-svc', 'sans400', 12), textWidths('orders-svc', 'sans400', 12).snapped));
});
it('measures mono text at a fixed 0.6em per glyph', () => {
@@ -28,10 +32,27 @@ describe('measureText', () => {
});
it('measures code points outside the table wide: 1.1em, emoji 1.3em, astral ones counted once', () => {
- expect(measureText('数', 'sans400', 10)).toBeCloseTo(11, 6);
- expect(measureText('🚀', 'sans400', 10)).toBeCloseTo(13, 6);
- expect(measureText('✅', 'sans400', 10)).toBeCloseTo(13, 6);
- expect(measureText('🇩🇪', 'sans400', 10)).toBeCloseTo(26, 6);
- expect(measureText('a🚀', 'sans400', 10)).toBeCloseTo(13 + (faces.sans400[97]! * 10) / 1000, 6);
+ expect(exact('数', 'sans400', 10)).toBeCloseTo(11, 6);
+ expect(exact('🚀', 'sans400', 10)).toBeCloseTo(13, 6);
+ expect(exact('✅', 'sans400', 10)).toBeCloseTo(13, 6);
+ expect(exact('🇩🇪', 'sans400', 10)).toBeCloseTo(26, 6);
+ expect(exact('a🚀', 'sans400', 10)).toBeCloseTo(13 + (faces.sans400[97]! * 10) / 1000, 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([
+ ['sans400', 11.5, 'Round robin', 67],
+ ['sans400', 12, 'commerce-api-1', 94],
+ ['sans400', 12, 'PostgreSQL cluster', 112],
+ ['sans400', 12, 'The quick brown fox jumps over the lazy dog', 250],
+ ['sans500', 13.5, 'commerce-api-1', 104],
+ ['sans500tnum', 15, 'The quick brown fox jumps over the lazy dog', 318],
+ ['sans500', 13, 'Primary shards', 92],
+ ['sans400', 11.5, 'OpenShip Edge', 83],
+ ['sans400', 11.5, 'Open cluster', 69],
+ ['sans500', 13, 'OpenShip Edge', 95],
+ ] as const)('is never narrower than Linux rendering: %s %spx "%s"', (face, size, text, linux) => {
+ expect(measureText(text, face, size)).toBeGreaterThanOrEqual(linux - 0.25);
});
});
diff --git a/packages/viewer/e2e/explore.spec.ts b/packages/viewer/e2e/explore.spec.ts
index 31c9483..9abbb75 100644
--- a/packages/viewer/e2e/explore.spec.ts
+++ b/packages/viewer/e2e/explore.spec.ts
@@ -88,6 +88,8 @@ test('"/" opens search; Enter selects the first match and centres it', async ({
const stage = (await page.locator('.sm-stage').boundingBox())!;
const c = (await card(page, 'sessions').boundingBox())!;
expect(Math.abs(c.x + c.width / 2 - (stage.x + stage.width / 2))).toBeLessThan(2);
+ // Vertically centred in the band between the toolbar (80px) and the zoom bar (76px).
+ expect(Math.abs(c.y + c.height / 2 - (stage.y + (stage.height + 80 - 76) / 2))).toBeLessThan(2);
});
test('search tolerates regex characters and whitespace', async ({ page }) => {
diff --git a/packages/viewer/e2e/metrics.spec.ts b/packages/viewer/e2e/metrics.spec.ts
index b2e0293..56166da 100644
--- a/packages/viewer/e2e/metrics.spec.ts
+++ b/packages/viewer/e2e/metrics.spec.ts
@@ -3,8 +3,10 @@ import { cardTextSlots, measureText, type FontFace } from '@stackmap/core';
import { boundaryNodes } from '../src/pages/boundary-nodes';
import { GALLERY_SECTIONS } from '../src/pages/gallery-nodes';
-// Browser ground truth for the headless card-fit measure (M1): the generated table must match what
-// Chromium renders, including kerning, within a small conservative margin.
+// Browser ground truth for the headless card-fit measure (M1). The measure is the wider of the macOS/
+// Windows layout (fractional, kerned) and the Linux one (whole-pixel advances, no kerning), so on any
+// platform it may run up to ~6% wide but must never come in narrower than what Chromium renders.
+const TOO_WIDE = (real: number) => real * 1.08 + 1;
const CORPUS = [
'commerce-api-1',
'API · Instance 1',
@@ -50,29 +52,30 @@ test('headless text measure matches the browser within a conservative margin', a
CORPUS.forEach((text, i) => {
const ours = measureText(text, face, size);
const real = browser[i]!;
- // Never narrower than the browser (would hide an overflow), and never more than 2% + 0.5px wider.
- if (ours < real - 0.25 || ours > real * 1.02 + 0.5) worst.push(`${face} ${size}px "${text}": ours ${ours.toFixed(2)} vs ${real.toFixed(2)}`);
+ // Never narrower than the browser (would hide an overflow), and never wildly wider.
+ if (ours < real - 0.25 || ours > TOO_WIDE(real)) worst.push(`${face} ${size}px "${text}": ours ${ours.toFixed(2)} vs ${real.toFixed(2)}`);
});
}
}
expect(worst).toEqual([]);
});
-test('a card slot truncates exactly when its measured text exceeds its budget', async ({ page }) => {
+test('a card slot never truncates text the measure said fits, and the measure stays close', async ({ page }) => {
await page.goto('/?page=gallery');
await page.evaluate(() => document.fonts.ready);
const rendered = await page.$$eval('[data-testid="node-card"]', (cards) =>
Object.fromEntries(
cards.map((card) => [
card.getAttribute('data-node-id')!,
- [...card.querySelectorAll('span[title], div[title]')].map((el) => ({
- text: el.getAttribute('title')!,
- truncated: el.scrollWidth > el.clientWidth,
- })),
+ [...card.querySelectorAll('span[title], div[title]')].map((el) => {
+ const range = document.createRange();
+ range.selectNodeContents(el);
+ return { text: el.getAttribute('title')!, truncated: el.scrollWidth > el.clientWidth, width: range.getBoundingClientRect().width };
+ }),
]),
),
);
- const mismatches: string[] = [];
+ const problems: string[] = [];
let checked = 0;
for (const [, nodes] of [...GALLERY_SECTIONS, ['Boundary', boundaryNodes()] as const]) {
for (const node of nodes) {
@@ -81,14 +84,20 @@ test('a card slot truncates exactly when its measured text exceeds its budget',
expect(dom.map((d) => d.text), node.id).toEqual(slots.map((s) => s.text));
slots.forEach((slot, i) => {
const width = measureText(slot.text, slot.face, slot.size);
- if (Math.abs(width - slot.maxWidth) < 1) return; // scrollWidth is integer-rounded
+ const at = `${node.id}${slot.path}: measured ${width.toFixed(1)} / budget ${slot.maxWidth.toFixed(1)}`;
checked++;
- if (width > slot.maxWidth !== dom[i]!.truncated)
- mismatches.push(`${node.id}${slot.path}: measured ${width.toFixed(1)} / budget ${slot.maxWidth.toFixed(1)}, truncated=${dom[i]!.truncated}`);
+ // The dangerous direction: validation passed it, the browser cut it off.
+ if (dom[i]!.truncated && width <= slot.maxWidth - 1) problems.push(`${at}, but truncated`);
+ // Untruncated text is fully laid out, so its range width is the real width.
+ if (!dom[i]!.truncated && width > TOO_WIDE(dom[i]!.width)) problems.push(`${at}, real ${dom[i]!.width.toFixed(1)}: too conservative`);
+ // Linux renders the snapped model to within ±2px (hinting), so text measured clearly over budget
+ // must truncate there; this is what catches a budget that is set too small.
+ if (process.platform === 'linux' && node.id === 'boundary-over' && width > slot.maxWidth + 4 && !dom[i]!.truncated)
+ problems.push(`${at}, but not truncated on Linux`);
});
}
}
- expect(mismatches).toEqual([]);
+ expect(problems).toEqual([]);
expect(checked).toBeGreaterThan(60);
});
diff --git a/packages/viewer/e2e/visual.spec.ts b/packages/viewer/e2e/visual.spec.ts
new file mode 100644
index 0000000..832590f
--- /dev/null
+++ b/packages/viewer/e2e/visual.spec.ts
@@ -0,0 +1,19 @@
+import { expect, test } from '@playwright/test';
+
+// Pixel baselines for the look (M0 sign-off): font rasterisation differs per OS, so baselines exist for
+// Linux only (CI, or `bun run visual:update`, which runs Playwright's Linux image in Docker).
+test.skip(process.platform !== 'linux' && !process.env.VISUAL, 'visual baselines are Linux-only');
+
+const PAGES = { sample: '/?page=sample', grouped: '/?page=grouped', gallery: '/?page=gallery' } as const;
+
+for (const theme of ['light', 'dark'] as const) {
+ for (const [name, url] of Object.entries(PAGES)) {
+ test(`${name} · ${theme}`, async ({ page }) => {
+ await page.emulateMedia({ colorScheme: theme, reducedMotion: 'reduce' });
+ await page.goto(url);
+ await page.evaluate(() => document.fonts.ready);
+ await page.waitForTimeout(200);
+ await expect(page).toHaveScreenshot(`${name}-${theme}.png`, { maxDiffPixelRatio: 0.002 });
+ });
+ }
+}
diff --git a/packages/viewer/e2e/visual.spec.ts-snapshots/gallery-dark-linux.png b/packages/viewer/e2e/visual.spec.ts-snapshots/gallery-dark-linux.png
new file mode 100644
index 0000000..92f1046
Binary files /dev/null and b/packages/viewer/e2e/visual.spec.ts-snapshots/gallery-dark-linux.png differ
diff --git a/packages/viewer/e2e/visual.spec.ts-snapshots/gallery-light-linux.png b/packages/viewer/e2e/visual.spec.ts-snapshots/gallery-light-linux.png
new file mode 100644
index 0000000..04ed91b
Binary files /dev/null and b/packages/viewer/e2e/visual.spec.ts-snapshots/gallery-light-linux.png differ
diff --git a/packages/viewer/e2e/visual.spec.ts-snapshots/grouped-dark-linux.png b/packages/viewer/e2e/visual.spec.ts-snapshots/grouped-dark-linux.png
new file mode 100644
index 0000000..5f85f7b
Binary files /dev/null and b/packages/viewer/e2e/visual.spec.ts-snapshots/grouped-dark-linux.png differ
diff --git a/packages/viewer/e2e/visual.spec.ts-snapshots/grouped-light-linux.png b/packages/viewer/e2e/visual.spec.ts-snapshots/grouped-light-linux.png
new file mode 100644
index 0000000..cd8c1b9
Binary files /dev/null and b/packages/viewer/e2e/visual.spec.ts-snapshots/grouped-light-linux.png differ
diff --git a/packages/viewer/e2e/visual.spec.ts-snapshots/sample-dark-linux.png b/packages/viewer/e2e/visual.spec.ts-snapshots/sample-dark-linux.png
new file mode 100644
index 0000000..354e1be
Binary files /dev/null and b/packages/viewer/e2e/visual.spec.ts-snapshots/sample-dark-linux.png differ
diff --git a/packages/viewer/e2e/visual.spec.ts-snapshots/sample-light-linux.png b/packages/viewer/e2e/visual.spec.ts-snapshots/sample-light-linux.png
new file mode 100644
index 0000000..480dd0d
Binary files /dev/null and b/packages/viewer/e2e/visual.spec.ts-snapshots/sample-light-linux.png differ
diff --git a/packages/viewer/index.html b/packages/viewer/index.html
index 45e2e01..1c139c4 100644
--- a/packages/viewer/index.html
+++ b/packages/viewer/index.html
@@ -1,6 +1,13 @@
+
stackmap
diff --git a/packages/viewer/package.json b/packages/viewer/package.json
index 080f734..130c889 100644
--- a/packages/viewer/package.json
+++ b/packages/viewer/package.json
@@ -9,7 +9,8 @@
"test": "vitest run",
"typecheck": "tsc --noEmit",
"e2e": "playwright test",
- "metrics:gen": "bun scripts/gen-font-metrics.ts"
+ "metrics:gen": "bun scripts/gen-font-metrics.ts",
+ "visual:update": "sh scripts/visual-update.sh"
},
"dependencies": {
"@fontsource-variable/geist": "^5.3.0",
diff --git a/packages/viewer/scripts/check-single-file.ts b/packages/viewer/scripts/check-single-file.ts
index ef2883d..8aeadad 100644
--- a/packages/viewer/scripts/check-single-file.ts
+++ b/packages/viewer/scripts/check-single-file.ts
@@ -29,6 +29,8 @@ if (/sans500tnum/.test(html)) fail('Geist metrics table leaked into the viewer b
// 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('')) fail('template lacks the empty stackmap-data block');
+// Bundling strips licence comments, so the attribution lives in an HTML comment that must survive the build.
+if (!html.includes('THIRD_PARTY_NOTICES.md') || !html.includes('SIL OFL 1.1')) fail('third-party notice comment missing from the template');
const kb = statSync(new URL('index.html', dist)).size / 1024;
if (kb > BUDGET_KB) fail(`index.html is ${kb.toFixed(0)} KB, budget ${BUDGET_KB} KB`);
diff --git a/packages/viewer/scripts/visual-update.sh b/packages/viewer/scripts/visual-update.sh
new file mode 100755
index 0000000..c2dab6c
--- /dev/null
+++ b/packages/viewer/scripts/visual-update.sh
@@ -0,0 +1,12 @@
+#!/bin/sh
+# Regenerates the Linux visual baselines in Playwright's official image. The repo is copied into the
+# container (so the host's node_modules are never touched) and only the PNGs are copied back.
+set -eu
+root="$(cd "$(dirname "$0")/../../.." && pwd)"
+image="mcr.microsoft.com/playwright:v1.63.0-noble"
+docker run --rm -v "$root:/src:ro" -v "$root/packages/viewer/e2e:/out" "$image" bash -euc '
+ mkdir /work && cd /src && tar --exclude=node_modules --exclude=.git --exclude=docs --exclude=dist -cf - . | tar -xf - -C /work
+ cd /work && npm i -g bun@1.3.14 >/dev/null && bun install --frozen-lockfile >/dev/null
+ cd packages/viewer && VISUAL=1 bunx playwright test e2e/visual.spec.ts --update-snapshots=all "$@"
+ rm -rf /out/visual.spec.ts-snapshots && cp -r e2e/visual.spec.ts-snapshots /out/
+' -- "$@"
diff --git a/packages/viewer/src/canvas/useZoom.ts b/packages/viewer/src/canvas/useZoom.ts
index bb6d2aa..7d92ffc 100644
--- a/packages/viewer/src/canvas/useZoom.ts
+++ b/packages/viewer/src/canvas/useZoom.ts
@@ -97,8 +97,13 @@ export function useZoom(stageRef: RefObject, content: Rec
),
[animate, content],
);
+ // Centre between the chrome bands, not on the raw stage, so the node doesn't sit under the toolbar.
const centerOn = useCallback(
- (p: Point) => animate((z, el) => select(el).transition().duration(duration()).call(z.translateTo, p.x, p.y)),
+ (p: Point) =>
+ animate((z, el) => {
+ const anchor: [number, number] = [el.clientWidth / 2, (el.clientHeight + CHROME_INSET.top - CHROME_INSET.bottom) / 2];
+ select(el).transition().duration(duration()).call(z.translateTo, p.x, p.y, anchor);
+ }),
[animate],
);
diff --git a/skill/references/authoring-contract.md b/skill/references/authoring-contract.md
index e04fbe2..c1cfb72 100644
--- a/skill/references/authoring-contract.md
+++ b/skill/references/authoring-contract.md
@@ -67,7 +67,7 @@ A card is a fixed 280px-wide tile. Only `title` is required; add sections when t
- `footer` — two short facts, left and right (region, protocol, ownership), with optional `icon`: `region`, `secure`, `members`.
- `cta` — a link out (`href`, http(s) only): a dashboard, a runbook.
-Text never wraps; it truncates, and truncation is a validation **error** (`card-fit/overflow`) measured with the real font. Typical budgets for ordinary words: title ≈ 28 characters, subtitle ≈ 32, a row label + value together ≈ 36 (value alone ≤ 22), stat values ≈ 5 and labels ≈ 8 with three tiles (≈ 17 with two), stats note ≈ 36, footer items ≈ 15 with an icon and ≈ 19 without, CTA label ≈ 32. Wide letters (`W`, `M`) and non-Latin scripts fit fewer; the diagnostic gives the exact `maxChars` for your text. Put long detail in `evidence` notes instead.
+Text never wraps; it truncates, and truncation is a validation **error** (`card-fit/overflow`) measured with the real font. Typical budgets for ordinary words: title ≈ 28 characters, subtitle ≈ 32, a row label + value together ≈ 35 (value alone ≤ 21), stat values ≈ 5 and labels ≈ 8 with three tiles (≈ 16 with two), stats note ≈ 36, footer items ≈ 14 with an icon and ≈ 17 without, CTA label ≈ 32. Wide letters (`W`, `M`) and non-Latin scripts fit fewer; the diagnostic gives the exact `maxChars` for your text. Put long detail in `evidence` notes instead.
## Repository diagrams