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/LICENSE b/LICENSE
new file mode 100644
index 0000000..92d4b89
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,50 @@
+MIT License
+
+Copyright (c) 2026 hyamero
+
+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.
+
+---------------------------------------------------------------------------
+
+stackmap is a successor to archify (https://github.com/tt-a1i/archify). Parts
+of the agent skill (skill/references/authoring-contract.md) are adapted from
+archify's documentation, used under the following license:
+
+MIT License
+
+Copyright (c) 2026 tt-a1i (Archify)
+Copyright (c) 2025 Cocoon AI
+
+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.
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/bun.lock b/bun.lock
index c52da16..fba9c71 100644
--- a/bun.lock
+++ b/bun.lock
@@ -10,6 +10,25 @@
"vitest": "^5.0.2",
},
},
+ "packages/cli": {
+ "name": "@hyamero/stackmap",
+ "version": "0.1.0",
+ "bin": {
+ "stackmap": "./dist/cli.js",
+ },
+ "dependencies": {
+ "elkjs": "^0.12.0",
+ },
+ "devDependencies": {
+ "@playwright/test": "^1.63.0",
+ "@stackmap/core": "workspace:*",
+ "@stackmap/layout": "workspace:*",
+ "@stackmap/schema": "workspace:*",
+ "@stackmap/viewer": "workspace:*",
+ "tsup": "^8.5.1",
+ "zod": "^4.6.5",
+ },
+ },
"packages/core": {
"name": "@stackmap/core",
"version": "0.0.0",
@@ -22,6 +41,17 @@
"elkjs": "^0.12.0",
},
},
+ "packages/schema": {
+ "name": "@stackmap/schema",
+ "version": "0.0.0",
+ "dependencies": {
+ "@stackmap/core": "workspace:*",
+ "zod": "^4.6.5",
+ },
+ "devDependencies": {
+ "ajv": "^8.20.0",
+ },
+ },
"packages/viewer": {
"name": "@stackmap/viewer",
"version": "0.0.0",
@@ -32,6 +62,7 @@
"d3-selection": "^3.0.0",
"d3-transition": "^3.0.1",
"d3-zoom": "^3.0.0",
+ "html-to-image": "^1.11.13",
"lucide-react": "^1.48.0",
"react": "^19.3.0",
"react-dom": "^19.3.0",
@@ -45,9 +76,11 @@
"@types/d3-selection": "^3.0.12",
"@types/d3-transition": "^3.0.9",
"@types/d3-zoom": "^3.0.8",
+ "@types/fontkit": "^2.0.9",
"@types/react": "^19.3.0",
"@types/react-dom": "^19.3.0",
"@vitejs/plugin-react": "^6.1.1",
+ "fontkit": "2.0.4",
"jsdom": "^30.1.1",
"tailwindcss": "^4.3.3",
"vite": "^8.3.1",
@@ -82,12 +115,66 @@
"@csstools/css-tokenizer": ["@csstools/css-tokenizer@4.0.2", "", {}, "sha512-OoKoR0f76dCY666JlcbhmVTs2drYj1GUXZTYTcbUgJjh9Nv41aFfZ21bPQTERm5+L5cBDo466NltB2lplS5GBw=="],
+ "@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.27.7", "", { "os": "aix", "cpu": "ppc64" }, "sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg=="],
+
+ "@esbuild/android-arm": ["@esbuild/android-arm@0.27.7", "", { "os": "android", "cpu": "arm" }, "sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ=="],
+
+ "@esbuild/android-arm64": ["@esbuild/android-arm64@0.27.7", "", { "os": "android", "cpu": "arm64" }, "sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ=="],
+
+ "@esbuild/android-x64": ["@esbuild/android-x64@0.27.7", "", { "os": "android", "cpu": "x64" }, "sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg=="],
+
+ "@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.27.7", "", { "os": "darwin", "cpu": "arm64" }, "sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw=="],
+
+ "@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.27.7", "", { "os": "darwin", "cpu": "x64" }, "sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ=="],
+
+ "@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.27.7", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w=="],
+
+ "@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.27.7", "", { "os": "freebsd", "cpu": "x64" }, "sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ=="],
+
+ "@esbuild/linux-arm": ["@esbuild/linux-arm@0.27.7", "", { "os": "linux", "cpu": "arm" }, "sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA=="],
+
+ "@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.27.7", "", { "os": "linux", "cpu": "arm64" }, "sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A=="],
+
+ "@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.27.7", "", { "os": "linux", "cpu": "ia32" }, "sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg=="],
+
+ "@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.27.7", "", { "os": "linux", "cpu": "none" }, "sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q=="],
+
+ "@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.27.7", "", { "os": "linux", "cpu": "none" }, "sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw=="],
+
+ "@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.27.7", "", { "os": "linux", "cpu": "ppc64" }, "sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ=="],
+
+ "@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.27.7", "", { "os": "linux", "cpu": "none" }, "sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ=="],
+
+ "@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.27.7", "", { "os": "linux", "cpu": "s390x" }, "sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw=="],
+
+ "@esbuild/linux-x64": ["@esbuild/linux-x64@0.27.7", "", { "os": "linux", "cpu": "x64" }, "sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA=="],
+
+ "@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.27.7", "", { "os": "none", "cpu": "arm64" }, "sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w=="],
+
+ "@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.27.7", "", { "os": "none", "cpu": "x64" }, "sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw=="],
+
+ "@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.27.7", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A=="],
+
+ "@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.27.7", "", { "os": "openbsd", "cpu": "x64" }, "sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg=="],
+
+ "@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.27.7", "", { "os": "none", "cpu": "arm64" }, "sha512-+KrvYb/C8zA9CU/g0sR6w2RBw7IGc5J2BPnc3dYc5VJxHCSF1yNMxTV5LQ7GuKteQXZtspjFbiuW5/dOj7H4Yw=="],
+
+ "@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.27.7", "", { "os": "sunos", "cpu": "x64" }, "sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA=="],
+
+ "@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.27.7", "", { "os": "win32", "cpu": "arm64" }, "sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA=="],
+
+ "@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.27.7", "", { "os": "win32", "cpu": "ia32" }, "sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw=="],
+
+ "@esbuild/win32-x64": ["@esbuild/win32-x64@0.27.7", "", { "os": "win32", "cpu": "x64" }, "sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg=="],
+
"@exodus/bytes": ["@exodus/bytes@1.16.0", "", { "peerDependencies": { "@noble/hashes": "^1.8.0 || ^2.0.0" }, "optionalPeers": ["@noble/hashes"] }, "sha512-IcpW84uEn3N7ETtNZMlxKhfl6Pec8rUNGOTBtWbK1FKhJxIFAptZyVrvVRVBimAJxJCgc3PxepxkdWWG4DVzfA=="],
"@fontsource-variable/geist": ["@fontsource-variable/geist@5.3.0", "", {}, "sha512-j0m+vLQuG5XAYoHtGCVu0spvlGreR3EzpECUVzkFmI1mTVnAO38l/NEPDCFgZ177JxzYJCLSmTQibIiYPilGrA=="],
"@fontsource-variable/geist-mono": ["@fontsource-variable/geist-mono@5.3.0", "", {}, "sha512-vBbuwDEo9AkrqADMXOrlAR3DFcJi4/JxeuU43FoiQERnNwsfXNnvxvReZG02cQKmyk4DZkZdBZX3oTDvy2zBAw=="],
+ "@hyamero/stackmap": ["@hyamero/stackmap@workspace:packages/cli"],
+
"@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="],
"@jridgewell/remapping": ["@jridgewell/remapping@2.3.5", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ=="],
@@ -98,6 +185,8 @@
"@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="],
+ "@napi-rs/lzma-linux-x64-gnu": ["@napi-rs/lzma-linux-x64-gnu@1.5.1", "", { "os": "linux", "cpu": "x64" }, "sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ=="],
+
"@oxc-project/types": ["@oxc-project/types@0.151.0", "", {}, "sha512-J1yXrIlNDZVzE3ada310xeAw7nH8yCAyLPuUIsjKatFPmfn5bS1oW+cM+QsGOtVWd5nhSpbwZWx/rue+r5Z+PA=="],
"@playwright/test": ["@playwright/test@1.63.0", "", { "dependencies": { "playwright": "1.63.0" }, "bin": { "playwright": "cli.js" } }, "sha512-oxMK4vllB9RK5NQ2l1pq1IfOf2AvnEuj/vYGDj0H2nMtmtZpKtCwt/l00GEO6xjGfpBNAvjovvYdCm50dRQkpQ=="],
@@ -134,12 +223,66 @@
"@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.1", "", {}, "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw=="],
+ "@rollup/rollup-android-arm-eabi": ["@rollup/rollup-android-arm-eabi@4.63.5", "", { "os": "android", "cpu": "arm" }, "sha512-J25QJU+B78T4FhhBsNpLJyVWOi31mwtpcMwywHmOKH65Q9IWGA81gPj+dnwlhU8wktVriYE+tFAaQgrnJRzAZg=="],
+
+ "@rollup/rollup-android-arm64": ["@rollup/rollup-android-arm64@4.63.5", "", { "os": "android", "cpu": "arm64" }, "sha512-LDopB3zuZM5Ux9TT2luNEBJW/tYbGU2g1d+VpKk6I+gSKDb+/7sYE6M225gRQt4RbMX6MSwMsVR/phdjVUgRLg=="],
+
+ "@rollup/rollup-darwin-arm64": ["@rollup/rollup-darwin-arm64@4.63.5", "", { "os": "darwin", "cpu": "arm64" }, "sha512-wlJEERGfeuHeBavCL2qVnNacOK43NDoZM4sjkeRPymd04OAE9T1zBqDJgmZ+CIsPTYKwdzpUC8vmOw84dwY4Tg=="],
+
+ "@rollup/rollup-darwin-x64": ["@rollup/rollup-darwin-x64@4.63.5", "", { "os": "darwin", "cpu": "x64" }, "sha512-4nJJGg5jbo2wwPP4JP+LfEBA3bvP8rU9CLuhp7jWvq9sxEyhjQFTFdrqi+/dHEin/pd8jpT0vcehIpnZtmEdcQ=="],
+
+ "@rollup/rollup-freebsd-arm64": ["@rollup/rollup-freebsd-arm64@4.63.5", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-DrZbyCDF1hneuO6jRbvZ2D7+PIBM6yIwYnJpg2vIk58T+wuFpiaGZrfUr59lDWw45bg+IrpTGLPiNi/Fk4w3Cg=="],
+
+ "@rollup/rollup-freebsd-x64": ["@rollup/rollup-freebsd-x64@4.63.5", "", { "os": "freebsd", "cpu": "x64" }, "sha512-gqfUVMJMB3mehqywxp6hTBFfgtMQykZY19+cfiaYP0toIJLb/1DZRJHVkQQGP13W4TAwfZDWeg1qBcheTRioXQ=="],
+
+ "@rollup/rollup-linux-arm-gnueabihf": ["@rollup/rollup-linux-arm-gnueabihf@4.63.5", "", { "os": "linux", "cpu": "arm" }, "sha512-CFmhpvAwzSaWMlN3VN7UtmoTihlZNzoP0juQib5TQRnYUyDV8dXeWOp29sobWAT6gXl/hQgAClLlEiYozQG3OQ=="],
+
+ "@rollup/rollup-linux-arm-musleabihf": ["@rollup/rollup-linux-arm-musleabihf@4.63.5", "", { "os": "linux", "cpu": "arm" }, "sha512-Uc9H8eXCOayV6JLTH5bXKMId6qbhNHa818/BgYjm4jrlq3vZquC9cqyvHBw17xy5Mnj5f+I3gFK5JcEf3hSqrw=="],
+
+ "@rollup/rollup-linux-arm64-gnu": ["@rollup/rollup-linux-arm64-gnu@4.63.5", "", { "os": "linux", "cpu": "arm64" }, "sha512-VcPr/szv/1BFw112Kt//fxulXt/JPqzzidU84iW68L2DdjnOO8QFUv2zTSYBEPHD6movBD4z+bbr5y60GYM7Jw=="],
+
+ "@rollup/rollup-linux-arm64-musl": ["@rollup/rollup-linux-arm64-musl@4.63.5", "", { "os": "linux", "cpu": "arm64" }, "sha512-BnxtJ5/91BrIHYIkGrmjz/lbMhqEHt1dPFqIxIFR+jPn0xVc/oUSCtIT089zfp5ufwGDlYz2UC+Fe1SRBpYFbQ=="],
+
+ "@rollup/rollup-linux-loong64-gnu": ["@rollup/rollup-linux-loong64-gnu@4.63.5", "", { "os": "linux", "cpu": "none" }, "sha512-LrYcHZwF+fAMNKHYTOQ5osWM4AZF7YF6D+XtsjDyEvljtt11twc+zHVXBLNEjxVSUnKYsOhvVz4Z213eW02COQ=="],
+
+ "@rollup/rollup-linux-loong64-musl": ["@rollup/rollup-linux-loong64-musl@4.63.5", "", { "os": "linux", "cpu": "none" }, "sha512-nj7QKQePAAUpCpJHtg0pR0W/b92A9NO17JS3BAQmHDn/yhmkir2p8llrKY9TOhleKIaSzy1JhxS3T9FVld6coA=="],
+
+ "@rollup/rollup-linux-ppc64-gnu": ["@rollup/rollup-linux-ppc64-gnu@4.63.5", "", { "os": "linux", "cpu": "ppc64" }, "sha512-5ylkX6dWMeBKge9nTU+Rxfb+ZfaCIJ9lRqIFaK0eAMcWp7OJbYnLveLgXmm0VrvuLKb8qIK+mHyH0qu88RM+iA=="],
+
+ "@rollup/rollup-linux-ppc64-musl": ["@rollup/rollup-linux-ppc64-musl@4.63.5", "", { "os": "linux", "cpu": "ppc64" }, "sha512-oHK4ZHYFDKjZviK34I+NwgfbGxgI7ztrNxj2hPTSSNFgeq1a/lEd7dHV2fdGAuTH4Iym3RHJg+vAbWaWG4B7Zg=="],
+
+ "@rollup/rollup-linux-riscv64-gnu": ["@rollup/rollup-linux-riscv64-gnu@4.63.5", "", { "os": "linux", "cpu": "none" }, "sha512-UcetmHZ6XOXuUByiKZyQmb55ZPr0LABr3Ec/HB9wKZn6CEAFWZkE+hsJErJ9hbPBC7nI0dKuELx7CoV6IM7TMg=="],
+
+ "@rollup/rollup-linux-riscv64-musl": ["@rollup/rollup-linux-riscv64-musl@4.63.5", "", { "os": "linux", "cpu": "none" }, "sha512-C5CmDPQBtvjVo8cgQsBs+w6WB0JLkiixhgi6hVLV11hERWdn/p0XcPU2OUcZzac9BPOFq7SbaHFa8r3SWEysCQ=="],
+
+ "@rollup/rollup-linux-s390x-gnu": ["@rollup/rollup-linux-s390x-gnu@4.63.5", "", { "os": "linux", "cpu": "s390x" }, "sha512-lHVQHJFKsuuxLMi3MQO9XVL8Tje3JR82CzB+QDKC5NWBcsIWuwsn9uIM5e3lBhI+fF1/s63qnyYqsg65+8rV/w=="],
+
+ "@rollup/rollup-linux-x64-gnu": ["@rollup/rollup-linux-x64-gnu@4.63.5", "", { "os": "linux", "cpu": "x64" }, "sha512-3W9bTFcQNJn71cSJVM9RKIiZOy8DO/XLDii8Uv/Pm6WKqDRj7JV3ZfuXIEfyuy5LXpIzAbB/1M4Ukp9GKNa7nA=="],
+
+ "@rollup/rollup-linux-x64-musl": ["@rollup/rollup-linux-x64-musl@4.63.5", "", { "os": "linux", "cpu": "x64" }, "sha512-VDC7rRJlee/scpki96GZ27Omf6yU87s1YXwVTpjE5841faVlDYYT565rgfmoR1U0sqL7z5ivQSDjcsF6VRXyBA=="],
+
+ "@rollup/rollup-openbsd-x64": ["@rollup/rollup-openbsd-x64@4.63.5", "", { "os": "openbsd", "cpu": "x64" }, "sha512-z86Ok2p4pTdv5xqCKZsTooO7yBEiaJR/HzU3Wx8RmWsPoLppnMKROhJusQob8B3IE1ghC343kUW9rC2r+Wf3ig=="],
+
+ "@rollup/rollup-openharmony-arm64": ["@rollup/rollup-openharmony-arm64@4.63.5", "", { "os": "none", "cpu": "arm64" }, "sha512-IzQmj+xXwQFGhMAMKMQVXkMwMZN3TqkJgAE0nSsqvVwWWciP4AIPMmWRqOQ2GfX7TUDZr+xqGFcBS36CRPGw0g=="],
+
+ "@rollup/rollup-win32-arm64-msvc": ["@rollup/rollup-win32-arm64-msvc@4.63.5", "", { "os": "win32", "cpu": "arm64" }, "sha512-F6qpTaPc9bwBH85kjy0/BLmLSW1uv7AoOXCoRIkg2arlgCYlWYcAbiMkvZuAcaWk9TpCRG//okznLAqLGshkMw=="],
+
+ "@rollup/rollup-win32-ia32-msvc": ["@rollup/rollup-win32-ia32-msvc@4.63.5", "", { "os": "win32", "cpu": "ia32" }, "sha512-igoDsTFhhwECBeGbUuLeIk7t8Y1apa+cs6mDWpx2EZ0ch7oEQgzHbFUXN9euoHekCAQzXdXApAGkV6jznS7tWw=="],
+
+ "@rollup/rollup-win32-x64-gnu": ["@rollup/rollup-win32-x64-gnu@4.63.5", "", { "os": "win32", "cpu": "x64" }, "sha512-U3teMeMbXFmaM5D+OTJpsOXd+wV/qftIeYF9kBKL4v73641qyJmoXFtA28DQLsnmlyayEsTe72xpLHrArq6vHw=="],
+
+ "@rollup/rollup-win32-x64-msvc": ["@rollup/rollup-win32-x64-msvc@4.63.5", "", { "os": "win32", "cpu": "x64" }, "sha512-ypfC34F3RKXvCXBglGqGMsUSMKlgwd1HX9AOAlx9RoZZ6GaI42YHVeKpzg3JG+wpBUJYTG+NNZhqbDWL8tBZkw=="],
+
"@stackmap/core": ["@stackmap/core@workspace:packages/core"],
"@stackmap/layout": ["@stackmap/layout@workspace:packages/layout"],
+ "@stackmap/schema": ["@stackmap/schema@workspace:packages/schema"],
+
"@stackmap/viewer": ["@stackmap/viewer@workspace:packages/viewer"],
+ "@swc/helpers": ["@swc/helpers@0.5.23", "", { "dependencies": { "tslib": "^2.8.0" } }, "sha512-5lSsMOTXURePglDfvuAQUqkGek9Hg2kksOYay2m0+XR++b2NWYL/4sWyuvVBIs8oKnJaxkdi9whaL/sqN13afw=="],
+
"@tailwindcss/node": ["@tailwindcss/node@4.3.3", "", { "dependencies": { "@jridgewell/remapping": "^2.3.5", "enhanced-resolve": "^5.24.1", "jiti": "^2.7.0", "lightningcss": "1.32.0", "magic-string": "^0.30.21", "source-map-js": "^1.2.1", "tailwindcss": "4.3.3" } }, "sha512-/T8IKEsf9VTU6tLjgC7+sv2mOPtQxzE2jMw7u4Tt40Tx+QSZxpzh95/H6cMKoja9XuW7iMdLJYBB0o9G1CaAgg=="],
"@tailwindcss/oxide": ["@tailwindcss/oxide@4.3.3", "", { "optionalDependencies": { "@tailwindcss/oxide-android-arm64": "4.3.3", "@tailwindcss/oxide-darwin-arm64": "4.3.3", "@tailwindcss/oxide-darwin-x64": "4.3.3", "@tailwindcss/oxide-freebsd-x64": "4.3.3", "@tailwindcss/oxide-linux-arm-gnueabihf": "4.3.3", "@tailwindcss/oxide-linux-arm64-gnu": "4.3.3", "@tailwindcss/oxide-linux-arm64-musl": "4.3.3", "@tailwindcss/oxide-linux-x64-gnu": "4.3.3", "@tailwindcss/oxide-linux-x64-musl": "4.3.3", "@tailwindcss/oxide-wasm32-wasi": "4.3.3", "@tailwindcss/oxide-win32-arm64-msvc": "4.3.3", "@tailwindcss/oxide-win32-x64-msvc": "4.3.3" } }, "sha512-krXjAikiaFSPaK/FkAQT5UTx3VormQaiZ5hBFlJZ9UFQGB/rwg1MZIhHAG9smMQRTdyJxP6Qt5MwMtdyU5FWrA=="],
@@ -194,6 +337,8 @@
"@types/estree": ["@types/estree@1.0.9", "", {}, "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg=="],
+ "@types/fontkit": ["@types/fontkit@2.0.9", "", { "dependencies": { "@types/node": "*" } }, "sha512-qNYerFky3muCmZPq+R+B3cUDRA5OONw/oh6aGGFxx2LOBz6yu8eamKusrhkHnC6rc2fm76+G9z9QoWSB2SaQaw=="],
+
"@types/node": ["@types/node@26.6.3", "", { "dependencies": { "undici-types": "~8.9.0" } }, "sha512-dsqMQQoeTLqu9wynDD00q573mNzso3IdQOAfHRJqLCcmCFPoGo9A1bDpUcv/9tnKpErQWv9uKeGfl37EIS02Yg=="],
"@types/react": ["@types/react@19.3.0", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-N0rFCuH9YoxG9/m61l9MfpJKfmLOVU0em7ipIz6TRgSSkvReLB9vL85GB+yr8Bs5leqpvg96JSwF4ZS1s4viQg=="],
@@ -206,20 +351,44 @@
"@vitest/spy": ["@vitest/spy@5.0.2", "", {}, "sha512-Ijc7T1nT9efNb5LxvjaBrEqw3f/QwUv5EE0nKqZxgqsaV/FxAAZ8baGylA8X/Z2oS4Lp+K74Jr6dTJsDKxJDeg=="],
+ "acorn": ["acorn@8.18.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ=="],
+
+ "ajv": ["ajv@8.20.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA=="],
+
"ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="],
"ansi-styles": ["ansi-styles@5.2.0", "", {}, "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA=="],
+ "any-promise": ["any-promise@1.3.0", "", {}, "sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A=="],
+
"aria-query": ["aria-query@5.3.2", "", {}, "sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw=="],
"assertion-error": ["assertion-error@2.0.1", "", {}, "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA=="],
+ "base64-js": ["base64-js@1.5.1", "", {}, "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA=="],
+
"bidi-js": ["bidi-js@1.1.0", "", { "dependencies": { "require-from-string": "^2.0.2" } }, "sha512-fX1Onk0tdVPC7obPWB5EbJ1z7NVhLq4m2xZLq2YXBkxzMXIGRpNMU88n0EPgWseKl12J7zXs7qrDxPK4sRs2fg=="],
"braces": ["braces@3.0.3", "", { "dependencies": { "fill-range": "^7.1.1" } }, "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA=="],
+ "brotli": ["brotli@1.3.3", "", { "dependencies": { "base64-js": "^1.1.2" } }, "sha512-oTKjJdShmDuGW94SyyaoQvAjf30dZaHnjJ8uAF+u2/vGJkJbJPJAT1gDiOJP5v1Zb6f9KEyW/1HpuaWIXtGHPg=="],
+
+ "bundle-require": ["bundle-require@5.1.0", "", { "dependencies": { "load-tsconfig": "^0.2.3" }, "peerDependencies": { "esbuild": ">=0.18" } }, "sha512-3WrrOuZiyaaZPWiEt4G3+IffISVC9HYlWueJEBWED4ZH4aIAC2PnkdnuRrR94M+w6yGWn4AglWtJtBI8YqvgoA=="],
+
+ "cac": ["cac@6.7.14", "", {}, "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ=="],
+
"chai": ["chai@6.2.2", "", {}, "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg=="],
+ "chokidar": ["chokidar@4.0.3", "", { "dependencies": { "readdirp": "^4.0.1" } }, "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA=="],
+
+ "clone": ["clone@2.1.2", "", {}, "sha512-3Pe/CF1Nn94hyhIYpjtiLhdCoEoz0DqQ+988E9gmeEdQZlojxnOb74wctFyuwWQHzqyf9X7C7MG8juUpqBJT8w=="],
+
+ "commander": ["commander@4.1.1", "", {}, "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA=="],
+
+ "confbox": ["confbox@0.1.8", "", {}, "sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w=="],
+
+ "consola": ["consola@3.4.2", "", {}, "sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA=="],
+
"css-tree": ["css-tree@3.2.1", "", { "dependencies": { "mdn-data": "2.27.1", "source-map-js": "^1.2.1" } }, "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA=="],
"css.escape": ["css.escape@1.5.1", "", {}, "sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg=="],
@@ -246,12 +415,16 @@
"data-urls": ["data-urls@7.0.0", "", { "dependencies": { "whatwg-mimetype": "^5.0.0", "whatwg-url": "^16.0.0" } }, "sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA=="],
+ "debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" }, "peerDependencies": { "supports-color": "*" }, "optionalPeers": ["supports-color"] }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
+
"decimal.js": ["decimal.js@10.6.0", "", {}, "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg=="],
"dequal": ["dequal@2.0.3", "", {}, "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA=="],
"detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="],
+ "dfa": ["dfa@1.2.0", "", {}, "sha512-ED3jP8saaweFTjeGX8HQPjeC1YYyZs98jGNZx6IiBvxW7JG5v492kamAQB3m2wop07CvU/RQmzcKr6bgcC5D/Q=="],
+
"dom-accessibility-api": ["dom-accessibility-api@0.6.3", "", {}, "sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w=="],
"elkjs": ["elkjs@0.12.0", "", {}, "sha512-YZcKynxVxYoKIOEpywEPwCFdg+BTbxQRNf3pbwdDCvc8O3kQD8bmIwSxKU1eOTVc4Xo+VG9Te+575mlfvOrhEQ=="],
@@ -262,20 +435,32 @@
"es-module-lexer": ["es-module-lexer@2.3.2", "", {}, "sha512-poHGpORABojJJucnV9KbOavETW8lBVnphkW77ER5/BQ5Fz7oXSoCNek7IH3vR5nRjdsEz926ibFYX8KtLQmdyw=="],
+ "esbuild": ["esbuild@0.27.7", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.27.7", "@esbuild/android-arm": "0.27.7", "@esbuild/android-arm64": "0.27.7", "@esbuild/android-x64": "0.27.7", "@esbuild/darwin-arm64": "0.27.7", "@esbuild/darwin-x64": "0.27.7", "@esbuild/freebsd-arm64": "0.27.7", "@esbuild/freebsd-x64": "0.27.7", "@esbuild/linux-arm": "0.27.7", "@esbuild/linux-arm64": "0.27.7", "@esbuild/linux-ia32": "0.27.7", "@esbuild/linux-loong64": "0.27.7", "@esbuild/linux-mips64el": "0.27.7", "@esbuild/linux-ppc64": "0.27.7", "@esbuild/linux-riscv64": "0.27.7", "@esbuild/linux-s390x": "0.27.7", "@esbuild/linux-x64": "0.27.7", "@esbuild/netbsd-arm64": "0.27.7", "@esbuild/netbsd-x64": "0.27.7", "@esbuild/openbsd-arm64": "0.27.7", "@esbuild/openbsd-x64": "0.27.7", "@esbuild/openharmony-arm64": "0.27.7", "@esbuild/sunos-x64": "0.27.7", "@esbuild/win32-arm64": "0.27.7", "@esbuild/win32-ia32": "0.27.7", "@esbuild/win32-x64": "0.27.7" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w=="],
+
"estree-walker": ["estree-walker@3.0.3", "", { "dependencies": { "@types/estree": "^1.0.0" } }, "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g=="],
"expect-type": ["expect-type@1.4.0", "", {}, "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA=="],
+ "fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="],
+
+ "fast-uri": ["fast-uri@3.1.8", "", {}, "sha512-GZMtZUTNRpOVIECoXwLNZS5xUGE+mVNbTB8h/7Rwh2TFWcBQiPzTgyZi05BF9UMZKkLJv8XBRJTlU7zg8+ZfMg=="],
+
"fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="],
"fill-range": ["fill-range@7.1.1", "", { "dependencies": { "to-regex-range": "^5.0.1" } }, "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg=="],
+ "fix-dts-default-cjs-exports": ["fix-dts-default-cjs-exports@1.0.1", "", { "dependencies": { "magic-string": "^0.30.17", "mlly": "^1.7.4", "rollup": "^4.34.8" } }, "sha512-pVIECanWFC61Hzl2+oOCtoJ3F17kglZC/6N94eRWycFgBH35hHx0Li604ZIzhseh97mf2p0cv7vVrOZGoqhlEg=="],
+
+ "fontkit": ["fontkit@2.0.4", "", { "dependencies": { "@swc/helpers": "^0.5.12", "brotli": "^1.3.2", "clone": "^2.1.2", "dfa": "^1.2.0", "fast-deep-equal": "^3.1.3", "restructure": "^3.0.0", "tiny-inflate": "^1.0.3", "unicode-properties": "^1.4.0", "unicode-trie": "^2.0.0" } }, "sha512-syetQadaUEDNdxdugga9CpEYVaQIxOwk7GlwZWWZ19//qW4zE5bknOKeMBDYAASwnpaSHKJITRLMF9m1fp3s6g=="],
+
"fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="],
"graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="],
"html-encoding-sniffer": ["html-encoding-sniffer@7.0.0", "", { "dependencies": { "@exodus/bytes": "^1.15.1" } }, "sha512-UikN5yr7xsCDAq87Or5or0PAlD3HJJOKVzM05az588WnpDJ4Ux7a2A53Qi6gofGg2/EtvF/H4hCi/TXfCW4Y6w=="],
+ "html-to-image": ["html-to-image@1.11.13", "", {}, "sha512-cuOPoI7WApyhBElTTb9oqsawRvZ0rHhaHwghRLlTuffoD1B2aDemlCruLeZrUIIdvG7gs9xeELEPm6PhuASqrg=="],
+
"indent-string": ["indent-string@4.0.0", "", {}, "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg=="],
"is-number": ["is-number@7.0.0", "", {}, "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng=="],
@@ -284,10 +469,14 @@
"jiti": ["jiti@2.7.0", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ=="],
+ "joycon": ["joycon@3.1.1", "", {}, "sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw=="],
+
"js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="],
"jsdom": ["jsdom@30.1.1", "", { "dependencies": { "@asamuzakjp/css-color": "^7.0.0", "@asamuzakjp/dom-selector": "^9.2.1", "@bramus/specificity": "^2.4.2", "@csstools/css-syntax-patches-for-csstree": "^1.1.13", "@exodus/bytes": "^1.15.1", "css-tree": "^3.2.1", "data-urls": "^7.0.0", "decimal.js": "^10.6.0", "html-encoding-sniffer": "^7.0.0", "is-potential-custom-element-name": "^1.0.1", "lru-cache": "^11.5.2", "parse5": "^8.0.1", "saxes": "^6.0.0", "tough-cookie": "^6.0.2", "undici": "^8.10.2", "w3c-xmlserializer": "^6.0.0", "webidl-conversions": "^8.0.1", "whatwg-mimetype": "^5.0.0", "whatwg-url": "^17.1.1", "xml-name-validator": "^5.0.0" }, "peerDependencies": { "canvas": "^3.2.3" }, "optionalPeers": ["canvas"] }, "sha512-FahmoPK5vbPc+jxV1iErMHmAZypCZ942NHF4+qqaWAuvaKKTBZxawnmAtrbGWLU7MtlxfqIP0qw6aSI+aWGtLg=="],
+ "json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="],
+
"lightningcss": ["lightningcss@1.33.0", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.33.0", "lightningcss-darwin-arm64": "1.33.0", "lightningcss-darwin-x64": "1.33.0", "lightningcss-freebsd-x64": "1.33.0", "lightningcss-linux-arm-gnueabihf": "1.33.0", "lightningcss-linux-arm64-gnu": "1.33.0", "lightningcss-linux-arm64-musl": "1.33.0", "lightningcss-linux-x64-gnu": "1.33.0", "lightningcss-linux-x64-musl": "1.33.0", "lightningcss-win32-arm64-msvc": "1.33.0", "lightningcss-win32-x64-msvc": "1.33.0" } }, "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA=="],
"lightningcss-android-arm64": ["lightningcss-android-arm64@1.33.0", "", { "os": "android", "cpu": "arm64" }, "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg=="],
@@ -312,6 +501,12 @@
"lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.33.0", "", { "os": "win32", "cpu": "x64" }, "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA=="],
+ "lilconfig": ["lilconfig@3.1.3", "", {}, "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw=="],
+
+ "lines-and-columns": ["lines-and-columns@1.2.4", "", {}, "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg=="],
+
+ "load-tsconfig": ["load-tsconfig@0.2.5", "", {}, "sha512-IXO6OCs9yg8tMKzfPZ1YmheJbZCiEsnBdcB03l0OcfK9prKnJb96siuHCr5Fl37/yo9DnKU+TLpxzTUspw9shg=="],
+
"lru-cache": ["lru-cache@11.5.3", "", {}, "sha512-U4N8FgzmWxc8k1VH8Kr6lQg18U7Fjvby6wXHVRX/ZZ7IwWbRMgrRbP0Wrb5q5NVinryp4SQampHKdvtecItxUg=="],
"lucide-react": ["lucide-react@1.48.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-R0CIKY/fXiC6y9xRBADgsK+VW2p/pcTJOMhLZf1T+uG+vJUvyUR02nGOgmIIC7MPkiNK4Ox8QDnbqF8rJYWPZQ=="],
@@ -326,22 +521,40 @@
"min-indent": ["min-indent@1.0.1", "", {}, "sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg=="],
+ "mlly": ["mlly@1.8.2", "", { "dependencies": { "acorn": "^8.16.0", "pathe": "^2.0.3", "pkg-types": "^1.3.1", "ufo": "^1.6.3" } }, "sha512-d+ObxMQFmbt10sretNDytwt85VrbkhhUA/JBGm1MPaWJ65Cl4wOgLaB1NYvJSZ0Ef03MMEU/0xpPMXUIQ29UfA=="],
+
+ "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
+
+ "mz": ["mz@2.7.0", "", { "dependencies": { "any-promise": "^1.0.0", "object-assign": "^4.0.1", "thenify-all": "^1.0.0" } }, "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q=="],
+
"nanoid": ["nanoid@3.3.19", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-Y2tUNy4ouw6tq5oDSKeQYGOyhkUBhNOcGV/02KC+6kd9eDGqdZd++mjMiIDilrBYvjEnCYvVtsuHCuP+okSfug=="],
+ "object-assign": ["object-assign@4.1.1", "", {}, "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg=="],
+
"obug": ["obug@2.2.1", "", {}, "sha512-XrsrhT5sybtKI6wakr2SPOlGZWWYbUXZ7a0jT8/QOeAPau+1X/bSegNe5YR75oJmEZQbKningirmGOEJCIk61Q=="],
+ "pako": ["pako@0.2.9", "", {}, "sha512-NUcwaKxUxWrZLpDG+z/xZaCgQITkA/Dv4V/T6bw7VON6l1Xz/VnrBqrYjZQ12TamKHzITTfOEIYUj48y2KXImA=="],
+
"parse5": ["parse5@8.0.1", "", { "dependencies": { "entities": "^8.0.0" } }, "sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw=="],
+ "pathe": ["pathe@2.0.3", "", {}, "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="],
+
"picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="],
"picomatch": ["picomatch@4.0.7", "", {}, "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA=="],
+ "pirates": ["pirates@4.0.7", "", {}, "sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA=="],
+
+ "pkg-types": ["pkg-types@1.3.1", "", { "dependencies": { "confbox": "^0.1.8", "mlly": "^1.7.4", "pathe": "^2.0.1" } }, "sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ=="],
+
"playwright": ["playwright@1.63.0", "", { "dependencies": { "playwright-core": "1.63.0" }, "bin": { "playwright": "cli.js" } }, "sha512-+7ziBLidS4NaNCdt57SUDT+wYmmd5fmiQejUic/kb+YsYSCPyOOE9sebzMjNmQrsnNpDJqd4WHvV/8lfKfUDUg=="],
"playwright-core": ["playwright-core@1.63.0", "", { "bin": { "playwright-core": "cli.js" } }, "sha512-rYCsBF/M5HjUch52bbtVONEFjv6Xu8sm8h72dNlR5bzIE1fvC/bxgspzkjSfU+MweEMmPM8KJebG6nnyxo5mCg=="],
"postcss": ["postcss@8.5.28", "", { "dependencies": { "nanoid": "^3.3.18", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A=="],
+ "postcss-load-config": ["postcss-load-config@6.0.1", "", { "dependencies": { "lilconfig": "^3.1.1" }, "peerDependencies": { "jiti": ">=1.21.0", "postcss": ">=8.0.9", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["jiti", "postcss", "tsx", "yaml"] }, "sha512-oPtTM4oerL+UXmx+93ytZVN82RrlY/wPUV8IeDxFrzIjXOLF1pN+EmKPLbubvKHT2HC20xXsCAH2Z+CKV6Oz/g=="],
+
"pretty-format": ["pretty-format@27.5.1", "", { "dependencies": { "ansi-regex": "^5.0.1", "ansi-styles": "^5.0.0", "react-is": "^17.0.1" } }, "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ=="],
"punycode": ["punycode@2.3.1", "", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="],
@@ -352,28 +565,46 @@
"react-is": ["react-is@17.0.2", "", {}, "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w=="],
+ "readdirp": ["readdirp@4.1.2", "", {}, "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg=="],
+
"redent": ["redent@3.0.0", "", { "dependencies": { "indent-string": "^4.0.0", "strip-indent": "^3.0.0" } }, "sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg=="],
"require-from-string": ["require-from-string@2.0.2", "", {}, "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw=="],
+ "resolve-from": ["resolve-from@5.0.0", "", {}, "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw=="],
+
+ "restructure": ["restructure@3.0.2", "", {}, "sha512-gSfoiOEA0VPE6Tukkrr7I0RBdE0s7H1eFCDBk05l1KIQT1UIKNc5JZy6jdyW6eYH3aR3g5b3PuL77rq0hvwtAw=="],
+
"rolldown": ["rolldown@1.2.11", "", { "dependencies": { "@oxc-project/types": "=0.151.0", "@rolldown/pluginutils": "^1.0.0" }, "optionalDependencies": { "@rolldown/binding-android-arm-eabi": "1.2.11", "@rolldown/binding-android-arm64": "1.2.11", "@rolldown/binding-darwin-arm64": "1.2.11", "@rolldown/binding-darwin-x64": "1.2.11", "@rolldown/binding-freebsd-x64": "1.2.11", "@rolldown/binding-linux-arm-gnueabihf": "1.2.11", "@rolldown/binding-linux-arm64-gnu": "1.2.11", "@rolldown/binding-linux-arm64-musl": "1.2.11", "@rolldown/binding-linux-ppc64-gnu": "1.2.11", "@rolldown/binding-linux-s390x-gnu": "1.2.11", "@rolldown/binding-linux-x64-gnu": "1.2.11", "@rolldown/binding-linux-x64-musl": "1.2.11", "@rolldown/binding-openharmony-arm64": "1.2.11", "@rolldown/binding-win32-arm64-msvc": "1.2.11", "@rolldown/binding-win32-x64-msvc": "1.2.11" }, "bin": { "rolldown": "./bin/cli.mjs" } }, "sha512-qpSwIyz0jHQq5qXBTNxFmE6664rJ7O+4TvPFOiOaBSrz8IOHc1koKKSqTM2H6u1UG1+TveuC6vaDHKXFOvb1Kw=="],
+ "rollup": ["rollup@4.63.5", "", { "dependencies": { "@types/estree": "1.0.9" }, "optionalDependencies": { "@napi-rs/lzma-linux-x64-gnu": "1.5.1", "@rollup/rollup-android-arm-eabi": "4.63.5", "@rollup/rollup-android-arm64": "4.63.5", "@rollup/rollup-darwin-arm64": "4.63.5", "@rollup/rollup-darwin-x64": "4.63.5", "@rollup/rollup-freebsd-arm64": "4.63.5", "@rollup/rollup-freebsd-x64": "4.63.5", "@rollup/rollup-linux-arm-gnueabihf": "4.63.5", "@rollup/rollup-linux-arm-musleabihf": "4.63.5", "@rollup/rollup-linux-arm64-gnu": "4.63.5", "@rollup/rollup-linux-arm64-musl": "4.63.5", "@rollup/rollup-linux-loong64-gnu": "4.63.5", "@rollup/rollup-linux-loong64-musl": "4.63.5", "@rollup/rollup-linux-ppc64-gnu": "4.63.5", "@rollup/rollup-linux-ppc64-musl": "4.63.5", "@rollup/rollup-linux-riscv64-gnu": "4.63.5", "@rollup/rollup-linux-riscv64-musl": "4.63.5", "@rollup/rollup-linux-s390x-gnu": "4.63.5", "@rollup/rollup-linux-x64-gnu": "4.63.5", "@rollup/rollup-linux-x64-musl": "4.63.5", "@rollup/rollup-openbsd-x64": "4.63.5", "@rollup/rollup-openharmony-arm64": "4.63.5", "@rollup/rollup-win32-arm64-msvc": "4.63.5", "@rollup/rollup-win32-ia32-msvc": "4.63.5", "@rollup/rollup-win32-x64-gnu": "4.63.5", "@rollup/rollup-win32-x64-msvc": "4.63.5", "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-KRWwmNLlPw5M7HcdYfm15oBv9n9LPtjzpzCIxS/phwqvPyxHSoKX6Y2YU3pxSPfy0CLquVgsx/j/hBi6OvH1Nw=="],
+
"saxes": ["saxes@6.0.0", "", { "dependencies": { "xmlchars": "^2.2.0" } }, "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA=="],
"scheduler": ["scheduler@0.28.0", "", {}, "sha512-juorfCmIkIw8tT+p5BXSm6PJjQF/ycEYmKyzURCIt/RaZIhL+PulbQ9Yu2z1HdOJDdqDTlxA1+xKBmHXJsczAw=="],
"simple-icons": ["simple-icons@16.33.0", "", {}, "sha512-z14u6A8ngAg9mgERRsNO8YkjIMefehIpBk7A8Dz3Gd91945qFP19MLPSHgV4z8P5u/MRYd5e07OTzaDoB4d6qA=="],
+ "source-map": ["source-map@0.7.6", "", {}, "sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ=="],
+
"source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="],
"std-env": ["std-env@4.2.0", "", {}, "sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw=="],
"strip-indent": ["strip-indent@3.0.0", "", { "dependencies": { "min-indent": "^1.0.0" } }, "sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ=="],
+ "sucrase": ["sucrase@3.35.1", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.2", "commander": "^4.0.0", "lines-and-columns": "^1.1.6", "mz": "^2.7.0", "pirates": "^4.0.1", "tinyglobby": "^0.2.11", "ts-interface-checker": "^0.1.9" }, "bin": { "sucrase": "bin/sucrase", "sucrase-node": "bin/sucrase-node" } }, "sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw=="],
+
"tailwindcss": ["tailwindcss@4.3.3", "", {}, "sha512-gOhV3P7ufE62QDGg1zVaTgCR+EtPv92k2nIhVcVKcLmxT1sUBsQGhnZj175j+MqRt4zLF7ic+sCYjfhxMxj7YQ=="],
"tapable": ["tapable@2.3.3", "", {}, "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A=="],
+ "thenify": ["thenify@3.3.1", "", { "dependencies": { "any-promise": "^1.0.0" } }, "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw=="],
+
+ "thenify-all": ["thenify-all@1.6.0", "", { "dependencies": { "thenify": ">= 3.1.0 < 4" } }, "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA=="],
+
+ "tiny-inflate": ["tiny-inflate@1.0.3", "", {}, "sha512-pkY1fj1cKHb2seWDy0B16HeWyczlJA9/WW3u3c4z/NiWDsO3DOU5D7nhTLE9CF0yXv/QZFY7sEJmj24dK+Rrqw=="],
+
"tinybench": ["tinybench@6.2.0", "", {}, "sha512-78U2TlB2CnVenajOFzf3BKSm0J6oz5L0NV7g32LCPccvYc0lbWvys4d3uUUCS2B1N8PAf2+aekR8i1KbC3HO7Q=="],
"tinyexec": ["tinyexec@1.3.1", "", {}, "sha512-GCvB3aoys96IuDFBMcTB46JOR6mdMtAToqwiW8JlWhsoh1mhHi/xn9ss/Dg7N555GiJyEt2qzoG/NHCwM6h1EA=="],
@@ -390,12 +621,26 @@
"tr46": ["tr46@6.0.0", "", { "dependencies": { "punycode": "^2.3.1" } }, "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw=="],
+ "tree-kill": ["tree-kill@1.2.2", "", { "bin": { "tree-kill": "cli.js" } }, "sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A=="],
+
+ "ts-interface-checker": ["ts-interface-checker@0.1.13", "", {}, "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA=="],
+
+ "tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
+
+ "tsup": ["tsup@8.5.1", "", { "dependencies": { "bundle-require": "^5.1.0", "cac": "^6.7.14", "chokidar": "^4.0.3", "consola": "^3.4.0", "debug": "^4.4.0", "esbuild": "^0.27.0", "fix-dts-default-cjs-exports": "^1.0.0", "joycon": "^3.1.1", "picocolors": "^1.1.1", "postcss-load-config": "^6.0.1", "resolve-from": "^5.0.0", "rollup": "^4.34.8", "source-map": "^0.7.6", "sucrase": "^3.35.0", "tinyexec": "^0.3.2", "tinyglobby": "^0.2.11", "tree-kill": "^1.2.2" }, "peerDependencies": { "@microsoft/api-extractor": "^7.36.0", "@swc/core": "^1", "postcss": "^8.4.12", "typescript": ">=4.5.0" }, "optionalPeers": ["@microsoft/api-extractor", "@swc/core", "postcss", "typescript"], "bin": { "tsup": "dist/cli-default.js", "tsup-node": "dist/cli-node.js" } }, "sha512-xtgkqwdhpKWr3tKPmCkvYmS9xnQK3m3XgxZHwSUjvfTjp7YfXe5tT3GgWi0F2N+ZSMsOeWeZFh7ZZFg5iPhing=="],
+
"typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
+ "ufo": ["ufo@1.6.4", "", {}, "sha512-JFNbkD1Svwe0KvGi8GOeLcP4kAWQ609twvCdcHxq1oSL8svv39ZuSvajcD8B+5D0eL4+s1Is2D/O6KN3qcTeRA=="],
+
"undici": ["undici@8.11.2", "", {}, "sha512-u4UB2/IrKdU6lFxumHmmo1a3fCQO5tzQllRorfoRS63txhrB7xTpSn1PftwC4qEHkOaqP95fCWW4lJzwErwzhQ=="],
"undici-types": ["undici-types@8.9.0", "", {}, "sha512-KTDyRTYX8sWmKXAikPHHSyc63CRPETMctyjKFupcC6OBLXT3xsN0e9aF7m+mIXutFWpUXuedtowG7iLOzp0kQg=="],
+ "unicode-properties": ["unicode-properties@1.4.1", "", { "dependencies": { "base64-js": "^1.3.0", "unicode-trie": "^2.0.0" } }, "sha512-CLjCCLQ6UuMxWnbIylkisbRj31qxHPAurvena/0iwSVbQ2G1VY5/HjV0IRabOEbDHlzZlRdCrD4NhB0JtU40Pg=="],
+
+ "unicode-trie": ["unicode-trie@2.0.0", "", { "dependencies": { "pako": "^0.2.5", "tiny-inflate": "^1.0.0" } }, "sha512-x7bc76x0bm4prf1VLg79uhAzKw8DVboClSN5VxJuQ+LKDOVEW9CdH+VY7SP+vX7xCYQqzzgQpFqz15zeLvAtZQ=="],
+
"vite": ["vite@8.3.1", "", { "dependencies": { "lightningcss": "^1.33.0", "picomatch": "^4.0.7", "postcss": "^8.5.28", "rolldown": "~1.2.9", "tinyglobby": "^0.2.17" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "@vitejs/devtools": "^0.7.1", "esbuild": "^0.27.0 || ^0.28.0", "jiti": ">=1.21.0", "less": "^4.0.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "@vitejs/devtools", "esbuild", "jiti", "less", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-/bvH9E9tmCXRGp2uXY3WbOldqpTwFkbha/8ANaEQ6VkxhH60KyqLwgZq6lG2y+4uT55x9+9eUHMpQ7uGnOCKjA=="],
"vite-plugin-singlefile": ["vite-plugin-singlefile@2.3.3", "", { "dependencies": { "micromatch": "^4.0.8" }, "peerDependencies": { "rollup": "^4.59.0", "vite": "^5.4.21 || ^6.0.0 || ^7.0.0 || ^8.0.0" }, "optionalPeers": ["rollup"] }, "sha512-XVnGH0QzbOa8fxRSsHdCarVN1BSBXNi7uLMQYlrGRN5apdHkk62XQWRJhVever0lnfuyBkwn+kvVChdm/OoOUg=="],
@@ -416,6 +661,8 @@
"xmlchars": ["xmlchars@2.2.0", "", {}, "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw=="],
+ "zod": ["zod@4.6.5", "", {}, "sha512-v5l/aFXZQeai4awLbOpSoHecE9UiMrnfx75tEXLjNonXVARxQ5mOeipTjROUchszUNCqnE+hqAMujRsRHsut2Q=="],
+
"@tailwindcss/node/lightningcss": ["lightningcss@1.32.0", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.32.0", "lightningcss-darwin-arm64": "1.32.0", "lightningcss-darwin-x64": "1.32.0", "lightningcss-freebsd-x64": "1.32.0", "lightningcss-linux-arm-gnueabihf": "1.32.0", "lightningcss-linux-arm64-gnu": "1.32.0", "lightningcss-linux-arm64-musl": "1.32.0", "lightningcss-linux-x64-gnu": "1.32.0", "lightningcss-linux-x64-musl": "1.32.0", "lightningcss-win32-arm64-msvc": "1.32.0", "lightningcss-win32-x64-msvc": "1.32.0" } }, "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ=="],
"@tailwindcss/node/magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="],
@@ -438,8 +685,12 @@
"data-urls/whatwg-url": ["whatwg-url@16.0.1", "", { "dependencies": { "@exodus/bytes": "^1.11.0", "tr46": "^6.0.0", "webidl-conversions": "^8.0.1" } }, "sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw=="],
+ "fix-dts-default-cjs-exports/magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="],
+
"micromatch/picomatch": ["picomatch@2.3.2", "", {}, "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA=="],
+ "tsup/tinyexec": ["tinyexec@0.3.2", "", {}, "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA=="],
+
"@tailwindcss/node/lightningcss/lightningcss-android-arm64": ["lightningcss-android-arm64@1.32.0", "", { "os": "android", "cpu": "arm64" }, "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg=="],
"@tailwindcss/node/lightningcss/lightningcss-darwin-arm64": ["lightningcss-darwin-arm64@1.32.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ=="],
diff --git a/package.json b/package.json
index 927d06a..7f1ac04 100644
--- a/package.json
+++ b/package.json
@@ -12,7 +12,7 @@
"scripts": {
"test": "bun run --filter './packages/*' test",
"typecheck": "bun run --filter './packages/*' typecheck",
- "build": "bun run --filter './packages/*' build"
+ "build": "bun run --filter @stackmap/viewer build && bun run --filter @hyamero/stackmap build"
},
"devDependencies": {
"@types/node": "^26.6.3",
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/e2e/offline.spec.ts b/packages/cli/e2e/offline.spec.ts
new file mode 100644
index 0000000..0fa99cd
--- /dev/null
+++ b/packages/cli/e2e/offline.spec.ts
@@ -0,0 +1,34 @@
+import { execFileSync } from 'node:child_process';
+import { mkdtempSync, writeFileSync } from 'node:fs';
+import { tmpdir } from 'node:os';
+import { join } from 'node:path';
+import { pathToFileURL } from 'node:url';
+import { expect, test } from '@playwright/test';
+import { groupedPlatform } from '@stackmap/core/samples';
+
+test('a delivered file renders from file:// with no network', async ({ page }) => {
+ const dir = mkdtempSync(join(tmpdir(), 'stackmap-e2e-'));
+ const input = join(dir, 'platform.json');
+ writeFileSync(input, JSON.stringify(groupedPlatform));
+ execFileSync('node', [new URL('../dist/cli.js', import.meta.url).pathname, 'deliver', input]);
+
+ const requests: string[] = [];
+ await page.route('**/*', (route) => {
+ const url = route.request().url();
+ if (url.startsWith('file:') || url.startsWith('data:')) return route.continue();
+ requests.push(url);
+ return route.abort();
+ });
+ await page.goto(pathToFileURL(join(dir, 'platform.html')).href);
+ await expect(page.locator('.sm-card')).toHaveCount(groupedPlatform.nodes.length);
+ await expect(page.locator('.sm-frame')).toHaveCount(3);
+ await expect(page).toHaveTitle('Platform · stackmap');
+ await expect(page.getByRole('heading', { level: 1 })).toHaveText('Platform');
+ expect(await page.evaluate(() => document.fonts.check("500 13.5px 'Geist'"))).toBe(true);
+ expect(requests).toEqual([]);
+});
+
+test('the bare template says no diagram is embedded', async ({ page }) => {
+ await page.goto(pathToFileURL(new URL('../dist/viewer.html', import.meta.url).pathname).href);
+ await expect(page.getByRole('status')).toHaveText(/No diagram embedded/);
+});
diff --git a/packages/cli/e2e/serve.spec.ts b/packages/cli/e2e/serve.spec.ts
new file mode 100644
index 0000000..1934a07
--- /dev/null
+++ b/packages/cli/e2e/serve.spec.ts
@@ -0,0 +1,94 @@
+import { spawn, type ChildProcess } from 'node:child_process';
+import { mkdtempSync, writeFileSync } from 'node:fs';
+import { tmpdir } from 'node:os';
+import { join } from 'node:path';
+import { expect, test } from '@playwright/test';
+import { commerceApi } from '@stackmap/core/samples';
+
+let proc: ChildProcess;
+let url: string;
+let file: string;
+const write = (content: unknown) => writeFileSync(file, typeof content === 'string' ? content : JSON.stringify(content));
+
+async function start(initial: unknown, port = '0') {
+ if (initial !== undefined) {
+ file = join(mkdtempSync(join(tmpdir(), 'stackmap-live-')), 'd.json');
+ write(initial);
+ }
+ proc = spawn('node', [new URL('../dist/cli.js', import.meta.url).pathname, 'serve', file, '--port', port]);
+ url = await new Promise((resolve, reject) => {
+ proc.stdout!.on('data', (b: Buffer) => {
+ const m = /serving (http:\/\/127\.0\.0\.1:\d+)/.exec(b.toString());
+ if (m) resolve(m[1]!);
+ });
+ proc.once('exit', (code) => reject(new Error(`serve exited ${code}`)));
+ });
+}
+test.afterEach(() => {
+ proc.kill('SIGINT');
+});
+
+const viewportOf = (page: import('@playwright/test').Page) =>
+ page.evaluate(() => {
+ const m = new DOMMatrix(getComputedStyle(document.querySelector('.sm-viewport')!).transform);
+ return { x: m.e, y: m.f, k: m.a };
+ });
+
+test('edits reload the page; a broken edit keeps the last good version and says why', async ({ page }) => {
+ await start(commerceApi);
+ await page.goto(url);
+ await expect(page.getByRole('heading', { level: 1 })).toHaveText('Commerce API');
+
+ write('{ "kind": "architecture", ');
+ const toast = page.getByRole('status').filter({ hasText: 'error' });
+ await expect(toast).toContainText('showing the last good version');
+ await expect(toast).toContainText('schema/invalid-json');
+ await expect(page.getByRole('heading', { level: 1 })).toHaveText('Commerce API');
+
+ write({ ...commerceApi, title: 'Commerce v2' });
+ await expect(page.getByRole('heading', { level: 1 })).toHaveText('Commerce v2');
+ await expect(toast).toHaveCount(0);
+});
+
+test('the camera, selection and theme survive a live reload', async ({ page }) => {
+ await start(commerceApi);
+ await page.goto(url);
+ await expect(page.locator('.sm-card')).toHaveCount(6);
+ await page.getByRole('button', { name: 'Zoom in' }).click();
+ await page.getByRole('button', { name: 'Zoom in' }).click();
+ await page.locator('.sm-card[data-card-id="orders"]').click();
+ await page.getByRole('button', { name: /Switch to (dark|light) theme/ }).click();
+ const theme = await page.evaluate(() => document.documentElement.dataset.theme);
+ await page.waitForTimeout(300);
+ const before = await viewportOf(page);
+
+ write({ ...commerceApi, title: 'Commerce v3' });
+ await expect(page.getByRole('heading', { level: 1 })).toHaveText('Commerce v3');
+ await page.waitForTimeout(300);
+ expect(await viewportOf(page)).toEqual(before);
+ await expect(page.locator('.sm-card[data-card-id="orders"]')).toHaveAttribute('aria-pressed', 'true');
+ expect(await page.evaluate(() => document.documentElement.dataset.theme)).toBe(theme);
+});
+
+test('serving an invalid file shows the waiting state, then the diagram once fixed', async ({ page }) => {
+ await start({ kind: 'architecture' });
+ await page.goto(url);
+ await expect(page.getByText('Waiting for a valid diagram…')).toBeVisible();
+ await expect(page.getByRole('status').filter({ hasText: 'error' })).toContainText('waiting for a valid diagram');
+ write(commerceApi);
+ await expect(page.getByRole('heading', { level: 1 })).toHaveText('Commerce API');
+});
+
+test('a page open across a serve restart shows it is disconnected, then picks up the new build', async ({ page }) => {
+ await start(commerceApi);
+ await page.goto(url);
+ await expect(page.getByRole('heading', { level: 1 })).toHaveText('Commerce API');
+ const port = new URL(url).port;
+ const stopped = new Promise((resolve) => proc.once('exit', resolve));
+ proc.kill('SIGINT');
+ await stopped;
+ await expect(page.getByText(/Disconnected from/)).toBeVisible();
+ write({ ...commerceApi, title: 'After restart' });
+ await start(undefined, port);
+ await expect(page.getByRole('heading', { level: 1 })).toHaveText('After restart', { timeout: 10_000 });
+});
diff --git a/packages/cli/package.json b/packages/cli/package.json
new file mode 100644
index 0000000..a1fbb1d
--- /dev/null
+++ b/packages/cli/package.json
@@ -0,0 +1,60 @@
+{
+ "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"
+ },
+ "files": [
+ "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",
+ "prepack": "bun run build && node scripts/prepack.mjs"
+ },
+ "dependencies": {
+ "elkjs": "^0.12.0"
+ },
+ "devDependencies": {
+ "@playwright/test": "^1.63.0",
+ "@stackmap/core": "workspace:*",
+ "@stackmap/layout": "workspace:*",
+ "@stackmap/schema": "workspace:*",
+ "@stackmap/viewer": "workspace:*",
+ "tsup": "^8.5.1",
+ "zod": "^4.6.5"
+ }
+}
diff --git a/packages/cli/playwright.config.ts b/packages/cli/playwright.config.ts
new file mode 100644
index 0000000..5fec51c
--- /dev/null
+++ b/packages/cli/playwright.config.ts
@@ -0,0 +1,4 @@
+import { defineConfig } from '@playwright/test';
+
+// No web server: the delivered file is opened from disk, as a user would.
+export default defineConfig({ testDir: 'e2e', use: { viewport: { width: 1440, height: 960 } } });
diff --git a/packages/cli/scripts/copy-template.mjs b/packages/cli/scripts/copy-template.mjs
new file mode 100644
index 0000000..4294605
--- /dev/null
+++ b/packages/cli/scripts/copy-template.mjs
@@ -0,0 +1,15 @@
+// Copies the built viewer into dist/viewer.html, refusing a missing or dev-server build.
+import { copyFileSync, existsSync, readFileSync } from 'node:fs';
+
+const src = new URL('../../viewer/dist/index.html', import.meta.url);
+const fail = (msg) => {
+ console.error(`✗ ${msg}; run \`bun run build\` from the repo root (it builds the viewer first)`);
+ process.exit(1);
+};
+if (!existsSync(src)) fail('viewer build missing');
+const html = readFileSync(src, 'utf8');
+if (!html.includes('')) fail('viewer build has no empty data block');
+if (/';
+
+const escapeHtml = (s: string) => s.replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"');
+
+// `<` is the only character that can end a script element or start `',
+ '# stackmap diagram schema',
+ '',
+ `${root.description ?? ''} Machine-readable: [stackmap.schema.json](stackmap.schema.json). Objects are strict: unknown keys are errors. Ids (and references to them) use \`a-z\`, \`0-9\`, \`-\` and \`_\`, starting with a letter or digit.`,
+ '',
+ ...sections.flatMap((s) => [s, '']),
+ '## Brand slugs',
+ '',
+ 'Values allowed in `nodes[].card.brand` (Simple Icons, CC0). Anything else is a warning and the type icon is shown.',
+ '',
+ BRAND_SLUGS.map((b) => `\`${b}\``).join(' · '),
+ '',
+ ].join('\n');
+}
diff --git a/packages/schema/src/schema.ts b/packages/schema/src/schema.ts
new file mode 100644
index 0000000..69db2a5
--- /dev/null
+++ b/packages/schema/src/schema.ts
@@ -0,0 +1,111 @@
+import { z } from 'zod';
+import { NODE_TYPES } from '@stackmap/core';
+
+// Mirrors the hand-written types in @stackmap/core (a type test keeps them identical). Objects are strict
+// so a misspelt key is reported instead of silently dropped.
+
+export const ID_PATTERN = /^[a-z0-9][a-z0-9_-]*$/;
+// Size caps keep validation and layout fast and the diagram readable; beyond them, split into views.
+export const LIMITS = { rows: 6, stats: 3, evidence: 8, edgeLabel: 24, nodes: 500, edges: 2000, groups: 200, views: 50 } as const;
+export const VISIBLE_TEXT = /\S/;
+
+const id = z.string().regex(ID_PATTERN);
+const text = z.string().regex(VISIBLE_TEXT);
+// A regex rather than z.url(): it carries into the JSON Schema, and only http(s) may become a link.
+export const HTTP_URL = /^https?:\/\/\S+$/;
+/** Repo-relative: no leading "/", no ".." segment, no backslash, no scheme; evidence links join it onto source.url. */
+export const REPO_PATH = /^(?!\/)(?!.*(?:^|\/)\.\.(?:\/|$))(?!.*\\)(?!.*:\/\/).*\S.*$/;
+const httpUrl = z.string().regex(HTTP_URL);
+
+const FooterItem = z.strictObject({
+ text,
+ icon: z.enum(['region', 'secure', 'members']).optional(),
+});
+
+const Card = z.strictObject({
+ title: text.meta({ description: 'Card title. Must fit the card; `stackmap validate` reports the character budget.' }),
+ subtitle: text.optional(),
+ brand: z.string().optional().meta({ description: 'simple-icons slug shown in the icon tile, e.g. "postgresql".' }),
+ rows: z
+ .array(z.strictObject({ label: text, value: text, mono: z.boolean().optional() }))
+ .max(LIMITS.rows)
+ .optional()
+ .meta({ description: 'Key/value rows. `mono` renders the value in Geist Mono (ports, IPs).' }),
+ stats: z
+ .array(z.strictObject({ value: text, label: text }))
+ .max(LIMITS.stats)
+ .optional()
+ .meta({ description: 'Stat tiles, e.g. replica counts.' }),
+ statsNote: text.optional().meta({ description: 'Line under the stat tiles; only shown with stats.' }),
+ footer: z.strictObject({ left: FooterItem.optional(), right: FooterItem.optional() }).optional(),
+ cta: z
+ .strictObject({
+ label: text,
+ href: httpUrl.optional(),
+ })
+ .optional(),
+});
+
+const Evidence = z.strictObject({
+ file: z.string().regex(REPO_PATH).meta({ description: 'Repo-relative path, e.g. "src/orders/api.ts".' }),
+ line: z.number().int().positive().optional(),
+ note: text.optional(),
+});
+
+const Node = z.strictObject({
+ id,
+ type: z.enum(NODE_TYPES).meta({ description: 'Sets the card color. Never color by brand.' }),
+ group: id.optional().meta({ description: 'Id of the group the node sits in.' }),
+ card: Card,
+ evidence: z
+ .array(Evidence)
+ .max(LIMITS.evidence)
+ .optional()
+ .meta({ description: 'Source locations backing this node; listed in the inspector.' }),
+});
+
+const Edge = z.strictObject({
+ id,
+ from: id.meta({ description: 'Source node id.' }),
+ to: id.meta({ description: 'Target node id.' }),
+ label: text.max(LIMITS.edgeLabel).optional().meta({ description: 'Short label; use sparingly.' }),
+ kind: z.enum(['sync', 'async']).optional().meta({ description: 'async edges render dashed.' }),
+});
+
+const Group = z.strictObject({
+ id,
+ label: text.meta({ description: 'Shown small-caps above the frame.' }),
+ parent: id.optional().meta({ description: 'Id of the enclosing group, for nesting.' }),
+});
+
+const View = z.strictObject({
+ id,
+ label: text,
+ caption: text.optional(),
+ nodes: z.array(id).meta({ description: 'Node ids this guided view focuses; the rest are dimmed.' }),
+});
+
+export const DiagramDraftSchema = z
+ .strictObject({
+ $schema: z.string().optional().meta({ description: `Optional; set it to ${'`'}https://unpkg.com/@hyamero/stackmap@0.1.0/dist/stackmap.schema.json${'`'} for editor completion.` }),
+ kind: z.enum(['architecture', 'dataflow']),
+ title: text,
+ subtitle: text.optional(),
+ source: z
+ .strictObject({ url: httpUrl })
+ .optional()
+ .meta({ description: 'Base URL for evidence links: `/#L`.' }),
+ direction: z.enum(['RIGHT', 'DOWN']).optional().meta({ description: 'Layout flow. Default RIGHT; prefer DOWN for tiered/grouped diagrams.' }),
+ groups: z.array(Group).max(LIMITS.groups).optional(),
+ nodes: z.array(Node).min(1).max(LIMITS.nodes),
+ edges: z.array(Edge).max(LIMITS.edges),
+ views: z.array(View).max(LIMITS.views).optional(),
+ })
+ .meta({ title: 'stackmap diagram', description: 'Agent-authored diagram. Layout is computed by stackmap; never give coordinates.' });
+
+/** Served from the published CLI package, so it is pinned to the same version as the validator. */
+export const SCHEMA_ID = 'https://unpkg.com/@hyamero/stackmap@0.1.0/dist/stackmap.schema.json';
+
+export function buildJsonSchema(): Record {
+ return { ...z.toJSONSchema(DiagramDraftSchema, { target: 'draft-2020-12' }), $id: SCHEMA_ID };
+}
diff --git a/packages/schema/src/util.ts b/packages/schema/src/util.ts
new file mode 100644
index 0000000..3504154
--- /dev/null
+++ b/packages/schema/src/util.ts
@@ -0,0 +1,34 @@
+/** RFC 6901 JSON pointer. */
+export const pointer = (path: readonly PropertyKey[]): string =>
+ path.map((p) => `/${String(p).replaceAll('~', '~0').replaceAll('/', '~1')}`).join('');
+
+export function levenshtein(a: string, b: string): number {
+ let prev = Array.from({ length: b.length + 1 }, (_, i) => i);
+ for (let i = 1; i <= a.length; i++) {
+ const cur = [i];
+ for (let j = 1; j <= b.length; j++) {
+ cur[j] = Math.min(prev[j]! + 1, cur[j - 1]! + 1, prev[j - 1]! + (a[i - 1] === b[j - 1] ? 0 : 1));
+ }
+ prev = cur;
+ }
+ return prev[b.length]!;
+}
+
+/** Up to `n` candidates closest to `target`, nearest first, ties in candidate order; only plausible matches. */
+export function closest(target: string, candidates: Iterable, n = 3): string[] {
+ // Short keys are one or two edits from almost anything (`id` → `kind`), so they only match on one.
+ const max = target.length <= 4 ? 1 : Math.max(2, Math.floor(target.length / 3));
+ return [...new Set(candidates)]
+ .map((c, i) => ({ c, i, d: levenshtein(target.toLowerCase(), c.toLowerCase()) }))
+ .filter((x) => x.d <= max)
+ .sort((a, b) => a.d - b.d || a.i - b.i)
+ .slice(0, n)
+ .map((x) => x.c);
+}
+
+/** A lowercase id derived from arbitrary text, for "use this id instead" fixes. */
+export const toId = (s: string): string =>
+ s
+ .toLowerCase()
+ .replace(/[^a-z0-9_-]+/g, '-')
+ .replace(/^[-_]+|-+$/g, '') || 'id';
diff --git a/packages/schema/src/validate.ts b/packages/schema/src/validate.ts
new file mode 100644
index 0000000..90ff0e3
--- /dev/null
+++ b/packages/schema/src/validate.ts
@@ -0,0 +1,20 @@
+import type { DiagramDraft } from '@stackmap/core';
+import type { ValidationResult } from './diagnostics';
+import { cardFitDiagnostics } from './rules/card-fit';
+import { refsDiagnostics } from './rules/refs';
+import { schemaDiagnostics } from './rules/schema';
+import { semanticsDiagnostics } from './rules/semantics';
+import { DiagramDraftSchema } from './schema';
+
+/**
+ * Schema first: the other families need a typed diagram, so schema errors are returned alone. Otherwise
+ * refs, semantics and card-fit all run, in that order, each in document order.
+ */
+export function validateDiagram(input: unknown): ValidationResult {
+ const parsed = DiagramDraftSchema.safeParse(input);
+ if (!parsed.success) return { ok: false, diagnostics: schemaDiagnostics(parsed.error.issues, input) };
+ const { $schema: _, ...diagram } = parsed.data;
+ const d: DiagramDraft = diagram;
+ const diagnostics = [...refsDiagnostics(d), ...semanticsDiagnostics(d), ...cardFitDiagnostics(d)];
+ return { ok: !diagnostics.some((x) => x.severity === 'error'), diagram: d, diagnostics };
+}
diff --git a/packages/schema/stackmap.schema.json b/packages/schema/stackmap.schema.json
new file mode 100644
index 0000000..9d77861
--- /dev/null
+++ b/packages/schema/stackmap.schema.json
@@ -0,0 +1,372 @@
+{
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
+ "type": "object",
+ "properties": {
+ "$schema": {
+ "description": "Optional; set it to `https://unpkg.com/@hyamero/stackmap@0.1.0/dist/stackmap.schema.json` for editor completion.",
+ "type": "string"
+ },
+ "kind": {
+ "type": "string",
+ "enum": [
+ "architecture",
+ "dataflow"
+ ]
+ },
+ "title": {
+ "type": "string",
+ "pattern": "\\S"
+ },
+ "subtitle": {
+ "type": "string",
+ "pattern": "\\S"
+ },
+ "source": {
+ "description": "Base URL for evidence links: `/#L`.",
+ "type": "object",
+ "properties": {
+ "url": {
+ "type": "string",
+ "pattern": "^https?:\\/\\/\\S+$"
+ }
+ },
+ "required": [
+ "url"
+ ],
+ "additionalProperties": false
+ },
+ "direction": {
+ "description": "Layout flow. Default RIGHT; prefer DOWN for tiered/grouped diagrams.",
+ "type": "string",
+ "enum": [
+ "RIGHT",
+ "DOWN"
+ ]
+ },
+ "groups": {
+ "maxItems": 200,
+ "type": "array",
+ "items": {
+ "type": "object",
+ "properties": {
+ "id": {
+ "type": "string",
+ "pattern": "^[a-z0-9][a-z0-9_-]*$"
+ },
+ "label": {
+ "type": "string",
+ "pattern": "\\S",
+ "description": "Shown small-caps above the frame."
+ },
+ "parent": {
+ "description": "Id of the enclosing group, for nesting.",
+ "type": "string",
+ "pattern": "^[a-z0-9][a-z0-9_-]*$"
+ }
+ },
+ "required": [
+ "id",
+ "label"
+ ],
+ "additionalProperties": false
+ }
+ },
+ "nodes": {
+ "minItems": 1,
+ "maxItems": 500,
+ "type": "array",
+ "items": {
+ "type": "object",
+ "properties": {
+ "id": {
+ "type": "string",
+ "pattern": "^[a-z0-9][a-z0-9_-]*$"
+ },
+ "type": {
+ "type": "string",
+ "enum": [
+ "client",
+ "service",
+ "gateway",
+ "database",
+ "cache",
+ "queue",
+ "storage",
+ "external",
+ "security"
+ ],
+ "description": "Sets the card color. Never color by brand."
+ },
+ "group": {
+ "description": "Id of the group the node sits in.",
+ "type": "string",
+ "pattern": "^[a-z0-9][a-z0-9_-]*$"
+ },
+ "card": {
+ "type": "object",
+ "properties": {
+ "title": {
+ "type": "string",
+ "pattern": "\\S",
+ "description": "Card title. Must fit the card; `stackmap validate` reports the character budget."
+ },
+ "subtitle": {
+ "type": "string",
+ "pattern": "\\S"
+ },
+ "brand": {
+ "description": "simple-icons slug shown in the icon tile, e.g. \"postgresql\".",
+ "type": "string"
+ },
+ "rows": {
+ "description": "Key/value rows. `mono` renders the value in Geist Mono (ports, IPs).",
+ "maxItems": 6,
+ "type": "array",
+ "items": {
+ "type": "object",
+ "properties": {
+ "label": {
+ "type": "string",
+ "pattern": "\\S"
+ },
+ "value": {
+ "type": "string",
+ "pattern": "\\S"
+ },
+ "mono": {
+ "type": "boolean"
+ }
+ },
+ "required": [
+ "label",
+ "value"
+ ],
+ "additionalProperties": false
+ }
+ },
+ "stats": {
+ "description": "Stat tiles, e.g. replica counts.",
+ "maxItems": 3,
+ "type": "array",
+ "items": {
+ "type": "object",
+ "properties": {
+ "value": {
+ "type": "string",
+ "pattern": "\\S"
+ },
+ "label": {
+ "type": "string",
+ "pattern": "\\S"
+ }
+ },
+ "required": [
+ "value",
+ "label"
+ ],
+ "additionalProperties": false
+ }
+ },
+ "statsNote": {
+ "description": "Line under the stat tiles; only shown with stats.",
+ "type": "string",
+ "pattern": "\\S"
+ },
+ "footer": {
+ "type": "object",
+ "properties": {
+ "left": {
+ "type": "object",
+ "properties": {
+ "text": {
+ "type": "string",
+ "pattern": "\\S"
+ },
+ "icon": {
+ "type": "string",
+ "enum": [
+ "region",
+ "secure",
+ "members"
+ ]
+ }
+ },
+ "required": [
+ "text"
+ ],
+ "additionalProperties": false
+ },
+ "right": {
+ "type": "object",
+ "properties": {
+ "text": {
+ "type": "string",
+ "pattern": "\\S"
+ },
+ "icon": {
+ "type": "string",
+ "enum": [
+ "region",
+ "secure",
+ "members"
+ ]
+ }
+ },
+ "required": [
+ "text"
+ ],
+ "additionalProperties": false
+ }
+ },
+ "additionalProperties": false
+ },
+ "cta": {
+ "type": "object",
+ "properties": {
+ "label": {
+ "type": "string",
+ "pattern": "\\S"
+ },
+ "href": {
+ "type": "string",
+ "pattern": "^https?:\\/\\/\\S+$"
+ }
+ },
+ "required": [
+ "label"
+ ],
+ "additionalProperties": false
+ }
+ },
+ "required": [
+ "title"
+ ],
+ "additionalProperties": false
+ },
+ "evidence": {
+ "description": "Source locations backing this node; listed in the inspector.",
+ "maxItems": 8,
+ "type": "array",
+ "items": {
+ "type": "object",
+ "properties": {
+ "file": {
+ "type": "string",
+ "pattern": "^(?!\\/)(?!.*(?:^|\\/)\\.\\.(?:\\/|$))(?!.*\\\\)(?!.*:\\/\\/).*\\S.*$",
+ "description": "Repo-relative path, e.g. \"src/orders/api.ts\"."
+ },
+ "line": {
+ "type": "integer",
+ "exclusiveMinimum": 0,
+ "maximum": 9007199254740991
+ },
+ "note": {
+ "type": "string",
+ "pattern": "\\S"
+ }
+ },
+ "required": [
+ "file"
+ ],
+ "additionalProperties": false
+ }
+ }
+ },
+ "required": [
+ "id",
+ "type",
+ "card"
+ ],
+ "additionalProperties": false
+ }
+ },
+ "edges": {
+ "maxItems": 2000,
+ "type": "array",
+ "items": {
+ "type": "object",
+ "properties": {
+ "id": {
+ "type": "string",
+ "pattern": "^[a-z0-9][a-z0-9_-]*$"
+ },
+ "from": {
+ "type": "string",
+ "pattern": "^[a-z0-9][a-z0-9_-]*$",
+ "description": "Source node id."
+ },
+ "to": {
+ "type": "string",
+ "pattern": "^[a-z0-9][a-z0-9_-]*$",
+ "description": "Target node id."
+ },
+ "label": {
+ "description": "Short label; use sparingly.",
+ "type": "string",
+ "maxLength": 24,
+ "pattern": "\\S"
+ },
+ "kind": {
+ "description": "async edges render dashed.",
+ "type": "string",
+ "enum": [
+ "sync",
+ "async"
+ ]
+ }
+ },
+ "required": [
+ "id",
+ "from",
+ "to"
+ ],
+ "additionalProperties": false
+ }
+ },
+ "views": {
+ "maxItems": 50,
+ "type": "array",
+ "items": {
+ "type": "object",
+ "properties": {
+ "id": {
+ "type": "string",
+ "pattern": "^[a-z0-9][a-z0-9_-]*$"
+ },
+ "label": {
+ "type": "string",
+ "pattern": "\\S"
+ },
+ "caption": {
+ "type": "string",
+ "pattern": "\\S"
+ },
+ "nodes": {
+ "type": "array",
+ "items": {
+ "type": "string",
+ "pattern": "^[a-z0-9][a-z0-9_-]*$"
+ },
+ "description": "Node ids this guided view focuses; the rest are dimmed."
+ }
+ },
+ "required": [
+ "id",
+ "label",
+ "nodes"
+ ],
+ "additionalProperties": false
+ }
+ }
+ },
+ "required": [
+ "kind",
+ "title",
+ "nodes",
+ "edges"
+ ],
+ "additionalProperties": false,
+ "title": "stackmap diagram",
+ "description": "Agent-authored diagram. Layout is computed by stackmap; never give coordinates.",
+ "$id": "https://unpkg.com/@hyamero/stackmap@0.1.0/dist/stackmap.schema.json"
+}
diff --git a/packages/schema/test/docs.test.ts b/packages/schema/test/docs.test.ts
new file mode 100644
index 0000000..93e475d
--- /dev/null
+++ b/packages/schema/test/docs.test.ts
@@ -0,0 +1,33 @@
+import { readdirSync, readFileSync } from 'node:fs';
+import { describe, expect, it } from 'vitest';
+import { buildJsonSchema } from '../src/schema';
+import { renderSchemaDoc } from '../src/schema-doc';
+import { validateDiagram } from '../src/validate';
+
+const skill = (p: string) => readFileSync(new URL(`../../../skill/${p}`, import.meta.url), 'utf8');
+
+describe('skill references', () => {
+ it('schema.md is generated from the Zod source (run `bun run schema:emit`)', () => {
+ expect(skill('references/schema.md')).toBe(renderSchemaDoc());
+ });
+
+ it('the skill ships the current JSON Schema', () => {
+ expect(JSON.parse(skill('references/stackmap.schema.json'))).toEqual(buildJsonSchema());
+ });
+
+ it('documents every object, its limits and the brand allowlist', () => {
+ const doc = renderSchemaDoc();
+ for (const heading of ['## Diagram', '## nodes[]', '## nodes[].card', '## nodes[].card.rows[]', '## edges[]', '## groups[]', '## views[]', '## nodes[].evidence[]'])
+ expect(doc).toContain(heading);
+ expect(doc).toMatch(/\| `stats` \| array \| no \| .*at most 3/);
+ expect(doc).toMatch(/\| `label` \| string \| no \| .*at most 24 characters/);
+ expect(doc).toContain('`postgresql`');
+ });
+
+ it.each(readdirSync(new URL('../../../skill/examples/', import.meta.url)).filter((f) => f.endsWith('.json')))(
+ 'example %s validates with no diagnostics',
+ (f) => {
+ expect(validateDiagram(JSON.parse(skill(`examples/${f}`))).diagnostics).toEqual([]);
+ },
+ );
+});
diff --git a/packages/schema/test/schema.test.ts b/packages/schema/test/schema.test.ts
new file mode 100644
index 0000000..5af2114
--- /dev/null
+++ b/packages/schema/test/schema.test.ts
@@ -0,0 +1,67 @@
+import { readFileSync } from 'node:fs';
+import Ajv2020 from 'ajv/dist/2020.js';
+import { describe, expect, expectTypeOf, it } from 'vitest';
+import type { z } from 'zod';
+import type { DiagramDraft } from '@stackmap/core';
+import { commerceApi, groupedPlatform } from '@stackmap/core/samples';
+import { buildJsonSchema, DiagramDraftSchema } from '../src/schema';
+
+type Inferred = z.infer;
+const node = (over: Record = {}) => ({ id: 'a', type: 'service', card: { title: 'a' }, ...over });
+const draft = (over: Record = {}) => ({ kind: 'architecture', title: 'T', nodes: [node()], edges: [], ...over });
+const parses = (input: unknown) => DiagramDraftSchema.safeParse(input).success;
+
+describe('DiagramDraftSchema', () => {
+ it('infers exactly the core DiagramDraft shape (plus $schema)', () => {
+ expectTypeOf().toExtend();
+ expectTypeOf().toExtend();
+ });
+
+ it('accepts both samples and a minimal diagram', () => {
+ expect(parses(commerceApi)).toBe(true);
+ expect(parses(groupedPlatform)).toBe(true);
+ expect(parses(draft({ $schema: './stackmap.schema.json' }))).toBe(true);
+ });
+
+ it('accepts node evidence and a source URL for evidence links', () => {
+ const evidence = [{ file: 'src/orders/api.ts', line: 42, note: 'route table' }, { file: 'infra/db.tf' }];
+ expect(parses(draft({ source: { url: 'https://github.com/acme/shop/blob/main' }, nodes: [node({ evidence })] }))).toBe(true);
+ });
+
+ it.each([
+ ['evidence with line 0', draft({ nodes: [node({ evidence: [{ file: 'a.ts', line: 0 }] })] })],
+ ['evidence with a fractional line', draft({ nodes: [node({ evidence: [{ file: 'a.ts', line: 1.5 }] })] })],
+ ['more than 8 evidence items', draft({ nodes: [node({ evidence: Array(9).fill({ file: 'a.ts' }) })] })],
+ ['a non-http source URL', draft({ source: { url: 'file:///etc' } })],
+ ['an uppercase id', draft({ nodes: [node({ id: 'Orders' })] })],
+ ['an id starting with a dash', draft({ nodes: [node({ id: '-a' })] })],
+ ['an unknown node type', draft({ nodes: [node({ type: 'lambda' })] })],
+ ['more than 3 stats', draft({ nodes: [node({ card: { title: 'a', stats: Array(4).fill({ value: '1', label: 'x' }) } })] })],
+ ['more than 6 rows', draft({ nodes: [node({ card: { title: 'a', rows: Array(7).fill({ label: 'k', value: 'v' }) } })] })],
+ ['an edge label over 24 chars', draft({ edges: [{ id: 'e', from: 'a', to: 'a', label: 'x'.repeat(25) }] })],
+ ['a javascript: CTA link', draft({ nodes: [node({ card: { title: 'a', cta: { label: 'Go', href: 'javascript:alert(1)' } } })] })],
+ ['a typo key', draft({ nodes: [node({ card: { title: 'a', subtitel: 'x' } })] })],
+ ['an empty title', draft({ nodes: [node({ card: { title: '' } })] })],
+ ['no nodes', draft({ nodes: [] })],
+ ['a non-object', 'diagram'],
+ ])('rejects %s', (_name, input) => {
+ expect(parses(input)).toBe(false);
+ });
+});
+
+describe('stackmap.schema.json', () => {
+ const committed = JSON.parse(readFileSync(new URL('../stackmap.schema.json', import.meta.url), 'utf8'));
+
+ it('is up to date with the Zod source (run `bun run schema:emit`)', () => {
+ expect(committed).toEqual(buildJsonSchema());
+ });
+
+ it('agrees with Zod on the samples and on the limits it can express', () => {
+ const validate = new Ajv2020({ strict: false }).compile(committed);
+ expect(validate(commerceApi)).toBe(true);
+ expect(validate(groupedPlatform)).toBe(true);
+ expect(validate(draft({ nodes: [node({ id: 'Bad' })] }))).toBe(false);
+ expect(validate(draft({ nodes: [node({ card: { title: 'a', cta: { label: 'Go', href: 'javascript:x' } } })] }))).toBe(false);
+ expect(validate(draft({ nodes: [node({ card: { title: 'a', stats: Array(4).fill({ value: '1', label: 'x' }) } })] }))).toBe(false);
+ });
+});
diff --git a/packages/schema/test/validate.test.ts b/packages/schema/test/validate.test.ts
new file mode 100644
index 0000000..9f45c3a
--- /dev/null
+++ b/packages/schema/test/validate.test.ts
@@ -0,0 +1,337 @@
+import { describe, expect, it } from 'vitest';
+import type { DiagramDraft } from '@stackmap/core';
+import { commerceApi, groupedPlatform } from '@stackmap/core/samples';
+import { validateDiagram, type Diagnostic } from '../src';
+
+const node = (id: string, over: Partial = {}) => ({ id, type: 'service' as const, card: { title: id }, ...over });
+const base = (over: Partial = {}): DiagramDraft => ({
+ kind: 'architecture',
+ title: 'T',
+ nodes: [node('a'), node('b')],
+ edges: [{ id: 'ab', from: 'a', to: 'b' }],
+ ...over,
+});
+const diags = (input: unknown) => validateDiagram(input).diagnostics;
+const only = (input: unknown, code: string) => diags(input).filter((d) => d.code === code);
+
+describe('validateDiagram', () => {
+ it.each([
+ ['commerce API', commerceApi],
+ ['grouped platform', groupedPlatform],
+ ])('the %s sample validates with no diagnostics', (_n, sample) => {
+ expect(validateDiagram(sample)).toEqual({ ok: true, diagram: sample, diagnostics: [] });
+ });
+
+ it('strips $schema from the typed diagram', () => {
+ const r = validateDiagram({ $schema: './stackmap.schema.json', ...base() });
+ expect(r.ok).toBe(true);
+ expect(r.diagram).toEqual(base());
+ });
+
+ describe('schema', () => {
+ it.each([null, [], 'diagram', 42, undefined])('reports a non-object root (%s) once, without throwing', (input) => {
+ const r = validateDiagram(input);
+ expect(r.ok).toBe(false);
+ expect(r.diagram).toBeUndefined();
+ expect(r.diagnostics).toHaveLength(1);
+ expect(r.diagnostics[0]).toMatchObject({ code: 'schema/invalid_type', subject: '', evidence: { expected: 'object' } });
+ });
+
+ it('a missing required field says to add it', () => {
+ const { edges: _, ...noEdges } = base();
+ expect(diags(noEdges)).toEqual([
+ {
+ code: 'schema/invalid_type',
+ severity: 'error',
+ subject: '/edges',
+ message: 'Expected array, received nothing',
+ evidence: { expected: 'array', received: 'missing' },
+ allowedFixes: ['add the required "edges" field (array)'],
+ },
+ ]);
+ });
+
+ it('a bad id suggests a valid one', () => {
+ const [d] = diags(base({ nodes: [node('Orders API'), node('b')], edges: [] }));
+ expect(d).toMatchObject({ code: 'schema/invalid_format', subject: '/nodes/0/id', allowedFixes: ['use a lowercase id such as "orders-api"'] });
+ });
+
+ it('an unknown type lists the allowed types', () => {
+ const [d] = diags(base({ nodes: [node('a', { type: 'lambda' as never })] }));
+ expect(d).toMatchObject({ code: 'schema/invalid_value', subject: '/nodes/0/type' });
+ expect(d!.allowedFixes[0]).toMatch(/^use one of: client, service, gateway/);
+ });
+
+ it('a misspelt key suggests the right one', () => {
+ const [d] = diags(base({ nodes: [node('a', { card: { title: 'a', subtitel: 'x' } as never })] }));
+ expect(d).toMatchObject({ code: 'schema/unrecognized_keys', subject: '/nodes/0/card', evidence: { keys: ['subtitel'] }, allowedFixes: ['rename "subtitel" to "subtitle"'] });
+ });
+
+ it('only suggests keys valid on the offending object, never the key itself', () => {
+ const onNode = diags(base({ nodes: [node('a', { label: 'x' } as never), node('b')] }))[0];
+ expect(onNode).toMatchObject({ subject: '/nodes/0', allowedFixes: ['remove "label" (not valid here; allowed: id, type, group, card, evidence)'] });
+ const onEdge = diags(base({ edges: [{ id: 'ab', from: 'a', to: 'b', subtitel: 'x' } as never] }))[0];
+ expect(onEdge!.allowedFixes).toEqual(['remove "subtitel" (not valid here; allowed: id, from, to, label, kind)']);
+ const onRoot = diags({ ...base(), titel: 'x' })[0];
+ expect(onRoot).toMatchObject({ subject: '', allowedFixes: ['rename "titel" to "title"'] });
+ });
+
+ it('a blank or whitespace-only text is one diagnostic asking for visible text', () => {
+ for (const title of ['', ' ']) {
+ expect(diags(base({ nodes: [node('a', { card: { title } }), node('b')] }))).toMatchObject([
+ { code: 'schema/invalid_format', subject: '/nodes/0/card/title', allowedFixes: ['give "title" visible text'] },
+ ]);
+ }
+ });
+
+ it('type errors name the value in plain words', () => {
+ expect(diags(null)[0]).toMatchObject({ evidence: { received: null }, allowedFixes: ['make the diagram an object'] });
+ expect(diags(base({ nodes: [5 as never] }))[0]).toMatchObject({ subject: '/nodes/0', allowedFixes: ['make item 0 of "nodes" an object'] });
+ expect(diags(base({ title: null as never }))[0]).toMatchObject({ evidence: { received: null }, allowedFixes: ['make "title" a string'] });
+ });
+
+ it('caps diagram size so validation and layout stay fast', () => {
+ const nodes = Array.from({ length: 501 }, (_, i) => node(`n${i}`));
+ expect(diags(base({ nodes }))[0]).toMatchObject({ code: 'schema/too_big', subject: '/nodes', allowedFixes: ['keep at most 500 items'] });
+ });
+
+ it.each([
+ ['a node-level brand belongs in the card', { brand: 'redis' }, '/nodes/0', ['move "brand" into "card"', 'remove "brand"']],
+ ['card evidence belongs on the node', { card: { title: 'a', evidence: [{ file: 'a.ts' }] } }, '/nodes/0/card', ['move "evidence" up to the enclosing object', 'remove "evidence"']],
+ ['a card "label" is the title', { card: { title: 'a', label: 'x' } }, '/nodes/0/card', ['rename "label" to "title"']],
+ ['a card "name" is the title', { card: { title: 'a', name: 'x' } }, '/nodes/0/card', ['rename "name" to "title"']],
+ ['a card "description" is the subtitle', { card: { title: 'a', description: 'x' } }, '/nodes/0/card', ['rename "description" to "subtitle"']],
+ ])('%s', (_n, over, subject, fixes) => {
+ const [d] = diags(base({ nodes: [node('a', over as never), node('b')] }));
+ expect(d).toMatchObject({ code: 'schema/unrecognized_keys', subject, allowedFixes: fixes });
+ });
+
+ it('never "moves" into an array or suggests a far-fetched rename for a short key', () => {
+ const [value] = diags(base({ nodes: [node('a', { card: { title: 'a', value: 'x' } } as never), node('b')] }));
+ expect(value!.allowedFixes).toEqual([expect.stringMatching(/^remove "value"/)]);
+ const [file] = diags(base({ nodes: [node('a', { card: { title: 'a', file: 'x' } } as never), node('b')] }));
+ expect(file!.allowedFixes).toEqual([expect.stringMatching(/^remove "file"/)]);
+ const [id] = diags({ ...base(), id: 'x' });
+ expect(id!.allowedFixes).toEqual([expect.stringMatching(/^remove "id"/)]);
+ });
+
+ it('an edge label limit counts characters, not UTF-16 units (emoji)', () => {
+ const emoji = '🚀'.repeat(13); // 13 characters, 26 UTF-16 units
+ expect(diags(base({ edges: [{ id: 'ab', from: 'a', to: 'b', label: emoji }] }))).toEqual([]);
+ expect(diags(base({ edges: [{ id: 'ab', from: 'a', to: 'b', label: '🚀'.repeat(25) }] }))[0]).toMatchObject({
+ subject: '/edges/0/label',
+ allowedFixes: ['shorten "label" to at most 24 characters'],
+ });
+ });
+
+ it('limits say how far to cut', () => {
+ const stats = Array.from({ length: 4 }, (_, i) => ({ value: String(i), label: 'x' }));
+ expect(diags(base({ nodes: [node('a', { card: { title: 'a', stats } })] }))[0]).toMatchObject({
+ code: 'schema/too_big',
+ subject: '/nodes/0/card/stats',
+ allowedFixes: ['keep at most 3 items'],
+ });
+ expect(diags(base({ edges: [{ id: 'ab', from: 'a', to: 'b', label: 'x'.repeat(25) }] }))[0]).toMatchObject({
+ code: 'schema/too_big',
+ subject: '/edges/0/label',
+ allowedFixes: ['shorten "label" to at most 24 characters'],
+ });
+ });
+
+ it('rejects a non-http CTA link', () => {
+ const [d] = diags(base({ nodes: [node('a', { card: { title: 'a', cta: { label: 'Go', href: 'javascript:alert(1)' } } })] }));
+ expect(d).toMatchObject({ code: 'schema/invalid_format', subject: '/nodes/0/card/cta/href', allowedFixes: ['use an http(s) URL', 'remove "href"'] });
+ });
+
+ it('a non-http source URL gets the same fix', () => {
+ // Removing only "url" would leave an invalid `source: {}`; the fix must name the whole object.
+ expect(diags(base({ source: { url: 'ftp://x' } }))[0]).toMatchObject({ subject: '/source/url', allowedFixes: ['use an http(s) URL', 'remove "source"'] });
+ });
+
+ it.each(['../../other/repo/x.ts', '/etc/passwd', 'src\\\\win.ts', 'https://evil.example/x', 'a/../b.ts'])(
+ 'rejects evidence file %s: repo-relative paths only',
+ (file) => {
+ const [d] = diags(base({ nodes: [node('a', { evidence: [{ file }] }), node('b')] }));
+ expect(d).toMatchObject({ subject: '/nodes/0/evidence/0/file', allowedFixes: ['use a repo-relative path like "src/api/server.ts" (no "..", "\\", "://" or leading "/")'] });
+ },
+ );
+
+ it('accepts ordinary repo-relative evidence paths', () => {
+ for (const file of ['src/a.ts', '.github/workflows/ci.yml', 'docs/my file#1.md', 'a..b/c.ts']) {
+ expect(validateDiagram(base({ nodes: [node('a', { evidence: [{ file }] }), node('b')] })).ok, file).toBe(true);
+ }
+ });
+
+ it('returns only schema diagnostics when the schema fails', () => {
+ // Dangling edge + orphan would be reported too if later families ran.
+ const r = validateDiagram({ ...base({ edges: [{ id: 'ax', from: 'a', to: 'x' }] }), title: '' });
+ expect(r.diagnostics.map((d) => d.code)).toEqual(['schema/invalid_format']);
+ });
+ });
+
+ describe('refs', () => {
+ it('a dangling edge end names the closest node ids', () => {
+ expect(only(base({ nodes: [node('orders'), node('b')], edges: [{ id: 'e', from: 'order', to: 'b' }] }), 'refs/unknown-node')).toEqual([
+ {
+ code: 'refs/unknown-node',
+ severity: 'error',
+ subject: '/edges/0/from',
+ message: 'Unknown node "order"',
+ evidence: { id: 'order', closest: ['orders'] },
+ allowedFixes: ['use "orders"', 'add node "order" or remove the edge'],
+ },
+ ]);
+ });
+
+ it('unknown groups on nodes and group parents', () => {
+ const d = base({ groups: [{ id: 'app', label: 'App', parent: 'missing' }], nodes: [node('a', { group: 'ap' }), node('b', { group: 'app' })] });
+ expect(only(d, 'refs/unknown-group').map((x) => [x.subject, x.evidence])).toEqual([
+ ['/groups/0/parent', { id: 'missing', closest: [] }],
+ ['/nodes/0/group', { id: 'ap', closest: ['app'] }],
+ ]);
+ });
+
+ it('unknown view members', () => {
+ const d = base({ views: [{ id: 'v', label: 'V', nodes: ['a', 'zz'] }] });
+ expect(only(d, 'refs/unknown-view-node')).toMatchObject([{ subject: '/views/0/nodes/1', evidence: { id: 'zz' } }]);
+ });
+
+ it('reports each group parent cycle once and terminates', () => {
+ const d = base({
+ groups: [
+ { id: 'x', label: 'X', parent: 'y' },
+ { id: 'y', label: 'Y', parent: 'x' },
+ { id: 'self', label: 'S', parent: 'self' },
+ { id: 'into', label: 'I', parent: 'x' },
+ ],
+ nodes: [node('a', { group: 'x' }), node('b', { group: 'self' })],
+ });
+ expect(only(d, 'refs/group-cycle').map((x) => [x.subject, x.evidence])).toEqual([
+ ['/groups/0/parent', { cycle: ['x', 'y'] }],
+ ['/groups/2/parent', { cycle: ['self'] }],
+ ]);
+ });
+
+ it('an unknown brand is a warning with the nearest slugs', () => {
+ const r = validateDiagram(base({ nodes: [node('a', { card: { title: 'a', brand: 'postgres' } }), node('b')] }));
+ expect(r.ok).toBe(true);
+ expect(r.diagnostics).toMatchObject([
+ { code: 'refs/unknown-brand', severity: 'warning', subject: '/nodes/0/card/brand', evidence: { closest: ['postgresql'] }, allowedFixes: ['use "postgresql"', 'remove "brand"'] },
+ ]);
+ });
+ });
+
+ describe('semantics', () => {
+ it('duplicate ids point at the first occurrence', () => {
+ const d = base({ nodes: [node('a'), node('b'), node('a')], edges: [{ id: 'ab', from: 'a', to: 'b' }, { id: 'ab', from: 'b', to: 'a' }] });
+ expect(only(d, 'semantics/duplicate-id').map((x) => [x.subject, x.evidence])).toEqual([
+ ['/nodes/2/id', { id: 'a', first: '/nodes/0/id' }],
+ ['/edges/1/id', { id: 'ab', first: '/edges/0/id' }],
+ ]);
+ });
+
+ it('a stats note without stats is a warning (it is never shown)', () => {
+ expect(only(base({ nodes: [node('a', { card: { title: 'a', statsNote: 'links' } }), node('b')] }), 'semantics/hidden-stats-note')).toMatchObject([
+ { severity: 'warning', subject: '/nodes/0/card/statsNote' },
+ ]);
+ });
+
+ it('a duplicate id rename suggestion is itself unused', () => {
+ const d = base({ nodes: [node('a'), node('a-2'), node('a')], edges: [] });
+ expect(only(d, 'semantics/duplicate-id')[0]!.allowedFixes[0]).toBe('rename to a unique id, e.g. "a-3"');
+ });
+
+ it('an unconnected node is an orphan, except in a one-node diagram', () => {
+ expect(only(base({ nodes: [node('a'), node('b'), node('c')] }), 'semantics/orphan-node')).toMatchObject([{ subject: '/nodes/2', severity: 'warning' }]);
+ expect(diags(base({ nodes: [node('a')], edges: [] }))).toEqual([]);
+ });
+
+ it('a self-loop is a warning', () => {
+ expect(only(base({ edges: [{ id: 'ab', from: 'a', to: 'b' }, { id: 'aa', from: 'a', to: 'a' }] }), 'semantics/self-loop')).toMatchObject([
+ { subject: '/edges/1', severity: 'warning' },
+ ]);
+ });
+
+ it('cycles warn in dataflow diagrams only', () => {
+ const edges = [
+ { id: 'ab', from: 'a', to: 'b' },
+ { id: 'bc', from: 'b', to: 'c' },
+ { id: 'ca', from: 'c', to: 'a' },
+ ];
+ const nodes = [node('a'), node('b'), node('c')];
+ expect(only(base({ kind: 'dataflow', nodes, edges }), 'semantics/dataflow-cycle')).toMatchObject([
+ { subject: '/edges/0', severity: 'warning', evidence: { nodes: ['a', 'b', 'c'] } },
+ ]);
+ expect(only(base({ nodes, edges }), 'semantics/dataflow-cycle')).toEqual([]);
+ });
+
+ it('an async feedback edge clears the cycle warning (its suggested fix works)', () => {
+ const edges = [
+ { id: 'ab', from: 'a', to: 'b' },
+ { id: 'ba', from: 'b', to: 'a', kind: 'async' as const },
+ ];
+ expect(only(base({ kind: 'dataflow', edges }), 'semantics/dataflow-cycle')).toEqual([]);
+ });
+
+ it('an empty view is an error, an empty group a warning', () => {
+ const r = validateDiagram(base({ views: [{ id: 'v', label: 'V', nodes: [] }], groups: [{ id: 'g', label: 'G' }] }));
+ expect(r.ok).toBe(false);
+ expect(r.diagnostics.map((x) => [x.code, x.severity, x.subject])).toEqual([
+ ['semantics/empty-view', 'error', '/views/0/nodes'],
+ ['semantics/empty-group', 'warning', '/groups/0'],
+ ]);
+ });
+
+ it('a group holding only a child group is not empty', () => {
+ const d = base({ groups: [{ id: 'outer', label: 'O' }, { id: 'inner', label: 'I', parent: 'outer' }], nodes: [node('a', { group: 'inner' }), node('b')] });
+ expect(only(d, 'semantics/empty-group')).toEqual([]);
+ });
+ });
+
+ describe('card-fit', () => {
+ it('reports an overflowing title with its character budget', () => {
+ const title = 'a-very-long-service-name-that-will-never-fit';
+ const [d] = only(base({ nodes: [node('a', { card: { title } }), node('b')] }), 'card-fit/overflow');
+ expect(d).toMatchObject({ severity: 'error', subject: '/nodes/0/card/title', evidence: { text: title, maxWidth: 200 } });
+ const { maxChars, width } = d!.evidence as { maxChars: number; width: number };
+ expect(width).toBeGreaterThan(200);
+ expect(maxChars).toBeGreaterThan(20);
+ expect(maxChars).toBeLessThan(title.length);
+ expect(d!.allowedFixes).toEqual([`shorten to at most ${maxChars} characters`, 'move the detail into a card row']);
+ });
+
+ it('covers every slot, not just the header', () => {
+ const long = 'x'.repeat(80);
+ const card = {
+ title: 'a',
+ rows: [{ label: 'k', value: long }],
+ stats: [{ value: '1', label: long }],
+ statsNote: long,
+ footer: { left: { text: long, icon: 'region' as const } },
+ cta: { label: long },
+ };
+ expect(only(base({ nodes: [node('a', { card }), node('b')] }), 'card-fit/overflow').map((x) => x.subject)).toEqual([
+ '/nodes/0/card/rows/0/value',
+ '/nodes/0/card/stats/0/label',
+ '/nodes/0/card/statsNote',
+ '/nodes/0/card/footer/left/text',
+ '/nodes/0/card/cta/label',
+ ]);
+ });
+
+ it('measures text as rendered: runs of spaces collapse, ends are trimmed', () => {
+ expect(only(base({ nodes: [node('a', { card: { title: `Orders${' '.repeat(60)}` } }), node('b')] }), 'card-fit/overflow')).toEqual([]);
+ });
+
+ it('a row label squeezed by its value also offers shortening the value', () => {
+ const card = { title: 'a', rows: [{ label: 'Connection string for the primary', value: 'postgres://orders.internal:5432' }] };
+ const [d] = only(base({ nodes: [node('a', { card }), node('b')] }), 'card-fit/overflow').filter((x) => x.subject.endsWith('/label'));
+ expect(d!.allowedFixes).toContain('shorten the value at /nodes/0/card/rows/0/value to give the label room');
+ });
+
+ it('measures non-Latin text conservatively instead of crashing', () => {
+ const [d] = only(base({ nodes: [node('a', { card: { title: '注文サービス本番環境クラスター東京' } }), node('b')] }), 'card-fit/overflow');
+ expect(d).toMatchObject({ subject: '/nodes/0/card/title', evidence: { maxChars: 13 } });
+ });
+ });
+});
diff --git a/packages/schema/tsconfig.json b/packages/schema/tsconfig.json
new file mode 100644
index 0000000..66b0774
--- /dev/null
+++ b/packages/schema/tsconfig.json
@@ -0,0 +1 @@
+{ "extends": "../../tsconfig.base.json", "compilerOptions": { "types": ["node"] }, "include": ["src", "test", "scripts"] }
diff --git a/packages/viewer/e2e/explore.spec.ts b/packages/viewer/e2e/explore.spec.ts
new file mode 100644
index 0000000..78354bc
--- /dev/null
+++ b/packages/viewer/e2e/explore.spec.ts
@@ -0,0 +1,358 @@
+import { expect, test, type Page } from '@playwright/test';
+
+// M3 explorer: selection, inspector, trace, search, lens, views, deep links, keyboard.
+const viewportOf = (page: Page) =>
+ page.evaluate(() => {
+ const m = new DOMMatrix(getComputedStyle(document.querySelector('.sm-viewport')!).transform);
+ return { x: m.e, y: m.f, k: m.a };
+ });
+const card = (page: Page, id: string) => page.locator(`.sm-card[data-card-id="${id}"]`);
+const emphasisOf = (page: Page) =>
+ page.$$eval('.sm-card', (els) => Object.fromEntries(els.map((e) => [e.getAttribute('data-card-id'), e.getAttribute('data-emphasis')])));
+const inspector = (page: Page) => page.getByRole('complementary', { name: 'Inspector' });
+const settle = (page: Page) => page.waitForTimeout(250);
+
+test.beforeEach(async ({ page }) => {
+ await page.goto('/?page=sample');
+ await expect(page.locator('.sm-card')).toHaveCount(6);
+ await settle(page);
+});
+
+test('clicking a card selects it: inspector shows its data, connections and evidence', async ({ page }) => {
+ await card(page, 'orders').click();
+ await expect(card(page, 'orders')).toHaveAttribute('aria-pressed', 'true');
+ const panel = inspector(page);
+ await expect(panel.getByRole('heading', { level: 2, name: 'Orders' })).toBeVisible();
+ await expect(panel.getByText('Read replicas', { exact: true })).toBeVisible();
+ await expect(panel.getByRole('list', { name: 'Connections' }).getByRole('button')).toHaveCount(3);
+ const link = panel.getByRole('link', { name: 'infra/orders/postgres.tf:12' });
+ await expect(link).toHaveAttribute('href', 'https://github.com/hyamero/stackmap/blob/main/infra/orders/postgres.tf#L12');
+ await expect(panel.getByText('Primary + 2 read replicas')).toBeVisible();
+ // Its direct edges take the source (service) tint.
+ await expect(page.locator('path[data-edge-id="e-1-orders"]')).toHaveAttribute('data-tint', 'service');
+ await expect(page.locator('path[data-edge-id="e-edge-1"]')).not.toHaveAttribute('data-tint', /.+/);
+});
+
+test('a connection in the inspector selects that node', async ({ page }) => {
+ await card(page, 'orders').click();
+ await inspector(page).getByRole('button', { name: /commerce-api-2/ }).click();
+ await expect(card(page, 'commerce-api-2')).toHaveAttribute('aria-pressed', 'true');
+});
+
+test('clicking empty canvas or pressing Escape clears the selection; dragging does not', async ({ page }) => {
+ await card(page, 'orders').click();
+ const stage = page.locator('.sm-stage');
+ const box = (await stage.boundingBox())!;
+ // Drag across empty canvas: pans, keeps the selection.
+ await page.mouse.move(box.x + box.width - 60, box.y + box.height - 120);
+ await page.mouse.down();
+ await page.mouse.move(box.x + box.width - 160, box.y + box.height - 160, { steps: 5 });
+ await page.mouse.up();
+ await expect(card(page, 'orders')).toHaveAttribute('aria-pressed', 'true');
+ // Drag that starts on a card: no selection change either.
+ const c = (await card(page, 'sessions').boundingBox())!;
+ await page.mouse.move(c.x + 40, c.y + 20);
+ await page.mouse.down();
+ await page.mouse.move(c.x - 60, c.y + 60, { steps: 5 });
+ await page.mouse.up();
+ await expect(card(page, 'orders')).toHaveAttribute('aria-pressed', 'true');
+ // A real click on empty canvas clears. (d3-zoom swallows clicks for one tick after a drag.)
+ await page.waitForTimeout(50);
+ await page.mouse.click(box.x + box.width - 40, box.y + box.height - 100);
+ await expect(card(page, 'orders')).toHaveAttribute('aria-pressed', 'false');
+ await card(page, 'orders').click();
+ await page.keyboard.press('Escape');
+ await expect(card(page, 'orders')).toHaveAttribute('aria-pressed', 'false');
+});
+
+test('trace dims everything outside the selection’s upstream and downstream', async ({ page }) => {
+ await card(page, 'commerce-api-1').click();
+ await page.getByRole('button', { name: /Trace upstream/ }).click();
+ const e = await emphasisOf(page);
+ expect(e).toEqual({ edge: 'normal', 'commerce-api-1': 'focus', 'commerce-api-2': 'dim', 'commerce-api-3': 'dim', orders: 'normal', sessions: 'normal' });
+ await expect(page.locator('path[data-edge-id="e-edge-1"]')).toHaveAttribute('data-tint', 'gateway');
+ await expect(page.locator('path[data-edge-id="e-2-orders"]')).toHaveAttribute('data-dim', 'true');
+});
+
+test('"/" opens search; Enter selects the first match and centres it', async ({ page }) => {
+ await page.locator('body').press('/');
+ const box = page.getByRole('combobox', { name: 'Search nodes' });
+ await expect(box).toBeFocused();
+ await box.fill('sess');
+ await expect(page.getByRole('option')).toHaveText([/Sessions/]);
+ expect((await emphasisOf(page)).orders).toBe('dim');
+ await box.press('Enter');
+ await expect(box).toBeHidden();
+ await expect(card(page, 'sessions')).toHaveAttribute('aria-pressed', 'true');
+ await settle(page);
+ 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 }) => {
+ await page.getByRole('button', { name: /Search nodes/ }).click();
+ const box = page.getByRole('combobox', { name: 'Search nodes' });
+ for (const q of ['.*', '(', ' ', '[a-z]+']) {
+ await box.fill(q);
+ await expect(page.locator('.sm-card')).toHaveCount(6);
+ }
+ await box.fill('.*');
+ await expect(page.getByText('No matching nodes')).toBeVisible();
+});
+
+test('the type lens dims unchecked types and can be restored', async ({ page }) => {
+ await page.getByRole('button', { name: 'Filter by type' }).click();
+ await page.getByRole('checkbox', { name: /Service/ }).uncheck();
+ const e = await emphasisOf(page);
+ expect(e['commerce-api-1']).toBe('dim');
+ expect(e.orders).toBe('normal');
+ await expect(page.getByRole('button', { name: 'Filter by type' })).toHaveAttribute('aria-pressed', 'true');
+ await page.getByRole('checkbox', { name: /Service/ }).check();
+ expect((await emphasisOf(page))['commerce-api-1']).toBe('normal');
+});
+
+test('a view tab dims non-members, shows its caption and fits its members', async ({ page }) => {
+ const before = await viewportOf(page);
+ await page.getByRole('tab', { name: 'Data tier' }).click();
+ await expect(page.getByText('Where state lives')).toBeVisible();
+ const e = await emphasisOf(page);
+ expect([e.orders, e.sessions, e['commerce-api-1']]).toEqual(['normal', 'normal', 'dim']);
+ await settle(page);
+ expect((await viewportOf(page)).k).toBeGreaterThan(before.k);
+ await page.getByRole('tab', { name: 'Overview' }).click();
+ await settle(page);
+ expect(await viewportOf(page)).toEqual(before);
+});
+
+test('state is mirrored in the hash and restored from a deep link', async ({ page }) => {
+ await page.getByRole('tab', { name: 'Data tier' }).click();
+ await card(page, 'orders').click();
+ await page.getByRole('button', { name: 'Filter by type' }).click();
+ await page.getByRole('checkbox', { name: /Cache/ }).uncheck();
+ expect(new URL(page.url()).hash).toBe('#view=data&node=orders&lens=cache');
+ await page.reload();
+ await expect(card(page, 'orders')).toHaveAttribute('aria-pressed', 'true');
+ await expect(page.getByRole('tab', { name: 'Data tier' })).toHaveAttribute('aria-selected', 'true');
+ expect((await emphasisOf(page)).sessions).toBe('dim');
+});
+
+test('a deep link with unknown ids is ignored and the viewer still renders', async ({ page }) => {
+ await page.goto('/?page=sample#view=nope&node=ghost&lens=lambda');
+ await expect(page.locator('.sm-card')).toHaveCount(6);
+ expect(Object.values(await emphasisOf(page)).every((v) => v === 'normal')).toBe(true);
+ await expect(page.getByRole('tab', { name: 'Overview' })).toHaveAttribute('aria-selected', 'true');
+});
+
+test('keyboard only: tab to a card, arrow between cards, Enter selects, Escape clears', async ({ page }) => {
+ await card(page, 'edge').focus();
+ await page.keyboard.press('ArrowRight');
+ await expect(card(page, 'commerce-api-2')).toBeFocused();
+ await page.keyboard.press('ArrowUp');
+ await expect(card(page, 'commerce-api-1')).toBeFocused();
+ await page.keyboard.press('Enter');
+ await expect(card(page, 'commerce-api-1')).toHaveAttribute('aria-pressed', 'true');
+ await expect(inspector(page).getByRole('heading', { level: 2, name: 'commerce-api-1' })).toBeVisible();
+ await page.keyboard.press('Escape');
+ await expect(card(page, 'commerce-api-1')).toHaveAttribute('aria-pressed', 'false');
+});
+
+test('Tab reaches the toolbar before the canvas, and the canvas is one tab stop (roving)', async ({ page }) => {
+ await page.locator('body').click({ position: { x: 5, y: 5 } });
+ const order: string[] = [];
+ for (let i = 0; i < 12; i++) {
+ await page.keyboard.press('Tab');
+ order.push(
+ await page.evaluate(() => {
+ const el = document.activeElement!;
+ return el.getAttribute('data-card-id') ? `card:${el.getAttribute('data-card-id')}` : (el.getAttribute('aria-label') ?? el.textContent ?? el.tagName).trim();
+ }),
+ );
+ }
+ expect(order.indexOf('Search nodes (/)')).toBeLessThan(order.findIndex((x) => x.startsWith('card:')));
+ expect(order.filter((x) => x.startsWith('card:'))).toEqual(['card:edge']);
+ // Arrows still reach every card from the single tab stop (walk every direction from every card reached).
+ const seen = new Set(['edge']);
+ const queue = ['edge'];
+ while (queue.length) {
+ const from = queue.shift()!;
+ for (const key of ['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight']) {
+ await card(page, from).focus();
+ await page.keyboard.press(key);
+ const to = await page.evaluate(() => document.activeElement?.getAttribute('data-card-id'));
+ if (to && !seen.has(to)) seen.add(to) && queue.push(to);
+ }
+ }
+ expect([...seen].sort()).toEqual(['commerce-api-1', 'commerce-api-2', 'commerce-api-3', 'edge', 'orders', 'sessions']);
+});
+
+test('focus is never dropped: search Enter lands on the card, Esc returns to the button, Clear returns to the card', async ({ page }) => {
+ await page.locator('body').press('/');
+ await page.getByRole('combobox').fill('orders');
+ await page.getByRole('combobox').press('Enter');
+ await expect(card(page, 'orders')).toBeFocused();
+ await page.getByRole('button', { name: /Search nodes/ }).click();
+ await page.getByRole('combobox').press('Escape');
+ await expect(page.getByRole('button', { name: /Search nodes/ })).toBeFocused();
+ await inspector(page).getByRole('button', { name: /Clear selection/ }).click();
+ await expect(card(page, 'orders')).toBeFocused();
+ await expect(card(page, 'orders')).toHaveAttribute('aria-pressed', 'false');
+});
+
+test('Escape closes the lens from its button and on the page body clears the selection', async ({ page }) => {
+ await page.getByRole('button', { name: 'Filter by type' }).click();
+ await expect(page.getByRole('checkbox').first()).toBeVisible();
+ await page.keyboard.press('Escape');
+ await expect(page.getByRole('checkbox')).toHaveCount(0);
+ await expect(page.getByRole('button', { name: 'Filter by type' })).toBeFocused();
+ await card(page, 'orders').click();
+ await page.evaluate(() => (document.activeElement as HTMLElement).blur());
+ await page.keyboard.press('Escape');
+ await expect(card(page, 'orders')).toHaveAttribute('aria-pressed', 'false');
+});
+
+test('the inspector collapses with a node selected, keeping focus on its toggle', async ({ page }) => {
+ await card(page, 'orders').click();
+ await inspector(page).getByRole('button', { name: 'Hide inspector' }).click();
+ await expect(inspector(page).getByRole('button', { name: 'Show inspector' })).toBeFocused();
+ await page.keyboard.press('Enter');
+ await expect(inspector(page).getByRole('button', { name: 'Hide inspector' })).toBeFocused();
+ await expect(inspector(page).getByRole('heading', { level: 2, name: 'Orders' })).toBeVisible();
+});
+
+test('a hash change to another node moves the camera to it', async ({ page }) => {
+ await page.goto('/?page=sample#node=orders');
+ await settle(page);
+ const before = await viewportOf(page);
+ await page.evaluate(() => (location.hash = '#node=edge'));
+ await settle(page);
+ await expect(card(page, 'edge')).toHaveAttribute('aria-pressed', 'true');
+ expect(await viewportOf(page)).not.toEqual(before);
+});
+
+test('a deep link to a node inside a view keeps the view’s fit', async ({ page }) => {
+ await page.goto('/?page=sample#view=data');
+ await settle(page);
+ const viewFit = await viewportOf(page);
+ await page.goto('/?page=sample#view=data&node=orders');
+ await settle(page);
+ expect((await viewportOf(page)).k).toBeCloseTo(viewFit.k, 3);
+});
+
+test('focusing an off-screen card pans it into view', async ({ page }) => {
+ await page.getByRole('button', { name: 'Zoom in' }).click();
+ await page.getByRole('button', { name: 'Zoom in' }).click();
+ await page.getByRole('button', { name: 'Zoom in' }).click();
+ await settle(page);
+ // Arrive by keyboard, as a keyboard user would (programmatic focus after a click isn't :focus-visible).
+ await card(page, 'orders').focus();
+ await page.keyboard.press('ArrowDown');
+ await expect(card(page, 'sessions')).toBeFocused();
+ await settle(page);
+ const stage = (await page.locator('.sm-stage').boundingBox())!;
+ const c = (await card(page, 'sessions').boundingBox())!;
+ expect(c.x).toBeGreaterThanOrEqual(stage.x);
+ expect(c.y).toBeGreaterThanOrEqual(stage.y + 80); // clear of the toolbar band
+ expect(c.x + c.width).toBeLessThanOrEqual(stage.x + stage.width);
+ expect(c.y + c.height).toBeLessThanOrEqual(stage.y + stage.height);
+});
+
+test('the focused stage pans with arrows and zooms with + / - / 0', async ({ page }) => {
+ const start = await viewportOf(page);
+ await page.locator('.sm-stage').focus();
+ await page.keyboard.press('ArrowRight');
+ await settle(page);
+ expect((await viewportOf(page)).x).toBeLessThan(start.x);
+ await page.keyboard.press('+');
+ await settle(page);
+ expect((await viewportOf(page)).k).toBeGreaterThan(start.k);
+ await page.keyboard.press('0');
+ await settle(page);
+ expect(await viewportOf(page)).toEqual(start);
+});
+
+test('the inspector starts collapsed on a narrow window and can be opened', async ({ page }) => {
+ await page.setViewportSize({ width: 1000, height: 800 });
+ await page.reload();
+ await expect(inspector(page).getByRole('button', { name: 'Show inspector' })).toBeVisible();
+ await inspector(page).getByRole('button', { name: 'Show inspector' }).click();
+ await expect(inspector(page).getByRole('list', { name: 'Legend' })).toBeVisible();
+});
+
+test('with reduced motion the camera jumps instead of animating', async ({ page }) => {
+ await page.emulateMedia({ reducedMotion: 'reduce' });
+ await page.reload();
+ await expect(page.locator('.sm-card')).toHaveCount(6);
+ const before = await viewportOf(page);
+ await page.getByRole('button', { name: 'Zoom in' }).click();
+ await page.waitForTimeout(20);
+ expect((await viewportOf(page)).k).toBeCloseTo(before.k * 1.2, 3);
+});
+
+test('a view fits its members clear of the toolbar and zoom bar', async ({ page }) => {
+ await page.getByRole('tab', { name: 'Data tier' }).click();
+ await settle(page);
+ const panels = await Promise.all((await page.locator('.sm-panel').all()).map((l) => l.boundingBox()));
+ for (const id of ['orders', 'sessions']) {
+ const c = (await card(page, id).boundingBox())!;
+ for (const p of panels.filter((b) => b !== null)) {
+ const overlap = c.x < p.x + p.width && p.x < c.x + c.width && c.y < p.y + p.height && p.y < c.y + c.height;
+ expect(overlap, `${id} under a panel`).toBe(false);
+ }
+ }
+});
+
+test('holding an arrow key pans the full distance for every repeat', async ({ page }) => {
+ const start = await viewportOf(page);
+ await page.locator('.sm-stage').focus();
+ await page.keyboard.down('ArrowRight');
+ for (let i = 0; i < 4; i++) await page.keyboard.down('ArrowRight'); // auto-repeat
+ await page.keyboard.up('ArrowRight');
+ await settle(page);
+ expect((await viewportOf(page)).x).toBeCloseTo(start.x - 5 * 80, 0);
+});
+
+test('dragging on the minimap pans the canvas with the pointer', async ({ page }) => {
+ await page.getByRole('button', { name: 'Toggle minimap' }).click();
+ const map = (await page.locator('svg.sm-minimap').boundingBox())!;
+ const before = await viewportOf(page);
+ await page.mouse.move(map.x + map.width * 0.3, map.y + map.height / 2);
+ await page.mouse.down();
+ await page.mouse.move(map.x + map.width * 0.7, map.y + map.height / 2, { steps: 6 });
+ const mid = await viewportOf(page);
+ await page.mouse.up();
+ expect(mid.x).toBeLessThan(before.x - 50); // moved during the drag, not only on release
+ expect(mid.k).toBeCloseTo(before.k, 5);
+});
+
+test('the canvas neither selects text nor scrolls the page on touch', async ({ page }) => {
+ const style = await page.locator('.sm-stage').evaluate((el) => {
+ const s = getComputedStyle(el);
+ return { select: s.userSelect, touch: s.touchAction };
+ });
+ expect(style).toEqual({ select: 'none', touch: 'none' });
+});
+
+test('a canvas that mounts at zero size fits once it gets one, without errors', async ({ page }) => {
+ const errors: string[] = [];
+ page.on('console', (m) => m.type() === 'error' && errors.push(m.text()));
+ // A constructed sheet survives document parsing (a `);
+await page.evaluate(async () => {
+ await Promise.all([
+ document.fonts.load("400 16px 'Geist'"),
+ document.fonts.load("500 16px 'Geist'"),
+ document.fonts.load("400 16px 'Geist Mono'"),
+ ]);
+});
+
+const out: Record> = {};
+const kern: Record> = {};
+for (const [name, f] of Object.entries(FACES)) {
+ // Pair adjustment = kerned width of the pair minus both advances; only non-zero pairs are kept.
+ kern[name] = await page.evaluate(
+ ({ f, size, min }) => {
+ const span = document.createElement('span');
+ Object.assign(span.style, {
+ fontFamily: `'${f.family}'`,
+ fontWeight: String(f.weight),
+ fontVariantNumeric: f.numeric,
+ fontSize: `${size}px`,
+ whiteSpace: 'pre',
+ position: 'absolute',
+ });
+ document.body.append(span);
+ const width = (t: string) => ((span.textContent = t), span.getBoundingClientRect().width);
+ const adv = new Map(f.cps.map((cp) => [cp, width(String.fromCodePoint(cp))]));
+ const pairs: Record = {};
+ const ascii = f.cps.filter((cp) => cp <= 0x7e);
+ for (const a of ascii) {
+ for (const b of ascii) {
+ const k = Math.round((width(String.fromCodePoint(a, b)) - adv.get(a)! - adv.get(b)!) * 100) / 100;
+ if (k >= 0.5 || k <= -min) pairs[a * 0x10000 + b] = k; // positive pairs always: dropping one under-measures
+ }
+ }
+ span.remove();
+ return pairs;
+ },
+ { f, size: SIZE, min: KERN_MIN },
+ );
+ out[name] = await page.evaluate(
+ ({ f, size }) => {
+ const span = document.createElement('span');
+ Object.assign(span.style, {
+ fontFamily: `'${f.family}'`,
+ fontWeight: String(f.weight),
+ fontVariantNumeric: f.numeric,
+ fontSize: `${size}px`,
+ whiteSpace: 'pre',
+ position: 'absolute',
+ });
+ document.body.append(span);
+ const adv: Record = {};
+ for (const cp of f.cps) {
+ span.textContent = String.fromCodePoint(cp);
+ adv[cp] = Math.round(span.getBoundingClientRect().width * 100) / 100;
+ }
+ span.remove();
+ return adv;
+ },
+ { f, size: SIZE },
+ );
+}
+await browser.close();
+
+const table = (t: Record>) =>
+ Object.entries(t)
+ .map(([name, m]) => ` ${name}: {${Object.entries(m).map(([k, v]) => `${k}:${v}`).join(',')}},`)
+ .join('\n');
+writeFileSync(
+ new URL('../../core/src/font-metrics.gen.ts', import.meta.url),
+ `// Generated by packages/viewer/scripts/gen-font-metrics.ts — do not edit.\n` +
+ `// Advance widths in 1/${SIZE} em keyed by code point; kerning keyed by first * 0x10000 + second.\n` +
+ `export type FontFace = ${Object.keys(FACES).map((f) => `'${f}'`).join(' | ')};\n\n` +
+ // Widened on purpose: literal types for ~12k keys make tsc slow and the tables unindexable.
+ `type Table = Record>;\n\n` +
+ `export const FONT_METRICS: { unitsPerEm: number; faces: Table; kerning: Table } = {\n unitsPerEm: ${SIZE},\n faces: {\n${table(out)}\n },\n kerning: {\n${table(kern)}\n },\n};\n`,
+);
+console.log(`wrote font-metrics.gen.ts (${Object.keys(out).map((n) => `${n}: ${Object.keys(out[n]!).length} glyphs, ${Object.keys(kern[n]!).length} pairs`).join('; ')})`);
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/App.tsx b/packages/viewer/src/App.tsx
index cebf3ca..2ca5a6d 100644
--- a/packages/viewer/src/App.tsx
+++ b/packages/viewer/src/App.tsx
@@ -1,13 +1,57 @@
+import type { LaidOutDiagram } from '@stackmap/core';
+import { isLive, LiveToast, useLiveStatus } from './chrome/LiveToast';
import { ViewerShell } from './chrome/ViewerShell';
+import { readEmbeddedDiagram } from './data';
import { GalleryPage } from './pages/GalleryPage';
import { commerceApiLayout } from './samples/commerce-api.layout';
import { groupedPlatformLayout } from './samples/grouped-platform.layout';
import { useTheme } from './theme/theme';
-export function App() {
- const { theme, toggle } = useTheme();
+type Loaded = { diagram: LaidOutDiagram } | { error: string } | null;
+
+function load(): Loaded {
+ try {
+ const diagram = readEmbeddedDiagram(document);
+ return diagram && { diagram };
+ } catch (e) {
+ return { error: (e as Error).message };
+ }
+}
+
+const loaded = load();
+if (loaded && 'diagram' in loaded) document.title = `${loaded.diagram.draft.title} · stackmap`;
+
+function Notice({ children }: { children: string }) {
+ return (
+
+
{children}
+
+ );
+}
+
+// Dev server only: samples and the card gallery. The built template renders only embedded data.
+function DevPages({ theme, toggle }: ReturnType) {
const page = new URLSearchParams(location.search).get('page');
if (page === 'gallery') return ;
const diagram = page === 'grouped' ? groupedPlatformLayout : commerceApiLayout;
return ;
}
+
+function Page() {
+ const { theme, toggle } = useTheme();
+ if (loaded && 'error' in loaded) return {loaded.error};
+ if (loaded) return ;
+ if (isLive()) return Waiting for a valid diagram…;
+ if (import.meta.env.DEV) return ;
+ return No diagram embedded. Create one with `stackmap deliver`.;
+}
+
+export function App() {
+ const live = useLiveStatus();
+ return (
+ <>
+
+
+ >
+ );
+}
diff --git a/packages/viewer/src/canvas/ArrowMarker.tsx b/packages/viewer/src/canvas/ArrowMarker.tsx
index 363fac2..4848e64 100644
--- a/packages/viewer/src/canvas/ArrowMarker.tsx
+++ b/packages/viewer/src/canvas/ArrowMarker.tsx
@@ -1,25 +1,36 @@
+import { NODE_TYPES, type NodeType } from '@stackmap/core';
+
export const ARROW_MARKER_ID = 'sm-arrow';
+/** Marker for an edge tinted by its source type (selection / trace, Q26). */
+export const arrowMarkerId = (tint: NodeType | null) => (tint ? `${ARROW_MARKER_ID}-${tint}` : ARROW_MARKER_ID);
const LENGTH = 7;
const HEIGHT = 6;
// Handle dots are 7px and paint above edges; the tip stops 4px short of the port so the dot can't hide it.
const TIP_GAP = 4;
+// One marker per colour: `context-stroke` would be neater but isn't reliable in every engine yet.
export function ArrowMarkerDefs() {
return (
-
-
-
+ {[null, ...NODE_TYPES].map((tint) => (
+
+
+
+ ))}
);
}
diff --git a/packages/viewer/src/canvas/DiagramCanvas.test.tsx b/packages/viewer/src/canvas/DiagramCanvas.test.tsx
index c23d9eb..034afa8 100644
--- a/packages/viewer/src/canvas/DiagramCanvas.test.tsx
+++ b/packages/viewer/src/canvas/DiagramCanvas.test.tsx
@@ -1,6 +1,7 @@
import { fireEvent, render } from '@testing-library/react';
import { describe, expect, it, vi } from 'vitest';
import { commerceApiLayout } from '../samples/commerce-api.layout';
+import { ExploreProvider } from '../explore/ExploreContext';
import { DiagramCanvas } from './DiagramCanvas';
const { nodeCardRenders } = vi.hoisted(() => ({ nodeCardRenders: vi.fn() }));
@@ -15,7 +16,11 @@ vi.mock('../card/NodeCard', () => ({
describe('DiagramCanvas', () => {
it('changes the viewport transform on wheel without re-rendering the cards', () => {
- const { container } = render();
+ const { container } = render(
+
+
+ ,
+ );
const stage = container.querySelector('.sm-stage')!;
const viewport = () => container.querySelector('.sm-viewport') as HTMLElement;
@@ -28,4 +33,16 @@ describe('DiagramCanvas', () => {
expect(viewport().style.transform).not.toBe(transformBefore);
expect(nodeCardRenders.mock.calls.length).toBe(rendersBefore);
});
+
+ it('selecting a card re-renders only the cards whose emphasis or tab stop changed', () => {
+ const { container } = render(
+
+
+ ,
+ );
+ nodeCardRenders.mockClear();
+ fireEvent.click(container.querySelector('.sm-card[data-card-id="orders"]')!);
+ // orders gains focus + the tab stop; edge (the default tab stop) loses it. The other four stay put.
+ expect([...new Set(nodeCardRenders.mock.calls.map(([id]) => id))].sort()).toEqual(['edge', 'orders']);
+ });
});
diff --git a/packages/viewer/src/canvas/DiagramCanvas.tsx b/packages/viewer/src/canvas/DiagramCanvas.tsx
index 213f297..8597b2b 100644
--- a/packages/viewer/src/canvas/DiagramCanvas.tsx
+++ b/packages/viewer/src/canvas/DiagramCanvas.tsx
@@ -1,13 +1,55 @@
-import { useMemo, useRef, useState, type ReactNode } from 'react';
-import type { LaidOutDiagram } from '@stackmap/core';
+import { useEffect, useMemo, useRef, useState, type KeyboardEvent, type ReactNode } from 'react';
+import type { LaidOutDiagram, Rect } from '@stackmap/core';
import { ZoomBar } from '../chrome/ZoomBar';
+import { useExplore } from '../explore/ExploreContext';
import { CanvasPanel } from './CanvasPanel';
import { Minimap } from './Minimap';
-import { toScene } from './scene';
+import { toScene, type Scene } from './scene';
import { SceneLayers } from './SceneLayers';
import { useZoom } from './useZoom';
+import type { Camera } from './useZoom';
import type { Transform } from './viewport';
-import { ViewportProvider } from './ViewportContext';
+import { CameraProvider, ContentProvider, ViewportProvider } from './ViewportContext';
+
+const PAN_STEP = 80;
+
+function union(rects: Rect[]): Rect | null {
+ if (!rects.length) return null;
+ const x = Math.min(...rects.map((r) => r.x));
+ const y = Math.min(...rects.map((r) => r.y));
+ return { x, y, width: Math.max(...rects.map((r) => r.x + r.width)) - x, height: Math.max(...rects.map((r) => r.y + r.height)) - y };
+}
+
+// Camera follows the explorer: a view fits its members (Overview fits everything, Q27); a revealing
+// selection (search, deep link) centres the node. The initial Overview needs nothing: useZoom already fit it.
+function useCameraEffects(scene: Scene, camera: Camera, restored: boolean) {
+ const { draft, state } = useExplore();
+ const rectOf = useMemo(() => new Map(scene.cards.map((c) => [c.node.id, c.rect])), [scene]);
+ // Compare against the previous view, not a "first run" flag: StrictMode re-runs effects on mount.
+ const shownView = useRef(restored ? state.view : null);
+ useEffect(() => {
+ if (shownView.current === state.view) return;
+ shownView.current = state.view;
+ const members = state.view ? (draft.views?.find((v) => v.id === state.view)?.nodes ?? []) : [];
+ const box = union(members.flatMap((id) => rectOf.get(id) ?? []));
+ if (box) camera.fitRect(box);
+ else camera.fit();
+ }, [state.view, draft, rectOf, camera]);
+ // The reveal a live reload restores from the hash is already on screen.
+ const restoredReveal = useRef(restored ? state.reveal : -1);
+ const latest = useRef({ state, draft });
+ latest.current = { state, draft };
+ useEffect(() => {
+ // Only a new reveal moves the camera, not every selection; read the rest from the latest render.
+ const { state: s, draft: d } = latest.current;
+ if (restoredReveal.current === s.reveal) return;
+ const r = s.reveal && s.selected ? rectOf.get(s.selected) : undefined;
+ if (!r) return;
+ // Inside the active view the fit already shows it; centring would undo the view's zoom (Q27).
+ const inView = s.view && d.views?.find((v) => v.id === s.view)?.nodes.includes(s.selected!);
+ if (!inView) camera.centerOn({ x: r.x + r.width / 2, y: r.y + r.height / 2 });
+ }, [state.reveal, rectOf, camera]);
+}
const GRID = 20;
const DOT = 1.2;
@@ -38,24 +80,58 @@ export function DiagramCanvas({ diagram, children }: { diagram: LaidOutDiagram;
const scene = useMemo(() => toScene(diagram), [diagram]);
const stageRef = useRef(null);
const viewport = useZoom(stageRef, scene.content);
+ // Exports frame everything drawn: edge routes can swing outside the card/frame box (U-turns).
+ const exportBox = useMemo(() => {
+ const points = scene.edges.flatMap((e) => e.points);
+ return union([scene.content, ...points.map((p) => ({ x: p.x, y: p.y, width: 0, height: 0 }))])!;
+ }, [scene]);
+ const { camera } = viewport;
+ const { emphasis, dispatch, state } = useExplore();
const [minimap, setMinimap] = useState(false);
const { x, y, k } = viewport.transform;
+ useCameraEffects(scene, camera, viewport.restored);
+
+ // Keys when the stage itself has focus (cards handle their own and stop propagation).
+ const onKeyDown = (e: KeyboardEvent) => {
+ const pan: Record = {
+ ArrowUp: [0, PAN_STEP],
+ ArrowDown: [0, -PAN_STEP],
+ ArrowLeft: [PAN_STEP, 0],
+ ArrowRight: [-PAN_STEP, 0],
+ };
+ if (pan[e.key]) camera.panBy(...pan[e.key]!, { instant: e.repeat });
+ else if (e.key === '+' || e.key === '=') camera.zoomIn();
+ else if (e.key === '-') camera.zoomOut();
+ else if (e.key === '0') camera.fit();
+ else if (e.key === 'Escape') dispatch({ type: 'clear' });
+ else return;
+ e.preventDefault();
+ };
return (
+
+
+ {/* Top-left chrome first in the DOM: Tab reaches search/lens/trace before the cards. */}
+ {children}
{/* Overlays are siblings of the stage, so wheel/drag on them never reaches d3-zoom. */}
@@ -67,8 +143,9 @@ export function DiagramCanvas({ diagram, children }: { diagram: LaidOutDiagram;
)}
- {children}
+
+
);
}
diff --git a/packages/viewer/src/canvas/Minimap.tsx b/packages/viewer/src/canvas/Minimap.tsx
index 222792c..77eb416 100644
--- a/packages/viewer/src/canvas/Minimap.tsx
+++ b/packages/viewer/src/canvas/Minimap.tsx
@@ -1,4 +1,4 @@
-import type { MouseEvent } from 'react';
+import { useRef, type PointerEvent } from 'react';
import type { Rect } from '@stackmap/core';
import { PANEL_CLASS, PANEL_STYLE } from '../chrome/ui';
import type { Scene } from './scene';
@@ -26,24 +26,39 @@ export function Minimap({ scene }: { scene: Scene }) {
height: Math.max(box.y + box.height, view.y + view.height) - y0,
};
- const onClick = (e: MouseEvent) => {
+ // Press to jump there; keep the button down and drag to steer the canvas live.
+ const dragging = useRef(false);
+ const toDiagram = (e: PointerEvent) => {
const ctm = e.currentTarget.getScreenCTM();
- if (!ctm) return;
- const p = new DOMPoint(e.clientX, e.clientY).matrixTransform(ctm.inverse());
+ return ctm && new DOMPoint(e.clientX, e.clientY).matrixTransform(ctm.inverse());
+ };
+ const onPointerDown = (e: PointerEvent) => {
+ const p = toDiagram(e);
+ if (!p) return;
+ dragging.current = true;
+ e.currentTarget.setPointerCapture(e.pointerId);
centerOn({ x: p.x, y: p.y });
};
+ const onPointerMove = (e: PointerEvent) => {
+ const p = dragging.current && toDiagram(e);
+ if (p) centerOn({ x: p.x, y: p.y }, { instant: true });
+ };
+ const stop = () => void (dragging.current = false);
return (