diff --git a/README.md b/README.md index b0ae2df..28cb652 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,9 @@ -# stackmap + + + stackmap: architecture diagrams your coding agent writes, as one offline HTML file + -**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.

stackmap viewer in light mode: a Commerce API topology with the Orders database selected and its upstream traced; the inspector shows its details, connections and source evidence @@ -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 @@ + + + stackmap + + +# 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 @@ +stackmap \ 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 @@ +stackmap \ 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 @@ +stackmap \ 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 @@ +stackmap \ 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 @@ +stackmap \ 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 @@ +stackmap \ 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 @@ +stackmap \ 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 @@ +stackmap \ 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 @@ +stackmap \ 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 @@ +stackmap \ 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 @@ +stackmap \ 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 @@ +stackmap \ 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 @@ +stackmap \ 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 @@ +stackmap \ 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 @@ +stackmap \ 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 @@ +stackmap \ 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 @@ +stackmap: every layer of your stack, on one map \ 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 @@ +stackmap: every layer of your stack, on one map \ 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 @@ +stackmap \ 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 @@ +stackmap \ 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 + +