diff --git a/README.md b/README.md
index b0ae2df..28cb652 100644
--- a/README.md
+++ b/README.md
@@ -1,8 +1,9 @@
-# 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.
+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.
@@ -86,6 +87,8 @@ cd packages/cli && bunx playwright test # delivered-file + live-se
| `packages/cli` | `@hyamero/stackmap`: validate · deliver · serve |
| `skill/` | the agent skill that `npx skills add` installs |
+The mark, lockups, icons and social card are in [`assets/brand/`](assets/brand/), with the rules for using them.
+
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
diff --git a/assets/brand/README.md b/assets/brand/README.md
new file mode 100644
index 0000000..3f90894
--- /dev/null
+++ b/assets/brand/README.md
@@ -0,0 +1,39 @@
+
+
+
+
+
+# Brand assets
+
+The stackmap mark is one connection routed across the dot grid, from a grey source card to an indigo target card. Everything else stays as quiet as the viewer: ink on neutral grounds, Geist, and one touch of colour.
+
+Every file carries explicit colours, so pick the light file or its `-dark` twin to match the ground. Text is outlined from Geist 600, so no font is needed.
+
+| File | Use |
+|---|---|
+| `stackmap-mark.svg`, `-dark` | The full mark with grid dots, at 48px and up |
+| `stackmap-mark-compact.svg`, `-dark` | 16–32px: no dots, heavier route, taller cards |
+| `stackmap-mark-mono-black.svg`, `-white` | One ink, for single-colour print and embossing |
+| `stackmap-lockup.svg`, `-dark` | The default horizontal lockup |
+| `stackmap-lockup-small.svg`, `-dark` | Lockups 32px tall or less: nav bars, badges, sidebars |
+| `stackmap-lockup-stacked.svg`, `-dark` | Square slots: avatars, stickers, title cards |
+| `stackmap-wordmark.svg`, `-dark` | The name alone, where the mark would be under 16px |
+| `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.svg`, `-dark` | The 1280×320 README header, in a `` at 640px wide |
+| `stackmap-social-card.png`, `.svg`, `-light.svg` | The 1200×630 link preview (`og:image`, the GitHub social preview) |
+
+## Rules
+
+- Keep a quarter of the mark's height clear on every side.
+- The source card stays grey and the target card stays indigo (`#4f63c9` light, `#8fa6f2` dark). The route and dots are always ink. Never recolour, swap, rotate, mirror, outline or shadow the mark.
+- Put the mark on the page grey or the stage (white or near-black). On anything else, use the mono mark or the app icon tile.
+- The name is always "stackmap", lowercase, in Geist 600 at -0.035em. Use the outlined files; never retype it.
+- The brand accent belongs to brand surfaces (README, social card, CLI output). Inside the viewer, colour means node type and nothing else.
+- In the terminal: the wordmark in bold, a middle dot, the command; output paths in the accent. Plain text when piped or with `NO_COLOR`.
+
+## Voice
+
+- **Tagline:** Every layer of your stack, on one map.
+- **Descriptor:** Architecture diagrams your coding agent writes, as one offline HTML file.
+- Plain, exact and calm. Say what it does, in the viewer's words: diagram, node, connection, view, your coding agent. No emoji, no exclamation marks, no "seamless", "supercharge" or "AI-powered".
diff --git a/assets/brand/stackmap-app-icon-512.png b/assets/brand/stackmap-app-icon-512.png
new file mode 100644
index 0000000..f8ff672
Binary files /dev/null and b/assets/brand/stackmap-app-icon-512.png differ
diff --git a/assets/brand/stackmap-app-icon.svg b/assets/brand/stackmap-app-icon.svg
new file mode 100644
index 0000000..836e9c7
--- /dev/null
+++ b/assets/brand/stackmap-app-icon.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/assets/brand/stackmap-apple-touch-icon-180.png b/assets/brand/stackmap-apple-touch-icon-180.png
new file mode 100644
index 0000000..7de210a
Binary files /dev/null and b/assets/brand/stackmap-apple-touch-icon-180.png differ
diff --git a/assets/brand/stackmap-favicon-16.png b/assets/brand/stackmap-favicon-16.png
new file mode 100644
index 0000000..7e970f4
Binary files /dev/null and b/assets/brand/stackmap-favicon-16.png differ
diff --git a/assets/brand/stackmap-favicon-32.png b/assets/brand/stackmap-favicon-32.png
new file mode 100644
index 0000000..1050599
Binary files /dev/null and b/assets/brand/stackmap-favicon-32.png differ
diff --git a/assets/brand/stackmap-favicon.svg b/assets/brand/stackmap-favicon.svg
new file mode 100644
index 0000000..228889c
--- /dev/null
+++ b/assets/brand/stackmap-favicon.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/assets/brand/stackmap-lockup-dark.svg b/assets/brand/stackmap-lockup-dark.svg
new file mode 100644
index 0000000..53ad463
--- /dev/null
+++ b/assets/brand/stackmap-lockup-dark.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/assets/brand/stackmap-lockup-small-dark.svg b/assets/brand/stackmap-lockup-small-dark.svg
new file mode 100644
index 0000000..a69a50e
--- /dev/null
+++ b/assets/brand/stackmap-lockup-small-dark.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/assets/brand/stackmap-lockup-small.svg b/assets/brand/stackmap-lockup-small.svg
new file mode 100644
index 0000000..377386a
--- /dev/null
+++ b/assets/brand/stackmap-lockup-small.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/assets/brand/stackmap-lockup-stacked-dark.svg b/assets/brand/stackmap-lockup-stacked-dark.svg
new file mode 100644
index 0000000..5ea9cc3
--- /dev/null
+++ b/assets/brand/stackmap-lockup-stacked-dark.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/assets/brand/stackmap-lockup-stacked.svg b/assets/brand/stackmap-lockup-stacked.svg
new file mode 100644
index 0000000..94587d5
--- /dev/null
+++ b/assets/brand/stackmap-lockup-stacked.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/assets/brand/stackmap-lockup.svg b/assets/brand/stackmap-lockup.svg
new file mode 100644
index 0000000..d40b55a
--- /dev/null
+++ b/assets/brand/stackmap-lockup.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/assets/brand/stackmap-mark-compact-dark.svg b/assets/brand/stackmap-mark-compact-dark.svg
new file mode 100644
index 0000000..27e5cbd
--- /dev/null
+++ b/assets/brand/stackmap-mark-compact-dark.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/assets/brand/stackmap-mark-compact.svg b/assets/brand/stackmap-mark-compact.svg
new file mode 100644
index 0000000..62d1774
--- /dev/null
+++ b/assets/brand/stackmap-mark-compact.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/assets/brand/stackmap-mark-dark.svg b/assets/brand/stackmap-mark-dark.svg
new file mode 100644
index 0000000..4b92a4c
--- /dev/null
+++ b/assets/brand/stackmap-mark-dark.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/assets/brand/stackmap-mark-mono-black.svg b/assets/brand/stackmap-mark-mono-black.svg
new file mode 100644
index 0000000..655eef0
--- /dev/null
+++ b/assets/brand/stackmap-mark-mono-black.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/assets/brand/stackmap-mark-mono-white.svg b/assets/brand/stackmap-mark-mono-white.svg
new file mode 100644
index 0000000..7572c5e
--- /dev/null
+++ b/assets/brand/stackmap-mark-mono-white.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/assets/brand/stackmap-mark.svg b/assets/brand/stackmap-mark.svg
new file mode 100644
index 0000000..63633ab
--- /dev/null
+++ b/assets/brand/stackmap-mark.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/assets/brand/stackmap-readme-header-dark.svg b/assets/brand/stackmap-readme-header-dark.svg
new file mode 100644
index 0000000..303f389
--- /dev/null
+++ b/assets/brand/stackmap-readme-header-dark.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/assets/brand/stackmap-readme-header.svg b/assets/brand/stackmap-readme-header.svg
new file mode 100644
index 0000000..d7b6a5b
--- /dev/null
+++ b/assets/brand/stackmap-readme-header.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/assets/brand/stackmap-social-card-light.svg b/assets/brand/stackmap-social-card-light.svg
new file mode 100644
index 0000000..3a7b70c
--- /dev/null
+++ b/assets/brand/stackmap-social-card-light.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/assets/brand/stackmap-social-card.png b/assets/brand/stackmap-social-card.png
new file mode 100644
index 0000000..60c45e4
Binary files /dev/null and b/assets/brand/stackmap-social-card.png differ
diff --git a/assets/brand/stackmap-social-card.svg b/assets/brand/stackmap-social-card.svg
new file mode 100644
index 0000000..a34df30
--- /dev/null
+++ b/assets/brand/stackmap-social-card.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/assets/brand/stackmap-wordmark-dark.svg b/assets/brand/stackmap-wordmark-dark.svg
new file mode 100644
index 0000000..7773dc3
--- /dev/null
+++ b/assets/brand/stackmap-wordmark-dark.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/assets/brand/stackmap-wordmark.svg b/assets/brand/stackmap-wordmark.svg
new file mode 100644
index 0000000..e1968ed
--- /dev/null
+++ b/assets/brand/stackmap-wordmark.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/assets/readme/viewer-dark.png b/assets/readme/viewer-dark.png
index 99a8569..8aaba0f 100644
Binary files a/assets/readme/viewer-dark.png and b/assets/readme/viewer-dark.png differ
diff --git a/assets/readme/viewer-light.png b/assets/readme/viewer-light.png
index f167032..78acbe0 100644
Binary files a/assets/readme/viewer-light.png and b/assets/readme/viewer-light.png differ
diff --git a/packages/cli/package.json b/packages/cli/package.json
index a1fbb1d..4242e2a 100644
--- a/packages/cli/package.json
+++ b/packages/cli/package.json
@@ -1,7 +1,7 @@
{
"name": "@hyamero/stackmap",
"version": "0.1.0",
- "description": "Validate agent-authored architecture diagrams and deliver them as one offline HTML viewer",
+ "description": "Architecture diagrams your coding agent writes, as one offline HTML file",
"keywords": [
"architecture",
"diagram",
diff --git a/packages/cli/scripts/prepack.mjs b/packages/cli/scripts/prepack.mjs
index c829d1f..8c909f9 100644
--- a/packages/cli/scripts/prepack.mjs
+++ b/packages/cli/scripts/prepack.mjs
@@ -1,4 +1,9 @@
// The npm tarball carries the repo's README, license and third-party notices (they live at the repo root).
-import { copyFileSync } from 'node:fs';
+import { copyFileSync, readFileSync, writeFileSync } 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));
+
+// npm resolves relative images against the package, not the repo root: point the README's images at GitHub.
+const readme = new URL('../README.md', import.meta.url);
+const raw = 'https://raw.githubusercontent.com/hyamero/stackmap/main/';
+writeFileSync(readme, readFileSync(readme, 'utf8').replace(/(src|srcset)="(assets\/[^"]+)"/g, `$1="${raw}$2"`));
diff --git a/packages/cli/src/cli.ts b/packages/cli/src/cli.ts
index 0590df5..ce63068 100644
--- a/packages/cli/src/cli.ts
+++ b/packages/cli/src/cli.ts
@@ -2,6 +2,7 @@ import { readFileSync } from 'node:fs';
import { parseArgs } from 'node:util';
import { deliverCommand, validateCommand, type CommandResult } from './commands';
import { serve } from './serve';
+import { styleFor } from './style';
// Injected by tsup (define), so the bundle doesn't carry the whole package.json.
declare const __STACKMAP_VERSION__: string;
@@ -22,6 +23,15 @@ Options:
-v, --version show the version
`;
+const style = styleFor(process.stdout);
+
+// On a TTY a command's stdout goes under the banner, indented like the brand's CLI sample.
+const sign = (command: string, text: string) => {
+ const banner = style.banner(command);
+ return banner && text ? banner + text.replace(/^(?=.)/gm, ' ') : text;
+};
+const signed = (command: string, r: CommandResult): CommandResult => ({ ...r, stdout: sign(command, r.stdout) });
+
async function run(argv: string[]): Promise {
let parsed;
try {
@@ -50,7 +60,7 @@ async function run(argv: string[]): Promise {
if (values.port !== undefined && command !== 'serve') return usage('--port applies to serve');
if (command === 'validate') {
if (values.out !== undefined) return usage('-o/--out applies to deliver, not validate');
- return validateCommand(file, { json: !!values.json });
+ return values.json ? validateCommand(file, { json: true }) : signed(command, await validateCommand(file, { json: false }));
}
if (values.json) return usage(`--json applies to validate, not ${command}`);
if (command === 'serve' && values.out !== undefined) return usage('-o/--out applies to deliver, not serve');
@@ -60,12 +70,12 @@ async function run(argv: string[]): Promise {
} catch (e) {
return { code: 2, stdout: '', stderr: `stackmap: internal error: viewer template missing (${(e as Error).message})\n` };
}
- if (command === 'deliver') return deliverCommand(file, { template, out: values.out });
+ if (command === 'deliver') return signed(command, await deliverCommand(file, { template, out: values.out, style }));
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}"`);
const server = await serve(file, { template, port, log: (line) => process.stderr.write(`${line}\n`) });
- process.stdout.write(`serving ${server.url} · watching ${file} · Ctrl-C to stop\n`);
+ process.stdout.write(sign(command, `serving ${style.path(server.url)} · watching ${file} · Ctrl-C to stop\n`));
await new Promise((resolve) => process.once('SIGINT', resolve));
await server.close();
return { code: 0, stdout: '', stderr: '' };
diff --git a/packages/cli/src/commands.ts b/packages/cli/src/commands.ts
index 0e90448..60b5271 100644
--- a/packages/cli/src/commands.ts
+++ b/packages/cli/src/commands.ts
@@ -7,6 +7,7 @@ import { embedDiagram } from './embed';
import { formatDiagnostic, summary } from './format';
import { CliError, readText, writeAtomic } from './io';
import { locateJsonError } from './json-error';
+import { plain, type Style } from './style';
export interface CommandResult {
code: 0 | 1 | 2;
@@ -68,7 +69,10 @@ export async function buildHtml(path: string, template: string): Promise {
+export async function deliverCommand(
+ path: string,
+ { template, out, style = plain }: { template: string; out?: string; style?: Style },
+): Promise {
try {
const target = out ?? path.slice(0, path.length - extname(path).length) + '.html';
if (real(target) === real(path)) throw new CliError(`refusing to overwrite the input ${path}; pass -o `);
@@ -78,7 +82,7 @@ export async function deliverCommand(path: string, { template, out }: { template
writeAtomic(target, html);
const bytes = Buffer.byteLength(html);
const sha = createHash('sha256').update(html).digest('hex');
- return { code: 0, stdout: `delivered ${target} · sha256 ${sha} · ${bytes} bytes\n`, stderr: report(diagnostics) };
+ return { code: 0, stdout: `delivered ${style.path(target)} · sha256 ${sha} · ${bytes} bytes\n`, stderr: report(diagnostics) };
} catch (e) {
return failure(e);
}
diff --git a/packages/cli/src/style.ts b/packages/cli/src/style.ts
new file mode 100644
index 0000000..030aaa5
--- /dev/null
+++ b/packages/cli/src/style.ts
@@ -0,0 +1,30 @@
+/**
+ * Terminal styling from the brand: the wordmark in bold, then a middle dot and the command, and output paths in
+ * the brand accent. Only on a TTY; agents read piped output, which stays byte-identical plain text.
+ */
+export interface Style {
+ /** The signature line printed above a command's output, or '' when there is none. */
+ banner(command: string): string;
+ path(text: string): string;
+}
+
+export const plain: Style = { banner: () => '', path: (text) => text };
+
+const sgr = (code: string, text: string, reset: string) => `\x1b[${code}m${text}\x1b[${reset}m`;
+
+export function styleFor(stream: { isTTY?: boolean }, env: NodeJS.ProcessEnv = process.env): Style {
+ if (!stream.isTTY) return plain;
+ // no-color.org: any non-empty NO_COLOR disables colour; the banner stays, as plain text.
+ if (env.NO_COLOR || env.TERM === 'dumb') return { banner: (command) => `stackmap · ${command}\n`, path: (text) => text };
+ // COLORFGBG ("fg;bg") is the only widespread hint of a light background: bg 7 or 15.
+ const light = /;(7|15)$/.test(env.COLORFGBG ?? '');
+ const accent = /truecolor|24bit/i.test(env.COLORTERM ?? '')
+ ? light ? '38;2;79;99;201' : '38;2;143;166;242'
+ : /256/.test(env.TERM ?? '')
+ ? light ? '38;5;62' : '38;5;111'
+ : '34';
+ return {
+ banner: (command) => `${sgr('1', 'stackmap', '22')} · ${command}\n`,
+ path: (text) => sgr(accent, text, '39'),
+ };
+}
diff --git a/packages/cli/test/commands.test.ts b/packages/cli/test/commands.test.ts
index f3ede04..02695f4 100644
--- a/packages/cli/test/commands.test.ts
+++ b/packages/cli/test/commands.test.ts
@@ -74,6 +74,12 @@ describe('deliver', () => {
expect(bytes.toString()).toContain('Platform · stackmap');
});
+ it('styles the written path when given a terminal style', async () => {
+ const style = { banner: () => '', path: (t: string) => `<${t}>` };
+ const r = await deliverCommand(file('d.json', commerceApi), { template: TEMPLATE, style });
+ expect(r.stdout).toMatch(new RegExp(`^delivered <${join(dir, 'd.html')}> · sha256 `));
+ });
+
it('is byte-identical across runs', async () => {
const input = file('d.json', commerceApi);
const a = await deliverCommand(input, { template: TEMPLATE, out: join(dir, 'a.html') });
diff --git a/packages/cli/test/style.test.ts b/packages/cli/test/style.test.ts
new file mode 100644
index 0000000..388226b
--- /dev/null
+++ b/packages/cli/test/style.test.ts
@@ -0,0 +1,29 @@
+import { describe, expect, it } from 'vitest';
+import { plain, styleFor } from '../src/style';
+
+const tty = { isTTY: true };
+
+describe('styleFor', () => {
+ it('is plain off a TTY, whatever the environment', () => {
+ expect(styleFor({ isTTY: false }, { COLORTERM: 'truecolor' })).toBe(plain);
+ expect(plain.banner('deliver')).toBe('');
+ expect(plain.path('out.html')).toBe('out.html');
+ });
+
+ it('keeps the banner but drops colour with NO_COLOR or a dumb terminal', () => {
+ for (const env of [{ NO_COLOR: '1' }, { TERM: 'dumb' }]) {
+ const s = styleFor(tty, env);
+ expect(s.banner('deliver')).toBe('stackmap · deliver\n');
+ expect(s.path('out.html')).toBe('out.html');
+ }
+ });
+
+ it('prints the wordmark in bold and paths in the accent for the terminal', () => {
+ expect(styleFor(tty, { COLORTERM: 'truecolor' }).banner('serve')).toBe('\x1b[1mstackmap\x1b[22m · serve\n');
+ expect(styleFor(tty, { COLORTERM: 'truecolor' }).path('a')).toBe('\x1b[38;2;143;166;242ma\x1b[39m');
+ expect(styleFor(tty, { COLORTERM: '24bit', COLORFGBG: '0;15' }).path('a')).toBe('\x1b[38;2;79;99;201ma\x1b[39m');
+ expect(styleFor(tty, { TERM: 'xterm-256color' }).path('a')).toBe('\x1b[38;5;111ma\x1b[39m');
+ expect(styleFor(tty, { TERM: 'xterm-256color', COLORFGBG: '0;7' }).path('a')).toBe('\x1b[38;5;62ma\x1b[39m');
+ expect(styleFor(tty, { TERM: 'xterm' }).path('a')).toBe('\x1b[34ma\x1b[39m');
+ });
+});
diff --git a/packages/viewer/index.html b/packages/viewer/index.html
index 1c139c4..08132a1 100644
--- a/packages/viewer/index.html
+++ b/packages/viewer/index.html
@@ -11,6 +11,8 @@
stackmap
+
+