diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d282dfcf7..eb581b0ea 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -12,8 +12,8 @@ jobs: matrix: node-version: [18, 20, 22, 24] steps: - - uses: actions/checkout@v5 - - uses: actions/setup-node@v5 + - uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5.1.0 + - uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0 with: node-version: ${{ matrix.node-version }} cache: npm @@ -25,16 +25,20 @@ jobs: run: npm test working-directory: archify + - name: Verify community Hermes adapter + if: matrix.node-version == 22 + run: node --test integrations/hermes-agent/test/plugin-contract.test.mjs + webm-artifact: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v5 - - uses: actions/setup-node@v5 + - uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5.1.0 + - uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0 with: node-version: 22 - name: Provision a known Chrome runtime id: setup-chrome - uses: browser-actions/setup-chrome@v2 + uses: browser-actions/setup-chrome@48ad923757ca74d66703209fe939badbdf80f2f4 # v2.2.0 with: chrome-version: stable install-dependencies: true @@ -45,41 +49,11 @@ jobs: working-directory: archify env: ARCHIFY_CHROME: ${{ steps.setup-chrome.outputs.chrome-path }} - - name: Verify packaged examples and Reader Layout boundaries in a real browser - run: node --test test/desktop-reader-browser.test.mjs test/reader-layout-browser.test.mjs - working-directory: archify - env: - ARCHIFY_CHROME: ${{ steps.setup-chrome.outputs.chrome-path }} - ARCHIFY_CHROME_NO_SANDBOX: '1' - - name: Verify SVG export cleanup and live state isolation - run: node --test test/export-cleanup-browser.test.mjs - working-directory: archify - env: - ARCHIFY_CHROME: ${{ steps.setup-chrome.outputs.chrome-path }} - ARCHIFY_CHROME_NO_SANDBOX: '1' - - name: Install offline font test parser + - name: Install browser test dependencies run: npm ci --ignore-scripts --no-audit --no-fund working-directory: archify - - name: Verify offline fonts and independent SVG/raster exports - run: node --test test/offline-font-browser.test.mjs - working-directory: archify - env: - ARCHIFY_CHROME: ${{ steps.setup-chrome.outputs.chrome-path }} - ARCHIFY_CHROME_NO_SANDBOX: '1' - - name: Verify all five diagram modes localize Viewer interactions - run: node --test test/i18n.test.mjs - working-directory: archify - env: - ARCHIFY_CHROME: ${{ steps.setup-chrome.outputs.chrome-path }} - ARCHIFY_CHROME_NO_SANDBOX: '1' - - name: Verify Semantic Radar collision and drag behavior - run: node --test test/semantic-radar.test.mjs - working-directory: archify - env: - ARCHIFY_CHROME: ${{ steps.setup-chrome.outputs.chrome-path }} - ARCHIFY_CHROME_NO_SANDBOX: '1' - - name: Verify Viewer chrome, camera, motion and input interactions - run: node --test --test-concurrency=2 test/viewer-chrome-layout.test.mjs test/viewer-camera-browser.test.mjs test/motion-governor-browser.test.mjs test/finder-browser.test.mjs test/intent-trace-browser.test.mjs test/semantic-lens-browser.test.mjs test/route-probe-browser.test.mjs test/guided-views-browser.test.mjs test/focus-browser.test.mjs test/export-browser.test.mjs test/viewer-identifiers-browser.test.mjs + - name: Run shared browser regression gate + run: npm run test:browser working-directory: archify env: ARCHIFY_CHROME: ${{ steps.setup-chrome.outputs.chrome-path }} @@ -88,8 +62,8 @@ jobs: zip-freshness: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v5 - - uses: actions/setup-node@v5 + - uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5.1.0 + - uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0 with: node-version: 22 - name: Rebuild archify.zip and compare exact bytes @@ -103,8 +77,8 @@ jobs: published-update-manifest: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v5 - - uses: actions/setup-node@v5 + - uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5.1.0 + - uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0 with: node-version: 22 - name: Public notifier manifest must reference an existing stable Release @@ -163,8 +137,8 @@ jobs: os: [ubuntu-latest, macos-latest, windows-latest] runs-on: ${{ matrix.os }} steps: - - uses: actions/checkout@v5 - - uses: actions/setup-node@v5 + - uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5.1.0 + - uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0 with: node-version: 22 - name: Extract packaged skill @@ -185,9 +159,29 @@ jobs: - name: Validate packaged skill without installing dependencies run: node scripts/package-smoke.mjs + windows-test-portability: + name: Windows test portability + runs-on: windows-latest + steps: + - uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5.1.0 + - uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0 + with: + node-version: 22 + - name: Install test dependencies + run: npm ci --ignore-scripts + working-directory: archify + - name: Verify Windows-sensitive regression fixtures + run: >- + node --test + --test-name-pattern="Git checkout preserves|clean staging preserves index modes|workflow migration cleanup failure|preview runs from an installed skill" + archify/test/checkout-line-endings.test.mjs + archify/test/clean-skill-staging.test.mjs + archify/test/workflow-migration.test.mjs + archify/test/cli.test.mjs + deploy-pages: if: github.event_name == 'push' && github.ref == 'refs/heads/main' - needs: [test, webm-artifact, zip-freshness, published-update-manifest, package-smoke] + needs: [test, webm-artifact, zip-freshness, published-update-manifest, package-smoke, windows-test-portability] runs-on: ubuntu-latest concurrency: group: github-pages @@ -200,7 +194,7 @@ jobs: name: github-pages url: ${{ steps.deployment.outputs.page_url }} steps: - - uses: actions/checkout@v5 + - uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5.1.0 - name: Reject an obsolete deployment run id: deployment-head env: @@ -215,13 +209,13 @@ jobs: fi - name: Configure GitHub Pages if: steps.deployment-head.outputs.current == 'true' - uses: actions/configure-pages@v6 + uses: actions/configure-pages@45bfe0192ca1faeb007ade9deae92b16b8254a0d # v6.0.0 - name: Upload checked documentation if: steps.deployment-head.outputs.current == 'true' - uses: actions/upload-pages-artifact@v5 + uses: actions/upload-pages-artifact@fc324d3547104276b827a68afc52ff2a11cc49c9 # v5.0.0 with: path: docs - name: Deploy checked documentation if: steps.deployment-head.outputs.current == 'true' id: deployment - uses: actions/deploy-pages@v5 + uses: actions/deploy-pages@cd2ce8fcbc39b97be8ca5fce6e763baed58fa128 # v5.0.0 diff --git a/.github/workflows/contributor-cards.yml b/.github/workflows/contributor-cards.yml new file mode 100644 index 000000000..7497b6b1b --- /dev/null +++ b/.github/workflows/contributor-cards.yml @@ -0,0 +1,104 @@ +name: Contributor cards + +on: + pull_request: + paths: + - 'tools/contributor-cards/**' + - '.github/workflows/contributor-cards.yml' + push: + branches: [main, dev] + paths: + - 'tools/contributor-cards/**' + - '.github/workflows/contributor-cards.yml' + pull_request_target: + types: [closed] + branches: [main] + workflow_dispatch: + inputs: + pr: + description: 'Merged pull request number' + required: true + type: string + publish: + description: 'Publish the card and post or update its thank-you reply' + type: boolean + default: false + +permissions: + contents: read + +jobs: + check: + if: github.event_name == 'pull_request' || github.event_name == 'push' + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5 + with: + persist-credentials: false + - uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5 + with: + node-version: 22 + - uses: browser-actions/setup-chrome@48ad923757ca74d66703209fe939badbdf80f2f4 # v2 + id: chrome + with: + chrome-version: stable + install-dependencies: true + - name: Install Chinese fallback font + run: sudo apt-get update && sudo apt-get install --no-install-recommends -y fonts-noto-cjk + - name: Verify records, publication retries and real card layout + env: + ARCHIFY_CHROME: ${{ steps.chrome.outputs.chrome-path }} + ARCHIFY_CHROME_NO_SANDBOX: '1' + run: node --test tools/contributor-cards/test/*.test.mjs + + card: + if: >- + github.repository == 'tt-a1i/archify' && + (github.event_name == 'workflow_dispatch' || + (github.event_name == 'pull_request_target' && + github.event.pull_request.merged == true && + github.event.pull_request.user.type == 'User')) + runs-on: ubuntu-latest + timeout-minutes: 10 + concurrency: + group: contributor-card-${{ github.event.pull_request.number || inputs.pr }} + cancel-in-progress: false + permissions: + contents: write + pull-requests: write + steps: + # Privileged runs use only the default branch, never PR head/merge refs. + - uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5 + with: + ref: ${{ github.event.repository.default_branch }} + persist-credentials: false + - uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5 + with: + node-version: 22 + - uses: browser-actions/setup-chrome@48ad923757ca74d66703209fe939badbdf80f2f4 # v2 + id: chrome + with: + chrome-version: stable + install-dependencies: true + - name: Install Chinese fallback font + run: sudo apt-get update && sudo apt-get install --no-install-recommends -y fonts-noto-cjk + - name: Render card and optionally publish + env: + GH_TOKEN: ${{ github.token }} + CARD_PR: ${{ github.event.pull_request.number || inputs.pr }} + CARD_PUBLISH: ${{ github.event_name == 'pull_request_target' || inputs.publish }} + ARCHIFY_CHROME: ${{ steps.chrome.outputs.chrome-path }} + ARCHIFY_CHROME_NO_SANDBOX: '1' + shell: bash + run: | + args=(--out "$RUNNER_TEMP/contributor-card") + if [[ "$CARD_PUBLISH" == 'true' ]]; then args+=(--publish); fi + node tools/contributor-cards/cli.mjs "${args[@]}" + - name: Keep downloadable card and rendering evidence + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + with: + name: contributor-card-${{ github.event.pull_request.number || inputs.pr }} + path: ${{ runner.temp }}/contributor-card/ + retention-days: 7 + if-no-files-found: error diff --git a/.github/workflows/dsh.yml b/.github/workflows/dsh.yml index eb66f2666..bbd12d033 100644 --- a/.github/workflows/dsh.yml +++ b/.github/workflows/dsh.yml @@ -34,10 +34,10 @@ jobs: name: DSH package contract runs-on: ubuntu-latest steps: - - uses: actions/checkout@v5 + - uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5.1.0 with: fetch-depth: 0 - - uses: actions/setup-node@v5 + - uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0 with: node-version: 22 - name: Static adapter contracts @@ -49,13 +49,13 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 30 steps: - - uses: actions/checkout@v5 + - uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5.1.0 with: fetch-depth: 0 - - uses: actions/setup-node@v5 + - uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0 with: node-version: 22 - - uses: pnpm/action-setup@v5 + - uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v5.0.0 with: version: 10 - name: Real tarball install, discovery, package-smoke, uninstall @@ -74,13 +74,13 @@ jobs: runs-on: ${{ matrix.os }} timeout-minutes: 40 steps: - - uses: actions/checkout@v5 + - uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5.1.0 with: fetch-depth: 0 - - uses: actions/setup-node@v5 + - uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0 with: node-version: 22 - - uses: pnpm/action-setup@v5 + - uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v5.0.0 with: version: 10 - name: Adapter staging regressions on the release host diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 4733a40d7..377d2c9f7 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -11,11 +11,11 @@ jobs: release: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v5 + - uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5.1.0 - name: Fetch exact tag object shell: bash run: git fetch --force --no-tags origin "+refs/tags/${GITHUB_REF_NAME}:refs/tags/${GITHUB_REF_NAME}" - - uses: actions/setup-node@v5 + - uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0 with: node-version: 22 cache: npm @@ -28,7 +28,7 @@ jobs: working-directory: archify - name: Provision a known Chrome runtime id: setup-chrome - uses: browser-actions/setup-chrome@v2 + uses: browser-actions/setup-chrome@48ad923757ca74d66703209fe939badbdf80f2f4 # v2.2.0 with: chrome-version: stable install-dependencies: true @@ -39,6 +39,12 @@ jobs: working-directory: archify env: ARCHIFY_CHROME: ${{ steps.setup-chrome.outputs.chrome-path }} + - name: Run shared browser regression gate + run: npm run test:browser + working-directory: archify + env: + ARCHIFY_CHROME: ${{ steps.setup-chrome.outputs.chrome-path }} + ARCHIFY_CHROME_NO_SANDBOX: '1' - name: Tag must match package.json version run: | pkg_version="$(node -p "require('./archify/package.json').version")" @@ -93,7 +99,7 @@ jobs: - name: Committed zip must match the build (same gate as CI) run: cmp -s /tmp/archify-built.zip archify.zip - name: Create GitHub Release with the zip attached - uses: softprops/action-gh-release@v3 + uses: softprops/action-gh-release@efb35369e0ad2afab669f228072c1b0d510eae64 # v3.0.3 with: files: archify.zip generate_release_notes: true diff --git a/.github/workflows/star-history.yml b/.github/workflows/star-history.yml index 637979d0e..523277b6b 100644 --- a/.github/workflows/star-history.yml +++ b/.github/workflows/star-history.yml @@ -16,7 +16,7 @@ jobs: update: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v5 + - uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5.1.0 # Upstream PR #6: Node 24 migration, before v1.0.6's chart-rendering changes. - uses: narayann7/star-history-action@00dfada13f106e4114ee46728aa415857078e76c with: diff --git a/.gitignore b/.gitignore index 499aeb548..2be3b0cb7 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,5 @@ node_modules/ .claude/ .lore/ /.impeccable/ +__pycache__/ +*.py[cod] diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 000000000..c24d318d6 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,13 @@ +# Agent instructions + +Follow [CONTRIBUTING.md](CONTRIBUTING.md) for repository changes and [REVIEWING.md](REVIEWING.md) for reviews. + +## Live Archify installations + +Install, update, reinstall, or remove a live Archify installation only when the user explicitly requests that action. Repository editing, testing, reviewing, publishing, or syncing does not authorize installation. This applies to Skills CLI, manual ZIP copies, and DSH plugins. Reuse authorization already given for the same action and scope; ask only for unresolved decisions. + +- Keep the source, Skill/package, target agents, and global/project scope within the request. Avoid bulk operations or force flags that expand that scope. An update notification is informational. +- Before a Skills CLI install, discover the selected source with `npx -y skills add --list --full-depth` (default source: `tt-a1i/archify`) and verify exactly one Skill named `archify`. For an explicitly requested durable local source, inspect that checkout. Stop on failed or ambiguous discovery. +- Use the canonical remote source by default. Use a local source only when requested, from a durable checkout; live symlinks must not point into temporary directories or disposable worktrees. +- Preserve unrelated files and existing unmanaged destinations. Report a destination conflict before replacing it unless that exact replacement is already explicitly authorized; use the installer's documented conflict handling within the approved scope. +- Report the action, source, targets, and result, including skipped or failed destinations. A partial installation is not a complete success. diff --git a/CHANGELOG.md b/CHANGELOG.md index 1b841d017..30a3f04cb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,11 +6,18 @@ All notable changes are documented here. Format loosely follows [Keep a Changelo > Development identity: `v2.17.0-dev.1`. Not a stable release. +### Added +- **Hermes Agent community opt-in.** `integrations/hermes-agent` is a Skill-only directory plugin: it registers the existing Node Archify `SKILL.md` for Hermes. The documented install is `hermes skills install skills-sh/tt-a1i/archify/archify -y` (published GitHub Skill). A checkout symlink remains the local-dev path. Hermes still runs `node bin/archify.mjs`. This is not an official Nous product and is not an agent-switcher target. +- **GitHub Copilot agent-switcher target.** The Start page agent switcher, README EN/ZH quick start, and landing copy add `github-copilot` alongside `cursor`, `codex`, `claude-code`, and `opencode`. It installs the same checked Skill and zero-dependency renderers via `npx skills add tt-a1i/archify --skill archify --agent github-copilot ...`; no vendor-specific fork, renderer, or schema behavior was added. + ### Fixed +- **Conservative VS15 text-width estimates.** Text-presentation selectors retain the base character's estimated width instead of forcing every sequence to one unit. This prevents wide labels, including CJK text whose font ignores VS15, from passing layout checks at half their estimated width. Actual glyph widths remain font-dependent; some text-presentation labels may now need more space. VS16 emoji estimates and text without selectors are unchanged. +- **Readable renderer failures.** `render` and direct renderer entry points format classified input, schema, layout, and output failures without Node stacks. Filesystem failures identify the failing operation instead of labelling output errors as unreadable input; successful artifact bytes and existing validation/delivery receipt formats remain unchanged. - **Architecture Delta baseline arrowheads (#433).** Removed and rerouted baseline relationships retain their marker definitions in the composed Delta SVG, preserving their authored direction alongside current relationships. - **Compare rollback recovery (#438).** If restoring a previous output fails, compare preserves its recovery directory and reports backup-to-target paths instead of deleting the remaining backups during cleanup. - **固定提交的来源校验 (#420)。** 校验忽略本地 Git replacement refs,始终读取指定提交的原始对象,避免替换后的文件或行范围造成误接受或误拒绝;保留原有来源链接、诊断与用户 Git 配置。 - **导出的独立 SVG 声明 UTF-8 编码。** 架构图「下载 SVG」和架构对比(compare)导出的 SVG 文档现在以 `` 声明开头;缺少声明时,部分消费方不按 XML 规范默认 UTF-8 而猜测编码,导致中文等非 ASCII 文本乱码。 +- **Architecture Delta provenance visibility.** Repository revision, provider, link-mode, and location-representation changes now produce a separate redacted provenance receipt and visible Delta notice instead of an empty authored-change claim. Provenance-only comparisons keep graph counts at zero, leave Review disabled, avoid false SVG change states, and carry the same truthful distinction into Share Cards. - **Compare 输入快照一致性 (#400)。** 原始输入校验使用首次读取的字节快照,使其与回执哈希和差异计算保持一致;读取后原文件发生变化不会影响本次比较,非法原始字段仍会被拒绝。 - **DSH plugin refresh.** Adapter 0.2.0 pins the current Archify development snapshot, includes the newer runtime and CLI fixes, and targets DSH 0.1.2-rc.1. Release metadata replaces the frozen 0.1.0 packaging source; the tarball uses the canonical clean-Skill stager and documents independent plugin upgrades. - **Machine-readable CLI argument failures (#330).** `validate --json` and `deliver --json` now keep invalid or missing option values, unknown options and diagram types, unsupported option combinations, and usage errors inside one versioned failure receipt on stdout. These failures use the `arguments` stage, stable diagnostic codes, and exit status 2, while human-mode stderr behavior remains unchanged. diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 000000000..1f02e0c93 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,5 @@ +# Claude Code instructions + +Follow [AGENTS.md](AGENTS.md). + +@AGENTS.md diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index a28747b5b..a3920a41e 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -73,6 +73,19 @@ ARCHIFY_CHROME="/path/to/chrome" node --test test/desktop-reader-browser.test.mj A browser test skipped because Chrome was unavailable is **skipped**, not passed. Follow [the delivery contract](archify/references/delivery-contract.md) for visual evidence, receipts, and failure stages. Successful validation, atomic delivery, browser checks, and perceptual review establish different claims. +PR CI and tag releases run the same browser regression gate: + +```sh +cd archify +ARCHIFY_CHROME="/path/to/chrome" npm run test:browser +``` + +This command requires a usable Chrome/Chromium and fails when none is available. +Its maintained file list is in `scripts/run-browser-tests.mjs`; add new browser +suites there so both workflows keep the same coverage. Ordinary `npm test` +retains optional browser skips. Real WebM decoding and site-language integration +remain in the separate `npm run test:webm` gate used by both workflows. + ## Packages and generated artifacts Viewer maintenance starts in [`viewer/`](viewer/README.md). Edit its source diff --git a/README.md b/README.md index b1ef38e45..b7551e9b2 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,7 @@ **Turn a codebase or system description into a polished, interactive system map — directly in chat.** -Archify is a Node.js rendering and validation system for Cursor, Claude Code, Codex CLI, and OpenCode. Agents produce typed JSON IR; Archify deterministically compiles it into HTML/SVG. +Archify is a Node.js rendering and validation system for Cursor, Claude Code, Codex CLI, OpenCode, and GitHub Copilot. Agents produce typed JSON IR; Archify deterministically compiles it into HTML/SVG. - **Open it and present** — five diagram types, four presets, dark/light themes, built-in brand marks, and finite motion - **Review architecture changes before merge** — compare two validated snapshots as Before / Delta / After, with exact added, removed, changed, moved, and rerouted facts @@ -25,7 +25,7 @@ Archify is a Node.js rendering and validation system for Cursor, Claude Code, Co **Current development version:** `v2.17.0-dev.1`. See [Changelog](CHANGELOG.md#unreleased). -**[Project page](https://tt-a1i.github.io/archify/)** · **[Scenario guide](https://tt-a1i.github.io/archify/guide.html)** · **[Proof Lab](https://tt-a1i.github.io/archify/gallery.html)** +**[Project](https://tt-a1i.github.io/archify/)** · **[Guide](https://tt-a1i.github.io/archify/guide.html)** · **[Proof Lab](https://tt-a1i.github.io/archify/gallery.html)** · **[X](https://x.com/t20000622yy)** ```bash npx skills add tt-a1i/archify -g @@ -116,7 +116,7 @@ npx skills use tt-a1i/archify@archify --agent codex [DSH community opt-in](integrations/deepseek-harness/README.md): `dsh plugin --profile web add @tt-a1i/archify-dsh@0.1.0` -The [agent switcher](https://tt-a1i.github.io/archify/start.html?agent=cursor&type=architecture) covers `cursor`, `codex`, `claude-code`, and `opencode`. For Raven's manual ZIP install, extract [`archify.zip`](archify.zip) into `~/.raven/workspace/skills`; it yields `~/.raven/workspace/skills/archify`. Raven is not a switcher target. +The [agent switcher](https://tt-a1i.github.io/archify/start.html?agent=cursor&type=architecture) covers `cursor`, `codex`, `claude-code`, `opencode`, and `github-copilot`. For Raven's manual ZIP install, extract [`archify.zip`](archify.zip) into `~/.raven/workspace/skills`; it yields `~/.raven/workspace/skills/archify`. Raven is not a switcher target. Archify may GET the fixed stable manifest solely to show an optional reminder; it never downloads or installs updates. Successful checks wait about 72 hours (±20%); active use retries failures after 6, then 24 hours. The server sees normal HTTP metadata (IP and time), but receives no version, Agent, project data, prompts, account/device ID, or ETag. You decide whether and when to update. Set `ARCHIFY_UPDATE_CHECK_DISABLED=1` to disable networking and reminder-state writes. @@ -140,17 +140,17 @@ Continue with focused requests such as `add Redis`, `move auth to the left`, or ## Choose the right diagram -| Type | Best for | Include in your prompt | +| Type | Copyable prompt | Example | |---|---|---| -| **Architecture** | Components, services, storage, boundaries | Scope, core components, primary path | -| **Workflow** | CI/CD, approvals, tool calls, runbooks | Participants, order, branches, exceptions | -| **Sequence** | API calls, cache fallback, auth, async traces | Callers, callees, returns, timing | -| **Data Flow** | Pipelines, lineage, PII, consumers | Sources, transforms, stores, boundaries | -| **Lifecycle** | States, retries, waits, terminal outcomes | States, events, retry and cancellation paths | +| **Architecture** | `Map runtime components, boundaries, and primary path with Archify.` | [Production deployment](https://tt-a1i.github.io/archify/gallery/artifacts/production-deployment.architecture.html) | +| **Workflow** | `Map participants, branches, approvals, and retries with Archify.` | [Agent tool-call workflow](https://tt-a1i.github.io/archify/gallery/artifacts/agent-tool-call.workflow.html) | +| **Sequence** | `Map API request, responses, and failure paths with Archify.` | [Async job round trip](https://tt-a1i.github.io/archify/gallery/artifacts/async-job-roundtrip.sequence.html) | +| **Data Flow** | `Map sources, transforms, stores, and boundaries with Archify.` | [Event stream dataflow](https://tt-a1i.github.io/archify/gallery/artifacts/event-stream.dataflow.html) | +| **Lifecycle** | `Map states, events, retries, and terminal outcomes with Archify.` | [Agent run lifecycle](https://tt-a1i.github.io/archify/gallery/artifacts/agent-run.lifecycle.html) | Architecture's optional `deployment-ownership` profile fails closed when authored owners, region placement, private database scope, or named crossings are missing; it is never implicit and does not inspect live infrastructure. See the [checked deployment proof](https://tt-a1i.github.io/archify/gallery.html#proof-deployment-ownership). -For design or PR review, Architecture Delta compares validated Before / Delta / After snapshots with a machine receipt. Select an authored change or play one finite, viewer-only Review; it infers no impact, risk, or merge safety. +Architecture Delta compares validated Before / Delta / After snapshots with a machine receipt. Provenance stays separate from graph changes. Select an authored change or play a finite, viewer-only Review; neither infers impact, risk, or merge safety. `node archify/bin/archify.mjs compare architecture base.json head.json architecture-delta.html --json` @@ -265,6 +265,7 @@ The complete generation and viewer contract lives in [`archify/SKILL.md`](archif | **opencode** | `~/.config/opencode/skills/`, `.opencode/skills/`, or `.agents/skills/` | Full renderer + validation workflow | | **Claude.ai** | Upload `archify.zip` under Settings → Capabilities → Skills | Depends on Node.js access in the sandbox | | **Project Knowledge** | Upload `archify.zip` to the project | Prompt-driven architecture fallback | +| **Hermes Agent** | `hermes skills install skills-sh/tt-a1i/archify/archify -y` | Community opt-in; Node `>=18`. [Details](integrations/hermes-agent/README.md). | | **DeepSeek Harness** | Opt-in: `dsh plugin --profile web add @tt-a1i/archify-dsh@0.1.0`. Invoke: `Use the archify skill to map this repository's runtime architecture.` Remove: `dsh plugin --profile web remove @tt-a1i/archify-dsh`. | Community integration for developer-preview `@deepseek-ai/dsh@0.1.0-rc.6`; Node `^22.19.0 \|\| >=24.0.0`; not an official DeepSeek product. No telemetry. Shell files need exact workspace paths, not Web Produced Files. [Details](integrations/deepseek-harness/README.md). | ## Reference and scope @@ -282,7 +283,7 @@ Automatic Mermaid parsing, general-purpose auto-layout, hosted sharing, and WYSI ## Contributing -Issues, pull requests, and real-world diagrams are welcome. Start with the [contribution guide](CONTRIBUTING.md), use the reproducible bug form for failures, or submit a validated diagram through the [community showcase form](https://github.com/tt-a1i/archify/issues/new?template=showcase.yml). · [LINUX DO](https://linux.do) +Issues, pull requests, and real-world diagrams are welcome. Start with the [contribution guide](CONTRIBUTING.md), use the reproducible bug form for failures, or submit a validated diagram through the [community showcase form](https://github.com/tt-a1i/archify/issues/new?template=showcase.yml). For security vulnerabilities, see [SECURITY.md](SECURITY.md). · [LINUX DO](https://linux.do) ## Star History diff --git a/README_EN.md b/README_EN.md index b1ef38e45..b7551e9b2 100644 --- a/README_EN.md +++ b/README_EN.md @@ -12,7 +12,7 @@ **Turn a codebase or system description into a polished, interactive system map — directly in chat.** -Archify is a Node.js rendering and validation system for Cursor, Claude Code, Codex CLI, and OpenCode. Agents produce typed JSON IR; Archify deterministically compiles it into HTML/SVG. +Archify is a Node.js rendering and validation system for Cursor, Claude Code, Codex CLI, OpenCode, and GitHub Copilot. Agents produce typed JSON IR; Archify deterministically compiles it into HTML/SVG. - **Open it and present** — five diagram types, four presets, dark/light themes, built-in brand marks, and finite motion - **Review architecture changes before merge** — compare two validated snapshots as Before / Delta / After, with exact added, removed, changed, moved, and rerouted facts @@ -25,7 +25,7 @@ Archify is a Node.js rendering and validation system for Cursor, Claude Code, Co **Current development version:** `v2.17.0-dev.1`. See [Changelog](CHANGELOG.md#unreleased). -**[Project page](https://tt-a1i.github.io/archify/)** · **[Scenario guide](https://tt-a1i.github.io/archify/guide.html)** · **[Proof Lab](https://tt-a1i.github.io/archify/gallery.html)** +**[Project](https://tt-a1i.github.io/archify/)** · **[Guide](https://tt-a1i.github.io/archify/guide.html)** · **[Proof Lab](https://tt-a1i.github.io/archify/gallery.html)** · **[X](https://x.com/t20000622yy)** ```bash npx skills add tt-a1i/archify -g @@ -116,7 +116,7 @@ npx skills use tt-a1i/archify@archify --agent codex [DSH community opt-in](integrations/deepseek-harness/README.md): `dsh plugin --profile web add @tt-a1i/archify-dsh@0.1.0` -The [agent switcher](https://tt-a1i.github.io/archify/start.html?agent=cursor&type=architecture) covers `cursor`, `codex`, `claude-code`, and `opencode`. For Raven's manual ZIP install, extract [`archify.zip`](archify.zip) into `~/.raven/workspace/skills`; it yields `~/.raven/workspace/skills/archify`. Raven is not a switcher target. +The [agent switcher](https://tt-a1i.github.io/archify/start.html?agent=cursor&type=architecture) covers `cursor`, `codex`, `claude-code`, `opencode`, and `github-copilot`. For Raven's manual ZIP install, extract [`archify.zip`](archify.zip) into `~/.raven/workspace/skills`; it yields `~/.raven/workspace/skills/archify`. Raven is not a switcher target. Archify may GET the fixed stable manifest solely to show an optional reminder; it never downloads or installs updates. Successful checks wait about 72 hours (±20%); active use retries failures after 6, then 24 hours. The server sees normal HTTP metadata (IP and time), but receives no version, Agent, project data, prompts, account/device ID, or ETag. You decide whether and when to update. Set `ARCHIFY_UPDATE_CHECK_DISABLED=1` to disable networking and reminder-state writes. @@ -140,17 +140,17 @@ Continue with focused requests such as `add Redis`, `move auth to the left`, or ## Choose the right diagram -| Type | Best for | Include in your prompt | +| Type | Copyable prompt | Example | |---|---|---| -| **Architecture** | Components, services, storage, boundaries | Scope, core components, primary path | -| **Workflow** | CI/CD, approvals, tool calls, runbooks | Participants, order, branches, exceptions | -| **Sequence** | API calls, cache fallback, auth, async traces | Callers, callees, returns, timing | -| **Data Flow** | Pipelines, lineage, PII, consumers | Sources, transforms, stores, boundaries | -| **Lifecycle** | States, retries, waits, terminal outcomes | States, events, retry and cancellation paths | +| **Architecture** | `Map runtime components, boundaries, and primary path with Archify.` | [Production deployment](https://tt-a1i.github.io/archify/gallery/artifacts/production-deployment.architecture.html) | +| **Workflow** | `Map participants, branches, approvals, and retries with Archify.` | [Agent tool-call workflow](https://tt-a1i.github.io/archify/gallery/artifacts/agent-tool-call.workflow.html) | +| **Sequence** | `Map API request, responses, and failure paths with Archify.` | [Async job round trip](https://tt-a1i.github.io/archify/gallery/artifacts/async-job-roundtrip.sequence.html) | +| **Data Flow** | `Map sources, transforms, stores, and boundaries with Archify.` | [Event stream dataflow](https://tt-a1i.github.io/archify/gallery/artifacts/event-stream.dataflow.html) | +| **Lifecycle** | `Map states, events, retries, and terminal outcomes with Archify.` | [Agent run lifecycle](https://tt-a1i.github.io/archify/gallery/artifacts/agent-run.lifecycle.html) | Architecture's optional `deployment-ownership` profile fails closed when authored owners, region placement, private database scope, or named crossings are missing; it is never implicit and does not inspect live infrastructure. See the [checked deployment proof](https://tt-a1i.github.io/archify/gallery.html#proof-deployment-ownership). -For design or PR review, Architecture Delta compares validated Before / Delta / After snapshots with a machine receipt. Select an authored change or play one finite, viewer-only Review; it infers no impact, risk, or merge safety. +Architecture Delta compares validated Before / Delta / After snapshots with a machine receipt. Provenance stays separate from graph changes. Select an authored change or play a finite, viewer-only Review; neither infers impact, risk, or merge safety. `node archify/bin/archify.mjs compare architecture base.json head.json architecture-delta.html --json` @@ -265,6 +265,7 @@ The complete generation and viewer contract lives in [`archify/SKILL.md`](archif | **opencode** | `~/.config/opencode/skills/`, `.opencode/skills/`, or `.agents/skills/` | Full renderer + validation workflow | | **Claude.ai** | Upload `archify.zip` under Settings → Capabilities → Skills | Depends on Node.js access in the sandbox | | **Project Knowledge** | Upload `archify.zip` to the project | Prompt-driven architecture fallback | +| **Hermes Agent** | `hermes skills install skills-sh/tt-a1i/archify/archify -y` | Community opt-in; Node `>=18`. [Details](integrations/hermes-agent/README.md). | | **DeepSeek Harness** | Opt-in: `dsh plugin --profile web add @tt-a1i/archify-dsh@0.1.0`. Invoke: `Use the archify skill to map this repository's runtime architecture.` Remove: `dsh plugin --profile web remove @tt-a1i/archify-dsh`. | Community integration for developer-preview `@deepseek-ai/dsh@0.1.0-rc.6`; Node `^22.19.0 \|\| >=24.0.0`; not an official DeepSeek product. No telemetry. Shell files need exact workspace paths, not Web Produced Files. [Details](integrations/deepseek-harness/README.md). | ## Reference and scope @@ -282,7 +283,7 @@ Automatic Mermaid parsing, general-purpose auto-layout, hosted sharing, and WYSI ## Contributing -Issues, pull requests, and real-world diagrams are welcome. Start with the [contribution guide](CONTRIBUTING.md), use the reproducible bug form for failures, or submit a validated diagram through the [community showcase form](https://github.com/tt-a1i/archify/issues/new?template=showcase.yml). · [LINUX DO](https://linux.do) +Issues, pull requests, and real-world diagrams are welcome. Start with the [contribution guide](CONTRIBUTING.md), use the reproducible bug form for failures, or submit a validated diagram through the [community showcase form](https://github.com/tt-a1i/archify/issues/new?template=showcase.yml). For security vulnerabilities, see [SECURITY.md](SECURITY.md). · [LINUX DO](https://linux.do) ## Star History diff --git a/README_ZH.md b/README_ZH.md index a99570a92..b38b8481d 100644 --- a/README_ZH.md +++ b/README_ZH.md @@ -8,7 +8,7 @@ **在对话里,把代码仓库或系统描述变成漂亮、可靠、可交互的系统地图。** -Archify 是一套基于 Node.js 的渲染与校验系统,并以 Agent Skill 的形式支持 Raven、Cursor、Claude Code、Codex CLI 和 OpenCode。Agent 负责生成 Typed JSON IR,Archify 再校验并确定性编译为便携、独立的 HTML/SVG 成品。 +Archify 是一套基于 Node.js 的渲染与校验系统,并以 Agent Skill 的形式支持 Raven、Cursor、Claude Code、Codex CLI、OpenCode 和 GitHub Copilot。Agent 负责生成 Typed JSON IR,Archify 再校验并确定性编译为便携、独立的 HTML/SVG 成品。 - **打开就是成品** —— 五种技术图、四套视觉预设、深浅主题、内置品牌徽标,以及显式启用的有限动态 - **合并前先看清架构变化** —— 把两份已校验快照对比为 Before / Delta / After,准确区分新增、删除、语义变化、移动和重路由 @@ -21,7 +21,7 @@ Archify 是一套基于 Node.js 的渲染与校验系统,并以 Agent Skill **当前开发版本:** `v2.17.0-dev.1`。详见[版本历史](CHANGELOG.md#unreleased)。 -**[在线项目页](https://tt-a1i.github.io/archify/)** · **[场景选图指南](https://tt-a1i.github.io/archify/guide.html)** · **[Proof Lab](https://tt-a1i.github.io/archify/gallery.html)** +**[在线项目页](https://tt-a1i.github.io/archify/)** · **[场景选图指南](https://tt-a1i.github.io/archify/guide.html)** · **[Proof Lab](https://tt-a1i.github.io/archify/gallery.html)** · **[在 X 关注项目动态](https://x.com/t20000622yy)** ```bash npx skills add tt-a1i/archify -g @@ -109,7 +109,7 @@ npx -y skills add tt-a1i/archify --skill archify --agent cursor --global --copy npx skills use tt-a1i/archify@archify --agent codex ``` -DeepSeek Harness(社区集成、显式启用):运行 `dsh plugin --profile web add @tt-a1i/archify-dsh@0.1.0`;参见[兼容范围、限制与安全说明](integrations/deepseek-harness/README.md)。[Agent 切换器](https://tt-a1i.github.io/archify/start.html?agent=cursor&type=architecture)只为 `cursor`、`codex`、`claude-code` 和 `opencode` 生成命令。Raven 仅支持 ZIP 手动安装:将 [`archify.zip`](archify.zip) 解压到 `~/.raven/workspace/skills`,解压后会得到 `~/.raven/workspace/skills/archify`;Raven 不属于切换器目标。 +DeepSeek Harness(社区集成、显式启用):运行 `dsh plugin --profile web add @tt-a1i/archify-dsh@0.1.0`;参见[兼容范围、限制与安全说明](integrations/deepseek-harness/README.md)。[Agent 切换器](https://tt-a1i.github.io/archify/start.html?agent=cursor&type=architecture)只为 `cursor`、`codex`、`claude-code`、`opencode` 和 `github-copilot` 生成命令。Raven 仅支持 ZIP 手动安装:将 [`archify.zip`](archify.zip) 解压到 `~/.raven/workspace/skills`,解压后会得到 `~/.raven/workspace/skills/archify`;Raven 不属于切换器目标。 安装后的 Skill 包含一个低频、失败静默的发布检查,它最多只显示可选更新提醒,绝不会自行下载或安装更新。一次成功检查后,下次网络请求通常约在 72 小时(±20%)后发出;检查失败后,活跃使用可能在首次 6 小时、后续 24 小时退避到期时重试。请求只访问 `https://tt-a1i.github.io/archify/skill-updates/archify/stable.json`。服务端会自然获得 IP、请求时间和常规 HTTP 元数据;检查器不会发送本地版本、Agent、项目数据、用户输入、账户/设备标识,也不会保存或回传 ETag。是否更新以及何时更新始终由你决定。如需完全关闭检查(包括网络请求和提醒状态写入),请在 Agent 环境中设置 `ARCHIFY_UPDATE_CHECK_DISABLED=1`。 @@ -133,20 +133,20 @@ DeepSeek Harness(社区集成、显式启用):运行 `dsh plugin --profile ## 选择合适的图表 -| 类型 | 最适合 | Prompt 中应包含 | +| 类型 | 可复制的 Prompt | 已验证示例 | |---|---|---| -| **Architecture** | 组件、服务、存储和系统边界 | 范围、核心组件、主要路径 | -| **Workflow** | CI/CD、审批、工具调用、Runbook | 参与者、顺序、分支、异常 | -| **Sequence** | API 调用、缓存回源、鉴权、异步链路 | 调用方、被调用方、返回、时序 | -| **Data Flow** | 数据管线、血缘、PII、下游消费者 | 来源、转换、存储、边界 | -| **Lifecycle** | 状态、重试、等待、终态 | 状态、事件、重试与取消路径 | +| **Architecture** | `使用 Archify 梳理运行时组件、边界和主要路径。` | [生产部署架构](https://tt-a1i.github.io/archify/gallery/artifacts/production-deployment.architecture.html) | +| **Workflow** | `使用 Archify 梳理参与者、分支、审批和重试。` | [Agent 工具调用工作流](https://tt-a1i.github.io/archify/gallery/artifacts/agent-tool-call.workflow.html) | +| **Sequence** | `使用 Archify 梳理这个 API 请求、响应和失败路径。` | [异步任务往返序列](https://tt-a1i.github.io/archify/gallery/artifacts/async-job-roundtrip.sequence.html) | +| **Data Flow** | `使用 Archify 梳理来源、转换、存储和边界。` | [事件流数据流](https://tt-a1i.github.io/archify/gallery/artifacts/event-stream.dataflow.html) | +| **Lifecycle** | `使用 Archify 梳理状态、事件、重试和终态。` | [Agent 运行生命周期](https://tt-a1i.github.io/archify/gallery/artifacts/agent-run.lifecycle.html) | 做生产部署评审时,Architecture 可以按需启用 `deployment-ownership` 工程画像:负责人、单一区域归属、数据库私有边界或边界穿越机制缺失时会直接阻断。 它不会被静默开启,只校验作者写入的事实,不代表线上基础设施已经核验。可查看 [通过校验的部署证明](https://tt-a1i.github.io/archify/gallery.html#proof-deployment-ownership)。 -做设计或 PR 评审时,Architecture Delta 生成已校验的 Before / Delta / After 和机器回执。精确选择任一作者变更,或播放一次有限 Review;全程只读,不推断影响、风险或合并安全。 +做设计或 PR 评审时,Architecture Delta 生成已校验的 Before / Delta / After 和机器回执。仓库 revision、provider 和链接模式的变化会作为独立来源事实显示,不会把图实体错误标记为已变化。精确选择任一已创作的图变更,或播放一次有限 Review;全程只读,不推断影响、风险或合并安全。 `node archify/bin/archify.mjs compare architecture base.json head.json architecture-delta.html --json` @@ -261,6 +261,7 @@ node bin/archify.mjs deliver workflow examples/agent-tool-call.workflow.json /tm | **opencode** | `~/.config/opencode/skills/`、`.opencode/skills/` 或 `.agents/skills/` | 完整 Renderer + Validation 工作流 | | **Claude.ai** | Settings → Capabilities → Skills 中上传 `archify.zip` | 取决于沙箱是否提供 Node.js | | **Project Knowledge** | 把 `archify.zip` 上传到项目 | Prompt 驱动的 Architecture Fallback | +| **Hermes Agent** | 显式启用:`hermes skills install skills-sh/tt-a1i/archify/archify -y` | 社区 Skill-only;Node `>=18`;不是 Nous 官方产品。没有遥测。非切换器目标。[详情](integrations/hermes-agent/README.md)。 | | **DeepSeek Harness** | 显式启用:`dsh plugin --profile web add @tt-a1i/archify-dsh@0.1.0`;调用:`Use the archify skill to map this repository's runtime architecture.`;卸载:`dsh plugin --profile web remove @tt-a1i/archify-dsh`。 | 面向开发者预览版 `@deepseek-ai/dsh@0.1.0-rc.6` 的社区集成;Node `^22.19.0 \|\| >=24.0.0`;不是 DeepSeek 官方产品。没有遥测;shell 文件不会自动进入 Web Produced Files,请返回精确工作区路径。[详情](integrations/deepseek-harness/README.md)。 | Claude.ai 中的上传入口: @@ -285,9 +286,8 @@ Claude.ai 中的上传入口: ## 参与贡献 -欢迎提交 Issue、Pull Request 和真实场景图。请先阅读[贡献指南](CONTRIBUTING.md);遇到问题时使用可复现 Bug 表单,也可以通过[社区 Showcase 表单](https://github.com/tt-a1i/archify/issues/new?template=showcase.yml)提交已验证成品。 - -较大的功能或行为调整请先通过 Issue 对齐价值、兼容边界和非目标,再基于最新 `main` 开发。一个 PR 尽量只解决一个问题;核心代码和回归测试先行,生成物最后统一重建。Archify 坚持 Agent-first,优先完善稳定的机器可读诊断和现有权威合同,避免新增容易与 CLI 漂移的重复说明。 · [LINUX DO](https://linux.do) +欢迎提交 Issue、Pull Request 和真实场景图。请先阅读[贡献指南](CONTRIBUTING.md);遇到问题时使用可复现 Bug 表单,也可以通过[社区 Showcase 表单](https://github.com/tt-a1i/archify/issues/new?template=showcase.yml)提交已验证成品。 如需报告安全漏洞,请遵循[安全报告说明](SECURITY.md)。 +较大的功能或行为调整请先通过 Issue 对齐价值、兼容边界和非目标,再基于最新 `dev` 开发。一个 PR 尽量只解决一个问题;核心代码和回归测试先行,生成物最后统一重建。Archify 坚持 Agent-first,优先完善稳定的机器可读诊断和现有权威合同,避免新增容易与 CLI 漂移的重复说明。 · [LINUX DO](https://linux.do) ## Star History diff --git a/archify.zip b/archify.zip index ede3ba354..62ac63aaa 100644 Binary files a/archify.zip and b/archify.zip differ diff --git a/archify/SKILL.md b/archify/SKILL.md index 4011ba543..95f94d87f 100644 --- a/archify/SKILL.md +++ b/archify/SKILL.md @@ -18,7 +18,7 @@ Use this bounded path for ordinary generation. Do not read the optional Viewer R 1. Choose `architecture`, `workflow`, `sequence`, `dataflow`, or `lifecycle` from the question. 2. Read one matching schema in `schemas/`, `schemas/common.schema.json`, and one matching JSON example in `examples/`. Read only those files. Fresh authorship means new stable IDs, domain wording, and layout; use the example for field shape, not facts. New workflow sources use `schema_version: 2` and its readable layout contract; keep `schema_version: 1` only when preserving an existing workflow's fixed geometry. When real product identity matters, query `node bin/archify.mjs brands "" --json`; read `references/brand-marks.md` only for an unknown brand with a user-provided URL. -3. Artifact first: the next tool action must write the candidate. Write the candidate before inspecting renderer internals. Do not plan exact coordinates in prose. Start with one clear main path, short side branches, sparse labels, and at most 12 primary nodes. Set `meta.quality_profile` to `"showcase"` unless the user explicitly requests a dense `standard` map. Start with automatic routes and labels. Do not add `via`, `channelX`, `channelY`, or `labelAt` before a diagnostic calls for one; apply at most one diagnosed geometry control per repair. +3. Artifact first: the next tool action must write the candidate. Write the candidate before inspecting renderer internals. Do not plan exact coordinates in prose. Start with one clear main path, short side branches, and sparse labels. Use roughly 12 primary nodes as an initial readability budget. Preserve every node and relationship required by the user's question; for larger diagrams, group related content where the selected schema supports it. Set `meta.quality_profile` to `"showcase"` unless the user explicitly requests a dense `standard` map. Start with automatic routes and labels. Do not add `via`, `channelX`, `channelY`, or `labelAt` before a diagnostic calls for one; repair one diagnosed geometry problem at a time. Before manual routing, read the relevant routing rules in `references/authoring-contract.md` and inspect layout evidence: use `validate --layout-json` for architecture/workflow; for other types, use validation diagnostics and the rendered SVG geometry. Check whether unnecessary agent-added controls disable automatic port spread; preserve user-required route intent. If several edges share a constrained channel, plan the smallest coupled change from measured geometry and validate it together. 4. Validate after every candidate edit and immediately before handoff: ```bash @@ -103,6 +103,8 @@ After delivery, collect bounded desktop evidence without modifying or rerenderin node bin/archify.mjs visual-check --json ``` +For workflow viewport overflow, read [Workflow viewport repair](references/authoring-contract.md#workflow-viewport-repair) before the next layout edit. + `visual-check` collects automated browser evidence from the exact delivered HTML without modifying or rerendering it. Its machine-readable measurements and screenshots do not approve perceptual polish. Follow `references/delivery-contract.md` for the canonical receipt fields, coverage, sidecars, exit behavior, and supplementary manual-record requirements. Keep the three claims separate: `deliver` proves deterministic artifact checks, `visual-check` proves bounded behavior in a real browser, and perceptual visual review requires an actual human or image-capable reviewer. Report browser evidence and perceptual review independently. An unconstrained glance can support only perceptual review; use the canonical delivery contract when recording supplementary manual browser work or handling an environmental failure. diff --git a/archify/assets/template.html b/archify/assets/template.html index a762d9d36..f00ab8375 100644 --- a/archify/assets/template.html +++ b/archify/assets/template.html @@ -4084,6 +4084,12 @@ .t-messagebus { fill: var(--messagebus-stroke); } .t-external { fill: var(--external-stroke); } + /* Relationship labels share the exact color token used by their path. */ + .t-edge-default { fill: var(--arrow); } + .t-edge-emphasis { fill: var(--arrow-emphasis); } + .t-edge-security { fill: var(--security-stroke); } + .t-edge-dashed { fill: var(--database-stroke); } + /* Semantic Sigils are small authored role stamps, not icons from a brand pack and not viewer controls. Prefix selectors with svg so the canonical export's SVG-only stylesheet collector keeps them automatically. */ @@ -4348,6 +4354,7 @@ html[data-embed="true"] .semantic-lens-overlay { display: none; } svg[data-focus-active] [data-node-id], svg[data-focus-active] [data-edge-from] { opacity: 0.13; } + svg[data-focus-active] [data-lifecycle-rail] { opacity: 0; } svg[data-focus-active] [data-focus-match] { opacity: 1; } svg[data-focus-active] [data-focus-selected] { filter: drop-shadow(0 0 10px var(--frontend-stroke)); @@ -4430,6 +4437,7 @@ neighborhood focus. It changes emphasis only; exported geometry and the relationship's original solid/dashed visual language stay untouched. */ svg[data-relationship-preview-active] [data-focus-match] { opacity: 0.18; } + svg[data-relationship-preview-active] [data-lifecycle-rail] { opacity: 0; } svg[data-relationship-preview-active] [data-relationship-preview], svg[data-relationship-preview-active] [data-relationship-preview-node] { opacity: 1; } svg[data-relationship-preview-active] [data-relationship-preview] { @@ -4527,6 +4535,7 @@ cloned edge geometry; the authored edge remains untouched underneath. */ svg[data-intent-trace-active] [data-node-id], svg[data-intent-trace-active] [data-edge-from] { opacity: 0.2; } + svg[data-intent-trace-active] [data-lifecycle-rail] { opacity: 0; } svg[data-intent-trace-active] [data-intent-trace-match] { opacity: 1; } svg[data-intent-trace-active] [data-intent-trace-selected] { filter: drop-shadow(0 0 10px var(--frontend-stroke)); @@ -7768,7 +7777,7 @@

Card Title 3

if (options.reveal !== false && Archify.view && typeof Archify.view.reveal === 'function') { Archify.view.reveal(result.nodeIds, { includeNeighbors: false, reason: 'reachability' }); } - requestLensPlacement(); + placeRelationshipLens(); return true; } function reachabilitySnapshot() { @@ -8248,7 +8257,7 @@

Card Title 3

target.removeAttribute('data-preview-active'); }); renderRelationshipCopyAction(); - requestLensPlacement(); + placeRelationshipLens(); } function previewRelationship(button, options) { options = options || {}; @@ -8275,7 +8284,7 @@

Card Title 3

if (!chip.hidden && options.direct !== true) chip.setAttribute('data-relationship-previewing', 'true'); activeRelationshipPreview = button; renderRelationshipPulse(key); - requestLensPlacement(); + placeRelationshipLens(); } function syncRelationshipPreview() { var next = pinnedRelationship || focusedRelationship || hoveredRelationship; @@ -8556,7 +8565,10 @@

Card Title 3

} var lensFrame = 0; function placeRelationshipLens() { - lensFrame = 0; + if (lensFrame) { + cancelAnimationFrame(lensFrame); + lensFrame = 0; + } if (chip.hidden || activeIds.length !== 1) return; var node = svg.querySelector('[data-node-id="' + activeIds[0] + '"]'); if (!node) return; @@ -8757,7 +8769,7 @@

Card Title 3

chip.hidden = options.hideChip === true || normalized.length !== 1 || selectionMode; if (!chip.hidden) { renderRelationshipLens(normalized[0], byId); - requestLensPlacement(); + placeRelationshipLens(); } if (options.updateUrl !== false) { var key = options.urlKey || 'focus'; @@ -8843,7 +8855,7 @@

Card Title 3

relationsBtn.setAttribute('aria-label', viewerText(expanded ? 'viewer.passport.relations.show' : 'viewer.passport.relations.hide')); - requestLensPlacement(); + placeRelationshipLens(); }); relationshipList.addEventListener('click', function (event) { var button = event.target.closest('[data-relationship-target]'); @@ -8959,7 +8971,7 @@

Card Title 3

reachabilitySnapshot: reachabilitySnapshot, inspectRelationship: inspectRelationship, inspectRelationshipById: inspectRelationshipById, - reposition: requestLensPlacement, + reposition: placeRelationshipLens, relationship: function () { var record = pinnedRelationshipRecord(); return record ? { id: record.id || null, key: record.key, from: record.from, to: record.to, label: record.label } : null; diff --git a/archify/bin/archify.mjs b/archify/bin/archify.mjs index 9ea8ab9a0..de3a1b162 100755 --- a/archify/bin/archify.mjs +++ b/archify/bin/archify.mjs @@ -22,7 +22,7 @@ function usage() { archify migrate workflow --to-schema 2 [--json] archify inspect archify check - archify visual-check [--json] + archify visual-check [--json] [--out-dir ] archify guide [scenario or question] [--json] [--lang en|zh] archify brands [name, alias, domain, or category] [--json] archify brands capture [--json] @@ -138,6 +138,27 @@ function extractRepoRootArgs(args) { return { rest, repoRoot: repoRoot ? path.resolve(repoRoot) : undefined }; } +function extractOutDirArgs(args) { + const rest = []; + let outDir; + for (let index = 0; index < args.length; index += 1) { + const arg = args[index]; + if (arg === '--out-dir') { + outDir = args[index + 1]; + if (!outDir || outDir.startsWith('--')) fail('--out-dir requires a directory path.'); + index += 1; + continue; + } + if (arg.startsWith('--out-dir=')) { + outDir = arg.slice('--out-dir='.length); + if (!outDir) fail('--out-dir requires a directory path.'); + continue; + } + rest.push(arg); + } + return { rest, outDir: outDir ? path.resolve(outDir) : undefined }; +} + function rendererEnv(quality, repoRoot, diagnosticJson = false) { return { ...(quality ? { ARCHIFY_QUALITY_PROFILE: quality } : {}), @@ -228,6 +249,7 @@ const COMPOSITION_FIXES = { 'composition/ambiguous-corridor': ['adjust route/via or channel coordinates so unrelated relationships do not visually merge'], 'composition/container-border-run': ['route across the frame perpendicularly through a clear opening'], 'composition/label-route-clearance': ['adjust labelAt, labelDx, labelDy, labelSegment, message y, or the other relationship route'], + 'composition/label-canvas-containment': ['adjust labelAt, labelDx, labelDy, or labelSegment so the label rect stays inside the viewBox, or enlarge meta.viewBox'], 'composition/desktop-readability': ['reduce the viewBox width, shorten node copy, widen affected nodes, or split the diagram so node context remains at least 6px at a 1440px desktop viewport'], 'composition/micro-segment': ['move the route/channel/via point so every visible segment is at least 8px'], 'composition/short-interior-segment': ['move the route/channel/via point so every interior turn has at least 16px'], @@ -237,12 +259,12 @@ function checkerDiagnostics(checker) { const diagnostics = []; for (const issue of checker?.composition?.issues || []) { if (issue.severity !== 'error') continue; - const { severity, code, relationship, ...evidence } = issue; + const { severity, code, relationship, nodeId, ...evidence } = issue; diagnostics.push(diagnostic({ code, severity, message: `Final artifact failed ${code}.`, - subject: relationship ? { relationship } : { check: 'composition' }, + subject: relationship ? { relationship } : { check: 'composition', ...(nodeId ? { nodeId } : {}) }, evidence, supportedFixes: COMPOSITION_FIXES[code] || [], })); @@ -1271,7 +1293,8 @@ function commandCheck(args) { if (result.status !== 0) exitFrom(result); } -async function commandVisualCheck(args) { +async function commandVisualCheck(rawArgs) { + const { rest: args, outDir } = extractOutDirArgs(rawArgs); const json = args.includes('--json'); const knownOptions = new Set(['--json']); const unknown = args.filter((arg) => arg.startsWith('--') && !knownOptions.has(arg)); @@ -1288,7 +1311,7 @@ async function commandVisualCheck(args) { let result; try { - result = await runVisualCheck({ artifactPath: positional[0] }); + result = await runVisualCheck({ artifactPath: positional[0], outDir }); } catch (error) { if (json) { console.log(JSON.stringify({ @@ -1312,11 +1335,12 @@ async function commandVisualCheck(args) { if (json) { console.log(JSON.stringify(result.receipt, null, 2)); } else { + const sidecarDirectory = outDir || path.dirname(result.receipt.artifact.path); console.log(`automated browser evidence ${result.receipt.status}: ${result.receipt.artifact.path}`); console.log(`visual-check containment ${result.receipt.containment.status}; captures ${result.receipt.captures.status}; perceptual visual review pending`); - console.log(`receipt ${path.join(path.dirname(result.receipt.artifact.path), result.receipt.sidecars.receipt)}`); + console.log(`receipt ${path.join(sidecarDirectory, result.receipt.sidecars.receipt)}`); if (result.receipt.captures.contactSheet) { - console.log(`contact sheet ${path.join(path.dirname(result.receipt.artifact.path), result.receipt.captures.contactSheet)}`); + console.log(`contact sheet ${path.join(sidecarDirectory, result.receipt.captures.contactSheet)}`); } if (result.receipt.error) console.error(result.receipt.error); } diff --git a/archify/bin/visual-check.mjs b/archify/bin/visual-check.mjs index 8adb154b8..2e6376cc2 100644 --- a/archify/bin/visual-check.mjs +++ b/archify/bin/visual-check.mjs @@ -56,10 +56,11 @@ function screenshotKey(width, height, theme) { return `${width}x${height}:${theme}`; } -export function sidecarPaths(artifactPath) { +export function sidecarPaths(artifactPath, { outDir } = {}) { const artifact = path.resolve(artifactPath); - const stem = artifact.replace(/\.html?$/i, ''); - const base = `${stem}.visual-check`; + const stem = path.basename(artifact).replace(/\.html?$/i, ''); + const directory = outDir ? path.resolve(outDir) : path.dirname(artifact); + const base = path.join(directory, `${stem}.visual-check`); const screenshots = CAPTURE_VIEWPORTS.flatMap(({ width, height }) => THEMES.map((theme) => ({ width, height, @@ -432,6 +433,29 @@ export class ChromeVisualBrowser { && typeof Archify.viewerChromeLayout.receipt === 'function' ? Archify.viewerChromeLayout.receipt() : null; + // Read rendered boxes only; source ownership, offsets and hard pins are unknown. + var workflowLanes = svg ? Array.from(svg.querySelectorAll('rect[data-composition-frame-kind="lane"]')).map(function (lane) { + var rect = lane.getBoundingClientRect(); + var members = Array.from(svg.querySelectorAll('g[data-node-id]')).map(function (node) { + var box = node.querySelector('rect'); + if (!box) return null; + var bounds = box.getBoundingClientRect(); + if (bounds.top < rect.top - 1 || bounds.bottom > rect.bottom + 1 + || bounds.left < rect.left - 1 || bounds.right > rect.right + 1) return null; + return { id: node.getAttribute('data-node-id'), top: bounds.top, bottom: bounds.bottom }; + }).filter(Boolean); + var top = members.length ? members.reduce(function (minimum, node) { return Math.min(minimum, node.top); }, Infinity) : null; + var bottom = members.length ? members.reduce(function (maximum, node) { return Math.max(maximum, node.bottom); }, -Infinity) : null; + return { + frameId: lane.getAttribute('data-composition-frame-id'), + heightPx: Math.round(rect.height), + nodeCount: members.length, + nodeIds: members.slice(0, 12).map(function (node) { return node.id; }), + nodeSpanPx: top === null ? null : Math.round(bottom - top), + spaceAboveNodesPx: top === null ? null : Math.round(top - rect.top), + spaceBelowNodesPx: bottom === null ? null : Math.round(rect.bottom - bottom) + }; + }).sort(function (a, b) { return b.heightPx - a.heightPx; }).slice(0, 6) : []; return { innerWidth: window.innerWidth, innerHeight: window.innerHeight, @@ -441,6 +465,7 @@ export class ChromeVisualBrowser { readerWidth: reader ? reader.getBoundingClientRect().width : 0, diagramWidth: diagramWidth, viewBoxWidth: viewBoxWidth, + workflowLanes: workflowLanes, minimumProjectedNodeTextPx: minimum ? minimum.projectedFontPx : null, minimumProjectedNodeText: minimum ? minimum.text : null, minimumProjectedNodeTextDetail: minimum ? minimum.detail : null, @@ -534,6 +559,7 @@ function observation({ width, height, theme, metrics }) { readerWidth: Number(metrics.readerWidth) || null, diagramWidth: Number(metrics.diagramWidth) || null, viewBoxWidth: Number(metrics.viewBoxWidth) || null, + ...(metrics.workflowLanes?.length ? { workflowLanes: metrics.workflowLanes } : {}), minimumProjectedNodeTextPx, minimumProjectedNodeText: metrics.minimumProjectedNodeText || null, minimumProjectedNodeTextDetail: metrics.minimumProjectedNodeTextDetail || null, @@ -604,9 +630,17 @@ function observationDiagnostics({ artifact, allObservations, readabilityObservat scrollHeight: entry.scrollHeight, overflowX: entry.overflowX, overflowY: entry.overflowY, + ...(entry.overflowY && entry.workflowLanes?.length ? { + workflowLanes: entry.workflowLanes, + measurement: 'CSS pixels; rendered node boxes geometrically contained in each lane frame; spaces include headers and routing, not guaranteed removable space', + } : {}), }, supportedFixes: [ - `contain the rendered layout within ${entry.width}x${entry.height}, then rerun visual-check`, + ...(entry.overflowY && entry.workflowLanes?.length ? [ + 'run validate workflow --layout-json and compare the tallest rendered lane frames with source lanes, col and yOffset; frame IDs are rendered indices, not source lane IDs', + 'where ownership and explicit geometry permit, distribute stacked steps across logical columns and meaningful lanes before increasing yOffset; preserve nodes, branches, labels and hard pins', + 'read references/authoring-contract.md#workflow-viewport-repair, then validate and deliver the changed source before rerunning visual-check on the new artifact; this is inspection guidance, not a verified coordinate fix', + ] : [`contain the rendered layout within ${entry.width}x${entry.height}, then rerun visual-check`]), ], })); } @@ -681,6 +715,8 @@ function baseReceipt({ artifactPath, artifact, outputs, chrome }) { viewerChrome: { status: 'fail', viewports: [] }, captures: { status: 'fail', screenshots: [], contactSheet: null }, sidecars: { + ...(path.dirname(outputs.receipt) !== path.dirname(artifactPath) + ? { directory: path.dirname(outputs.receipt) } : {}), receipt: path.basename(outputs.receipt), contactSheet: path.basename(outputs.contactSheet), }, @@ -693,6 +729,7 @@ function persistReceipt(outputs, receipt) { export async function runVisualCheck({ artifactPath, + outDir, chromePath, resolveChrome = findChrome, browserFactory = async (resolvedChrome) => new ChromeVisualBrowser(resolvedChrome), @@ -701,7 +738,8 @@ export async function runVisualCheck({ const artifact = path.resolve(artifactPath); if (!/\.html?$/i.test(artifact)) throw new Error('visual-check requires an .html artifact.'); const artifactBytes = fs.readFileSync(artifact); - const outputs = sidecarPaths(artifact); + const outputs = sidecarPaths(artifact, { outDir }); + fs.mkdirSync(path.dirname(outputs.receipt), { recursive: true }); cleanupCaptureSidecars(outputs); safeUnlink(outputs.receipt); diff --git a/archify/delta/architecture-delta.mjs b/archify/delta/architecture-delta.mjs index 7ca38d62f..de99f52dc 100644 --- a/archify/delta/architecture-delta.mjs +++ b/archify/delta/architecture-delta.mjs @@ -45,6 +45,32 @@ function normalizeRepository(repository) { }; } +const PROVENANCE_FIELDS = ['url', 'revision', 'provider', 'link_mode']; + +function provenanceSide(repository) { + if (!repository) return null; + return { + revision: repository.revision, + ...(repository.provider !== undefined ? { provider: repository.provider } : {}), + ...(repository.link_mode !== undefined ? { linkMode: repository.link_mode } : {}), + }; +} + +function provenanceChange(base, head) { + if (equal(base, head)) return undefined; + const changedFields = !base || !head + ? ['/repository'] + : PROVENANCE_FIELDS + .filter((field) => !equal(base[field], head[field])) + .map((field) => `/${field}`) + .sort(codepointOrder); + return { + changedFields, + base: provenanceSide(base), + head: provenanceSide(head), + }; +} + function normalizeComponent(component) { return { ...component, @@ -280,7 +306,8 @@ export function compareArchitecture(base, head, evidence = {}) { kind: (after || before).kind, label: (after || before).label, })); - const provenanceChanged = !equal(baseRepository, headRepository); + const provenance = provenanceChange(baseRepository, headRepository); + const provenanceChanged = Boolean(provenance); return { schemaVersion: 1, @@ -313,6 +340,7 @@ export function compareArchitecture(base, head, evidence = {}) { provenanceChanged, }, changes: { components, connections, boundaries }, + ...(provenance ? { provenance } : {}), identity: { components: 'components[].id', connections: 'connections[].id (required)', @@ -703,15 +731,39 @@ function expectedReviewTargetSignature(row) { const total = (summary, key) => summary.components[key] + summary.connections[key] + summary.boundaries[key]; +function provenanceDetail(provenance) { + if (provenance === undefined) return 'Repository metadata changed; field details are unavailable in this receipt.'; + const value = (side, field) => { + if (!side) return 'undeclared'; + if (field === '/revision') return side.revision?.slice(0, 8) || 'none'; + if (field === '/provider') return side.provider ?? 'automatic'; + if (field === '/link_mode') return side.linkMode ?? 'default'; + if (field === '/repository') return 'declared'; + return 'changed'; + }; + return provenance.changedFields.map((field) => { + if (field === '/url') return '/url: repository location changed'; + return `${field}: ${value(provenance.base, field)} → ${value(provenance.head, field)}`; + }).join(' · '); +} + export function renderArchitectureDeltaHtml({ receipt, baseSvg, deltaSvg, headSvg, baseHtml = '', headHtml = '', artifactCss }) { const rows = architectureDeltaChangeRows(receipt); const changed = total(receipt.summary, 'changed'); const proof = receipt.proofLevel === 'revision-pinned' ? 'REVISION-PINNED INPUTS' : 'AUTHORED SNAPSHOTS'; + const provenanceChanged = receipt.summary.provenanceChanged === true; + const provenanceFields = receipt.provenance?.changedFields?.join(', ') || 'repository metadata'; + const provenanceDescription = provenanceChanged ? provenanceDetail(receipt.provenance) : ''; + const provenanceNotice = provenanceChanged + ? `` + : ''; const rowHtml = rows.length ? rows.map((row, index) => { const label = row.headLabel || row.baseLabel || row.head?.label || row.base?.label || row.label || row.id; const targetSignature = expectedReviewTargetSignature(row); return `
  • `; - }).join('\n') : '
  • No authored architecture changes.
  • '; + }).join('\n') : provenanceChanged + ? '
  • No component, relationship, or boundary changes; repository provenance changed.
  • ' + : '
  • No authored architecture changes.
  • '; const baseView = baseHtml ? `` : baseSvg; @@ -726,17 +778,18 @@ ${artifactCss} :root{color-scheme:dark;--d-add:#34d399;--d-remove:#fb7185;--d-change:#fbbf24;--d-move:#7dd3fc;--d-focus:#7dd3fc;--d-ink:#e6edf5;--d-muted:#8aa0b5;--d-line:#25384a} *{box-sizing:border-box}body{margin:0;overflow-x:hidden;background:#071019;color:var(--d-ink);font-family:"JetBrains Mono",ui-monospace,SFMono-Regular,Menlo,monospace}.proof-page{width:min(1600px,calc(100vw - 64px));margin:auto;padding:30px 0 42px}.proof-head{display:grid;grid-template-columns:minmax(0,1fr) auto;gap:28px;align-items:end;padding-bottom:20px;border-bottom:1px solid var(--d-line)}.eyebrow{margin:0 0 8px;color:#7dd3fc;font:700 11px/1.2 ui-monospace,SFMono-Regular,Menlo,monospace;letter-spacing:.14em}.proof-head h1{margin:0;font-size:clamp(32px,4vw,56px);line-height:.96;letter-spacing:-.04em}.subtitle{margin:12px 0 0;color:var(--d-muted);font-size:14px}.metrics{display:flex;gap:9px}.metric{min-width:86px;padding:11px 13px;border:1px solid var(--d-line);border-radius:8px;background:#0b1722}.metric strong{display:block;font:700 23px/1 ui-monospace,SFMono-Regular,Menlo,monospace}.metric span{display:block;margin-top:6px;color:var(--d-muted);font:700 9px/1 ui-monospace,SFMono-Regular,Menlo,monospace;letter-spacing:.1em}.add strong{color:var(--d-add)}.remove strong{color:var(--d-remove)}.change strong{color:var(--d-change)} .proof-tools{display:flex;align-items:center;justify-content:space-between;gap:20px;margin:16px 0 10px}.view-switch{display:inline-flex;padding:3px;border:1px solid var(--d-line);border-radius:8px;background:#0a141e}.view-switch button,.utility,.review-step{border:0;border-radius:6px;background:transparent;color:var(--d-muted);padding:8px 14px;font:700 11px/1 ui-monospace,SFMono-Regular,Menlo,monospace;cursor:pointer}.view-switch button[aria-selected="true"]{background:#173047;color:#fff}.utility{border:1px solid var(--d-line)}.view-switch button:focus-visible,.utility:focus-visible,.review-step:focus-visible,.change-row:focus-visible{outline:2px solid var(--d-focus);outline-offset:2px}.utility:disabled,.review-step:disabled{cursor:not-allowed;opacity:.45}.legend{display:flex;gap:16px;color:var(--d-muted);font:650 10px/1 ui-monospace,SFMono-Regular,Menlo,monospace}.legend span{display:inline-flex;align-items:center;gap:6px}.legend i{width:22px;border-top:3px solid currentColor}.legend .add{color:var(--d-add)}.legend .remove{color:var(--d-remove)}.legend .remove i{border-top-style:dashed}.legend .change{color:var(--d-change)}.legend .change i{border-top-style:dotted}.legend .move{color:var(--d-move)}.legend .move i{border-top-style:double} +.provenance-change{display:flex;align-items:center;gap:12px;margin:14px 0 0;padding:10px 13px;border:1px solid rgba(125,211,252,.45);border-radius:8px;background:rgba(125,211,252,.07);color:var(--d-muted);font:650 11px/1.35 ui-monospace,SFMono-Regular,Menlo,monospace}.provenance-change strong{color:var(--d-focus)}.provenance-change span{margin-left:auto;color:var(--d-muted)} .review-strip{display:grid;grid-template-columns:auto auto auto auto minmax(0,1fr);align-items:center;gap:5px;margin:0 0 10px;padding:7px 8px;border-block:1px solid var(--d-line);font-family:ui-monospace,SFMono-Regular,Menlo,monospace}.review-step{min-height:34px;border:1px solid var(--d-line);padding-inline:11px}.review-step[aria-pressed="true"]{border-color:var(--d-focus);color:var(--d-ink)}.review-status{min-width:0;padding-left:9px;color:var(--d-muted);font-size:10px;line-height:1.3;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.review-status strong{color:var(--d-ink);font-weight:750}.review-status[data-state="unavailable"]{color:var(--d-remove)} .canvas{overflow:hidden;border:1px solid var(--d-line);border-radius:10px;background:#09141e;padding:12px;min-height:520px}.canvas svg{display:block;width:100%;height:auto;max-height:72vh}.canvas[hidden]{display:none}.snapshot-frame{display:block;width:100%;height:min(76vh,920px);min-height:620px;border:0;border-radius:6px;background:#071019}.canvas[data-view="base"],.canvas[data-view="head"]{padding:0}.canvas[data-view="delta"] [data-delta-state="same"]{opacity:.38}.canvas[data-delta-review-active]{--review-same-opacity:.14;--review-change-opacity:.28}.canvas[data-delta-review-active] [data-delta-state="same"]{opacity:var(--review-same-opacity)!important}.canvas[data-delta-review-active] [data-delta-state]:not([data-delta-state="same"]):not([data-delta-review-current]){opacity:var(--review-change-opacity)!important}.canvas[data-delta-review-active] [data-delta-review-current]{opacity:1!important;transition:opacity .16s ease-out}g[data-node-id][data-delta-state="added"]>rect:last-of-type{stroke:var(--d-add)!important;stroke-width:3!important}g[data-node-id][data-delta-state="removed"]>rect:last-of-type{stroke:var(--d-remove)!important;stroke-width:3!important;stroke-dasharray:7 5}g[data-node-id][data-delta-state="changed"]>rect:last-of-type{stroke:var(--d-change)!important;stroke-width:3!important;stroke-dasharray:2 3}g[data-node-id][data-delta-state="moved"]>rect:last-of-type,g[data-node-id][data-delta-state="moved-from"]>rect:last-of-type{stroke:var(--d-move)!important;stroke-width:3!important;stroke-dasharray:8 3 2 3}g[data-node-id][data-delta-state="moved-from"],path[data-delta-state="moved-from"]{opacity:.42}path[data-delta-state="added"]{stroke:var(--d-add)!important;stroke-width:3!important}path[data-delta-state="removed"]{stroke:var(--d-remove)!important;stroke-width:3!important;stroke-dasharray:7 5!important}path[data-delta-state="changed"]{stroke:var(--d-change)!important;stroke-width:3!important;stroke-dasharray:2 3!important}path[data-delta-state="rerouted"],path[data-delta-state="moved-from"]{stroke:var(--d-move)!important;stroke-width:2.5!important;stroke-dasharray:8 3 2 3!important}.delta-node-marker circle{fill:#071019;stroke:currentColor;stroke-width:1.5}.delta-node-marker text,.delta-edge-marker,.delta-boundary-marker{fill:currentColor;font:800 9px ui-monospace,SFMono-Regular,Menlo,monospace}[data-delta-state="added"] .delta-node-marker,.delta-edge-marker[data-delta-state="added"],.delta-boundary-marker[data-delta-state="added"]{color:var(--d-add)}[data-delta-state="removed"] .delta-node-marker,.delta-edge-marker[data-delta-state="removed"],.delta-boundary-marker[data-delta-state="removed"]{color:var(--d-remove)}[data-delta-state="changed"] .delta-node-marker,.delta-edge-marker[data-delta-state="changed"],.delta-boundary-marker[data-delta-state="changed"]{color:var(--d-change)}[data-delta-state="moved"] .delta-node-marker,[data-delta-state="moved-from"] .delta-node-marker,.delta-edge-marker[data-delta-state="moved-from"],.delta-edge-marker[data-delta-state="rerouted"],.delta-boundary-marker[data-delta-state="geometry-changed"]{color:var(--d-move)}.delta-edge-marker,.delta-boundary-marker{paint-order:stroke;stroke:#071019;stroke-width:3px} rect[data-graph-role="structural-frame"][data-delta-state="added"]{stroke:var(--d-add)!important;stroke-width:2.5!important}rect[data-graph-role="structural-frame"][data-delta-state="removed"]{stroke:var(--d-remove)!important;stroke-width:2.5!important;stroke-dasharray:7 5!important}rect[data-graph-role="structural-frame"][data-delta-state="changed"]{stroke:var(--d-change)!important;stroke-width:2.5!important;stroke-dasharray:2 3!important}rect[data-graph-role="structural-frame"][data-delta-state="moved-from"]{stroke:var(--d-move)!important;stroke-width:2!important;stroke-dasharray:8 3 2 3!important;opacity:.42}text[data-delta-boundary-state="added"]{fill:var(--d-add)!important}text[data-delta-boundary-state="removed"]{fill:var(--d-remove)!important}text[data-delta-boundary-state="changed"]{fill:var(--d-change)!important}text[data-delta-boundary-state="moved-from"]{fill:var(--d-move)!important;opacity:.55} details{margin-top:12px;border:1px solid var(--d-line);border-radius:9px;background:#0a141e}summary{padding:13px 15px;cursor:pointer;font-weight:700}.changes{list-style:none;margin:0;padding:0 8px 8px}.changes li{border-top:1px solid rgba(138,160,181,.16)}.change-row{display:grid;grid-template-columns:30px 90px minmax(140px,1fr) minmax(100px,.7fr) minmax(120px,.8fr) minmax(140px,1.2fr);gap:10px;width:100%;margin:0;padding:9px 7px;border:0;border-radius:5px;background:transparent;color:inherit;font:inherit;font-size:11px;text-align:left;align-items:baseline;cursor:pointer}.change-row:hover{background:rgba(125,211,252,.06)}.change-row[aria-current="step"]{background:rgba(125,211,252,.1);box-shadow:inset 0 0 0 1px var(--d-focus)}.change-row:disabled{cursor:default}.token{font:800 13px/1 ui-monospace,SFMono-Regular,Menlo,monospace}.changes code,.change-row>span:last-child{color:var(--d-muted)}.proof-foot{display:flex;justify-content:space-between;gap:24px;margin-top:14px;color:var(--d-muted);font:650 10px/1.4 ui-monospace,SFMono-Regular,Menlo,monospace} html[data-theme="dark"] body{background:#071019!important;background-image:none!important}html[data-theme="light"]{color-scheme:light;--d-ink:#10283c;--d-muted:#587187;--d-line:#c8d6e2;--d-focus:#006b8f}html[data-theme="light"] body{background:#eef3f7!important;background-image:none!important;color:var(--d-ink)}html[data-theme="light"] .metric,html[data-theme="light"] .view-switch,html[data-theme="light"] details{background:#fff}html[data-theme="light"] .canvas{background:#f8fbfd}html[data-theme="light"] .view-switch button[aria-selected="true"]{background:#dbeaf5;color:#10283c}html[data-theme="light"] .delta-node-marker circle{fill:#fff}html[data-preset="blueprint"] body{background-image:none!important} -@media(max-width:760px){.proof-page{width:100%;padding:12px}.proof-head{grid-template-columns:1fr;gap:14px;align-items:start}.proof-head h1{font-size:32px}.metrics{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));width:100%}.metric{min-width:0}.proof-tools{align-items:stretch;flex-wrap:wrap;gap:8px}.view-switch{display:flex;flex:1 1 100%}.view-switch button{flex:1;padding-inline:8px}.legend{flex-wrap:wrap;gap:8px}.proof-tools>div:last-child{margin-left:auto}.review-strip{grid-template-columns:auto auto auto auto}.review-status{grid-column:1/-1;padding:4px 2px 0}.canvas{min-height:0;padding:6px;overflow:auto}.canvas svg{min-width:720px;max-height:none}.snapshot-frame{min-width:720px}.changes{overflow-x:auto}.change-row{min-width:820px}.proof-foot{flex-direction:column;gap:4px}} +@media(max-width:760px){.proof-page{width:100%;padding:12px}.proof-head{grid-template-columns:1fr;gap:14px;align-items:start}.proof-head h1{font-size:32px}.metrics{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));width:100%}.metric{min-width:0}.provenance-change{align-items:flex-start;flex-wrap:wrap}.provenance-change span{width:100%;margin-left:0}.proof-tools{align-items:stretch;flex-wrap:wrap;gap:8px}.view-switch{display:flex;flex:1 1 100%}.view-switch button{flex:1;padding-inline:8px}.legend{flex-wrap:wrap;gap:8px}.proof-tools>div:last-child{margin-left:auto}.review-strip{grid-template-columns:auto auto auto auto}.review-status{grid-column:1/-1;padding:4px 2px 0}.canvas{min-height:0;padding:6px;overflow:auto}.canvas svg{min-width:720px;max-height:none}.snapshot-frame{min-width:720px}.changes{overflow-x:auto}.change-row{min-width:820px}.proof-foot{flex-direction:column;gap:4px}} @media(prefers-reduced-motion:reduce){*{scroll-behavior:auto!important}.canvas[data-delta-review-active] [data-delta-review-current]{transition:none!important}}@media print{body{min-width:0;background:#fff;color:#111}.proof-page{width:100%;padding:0}.proof-tools,.review-strip,details{display:none!important}.canvas{display:none!important}.canvas[data-view="delta"]{display:block!important;border:0}.canvas[data-view="delta"] [data-delta-state="same"]{opacity:1!important;transition:none!important}.canvas[data-delta-review-active]{--review-same-opacity:1;--review-change-opacity:1}.canvas[data-delta-review-active] [data-delta-review-current]{opacity:1!important;transition:none!important}.proof-foot{color:#444}} -

    ARCHITECTURE DELTA · ${proof}

    See what changed
    before you merge.

    ${esc(receipt.base.title)} → ${esc(receipt.head.title)}

    ${total(receipt.summary, 'added')}ADDED
    ${total(receipt.summary, 'removed')}REMOVED
    ${changed}CHANGED
    +

    ARCHITECTURE DELTA · ${proof}

    See what changed
    before you merge.

    ${esc(receipt.base.title)} → ${esc(receipt.head.title)}

    ${total(receipt.summary, 'added')}ADDED
    ${total(receipt.summary, 'removed')}REMOVED
    ${changed}CHANGED
    ${provenanceNotice}
    + ADD− DEL~ MOD↔ MOVE
    - +
    ${deltaSvg}
    Exact authored changes · ${rows.length}
      ${rowHtml}
    Stable IDs only · completeness: complete · ${proof}Authored IR only · no risk or mergeability inference
    @@ -1052,18 +1105,31 @@ html[data-theme="dark"] body{background:#071019!important;background-image:none! ctx.fillText(connectionLine, 420, 151); ctx.fillText(boundaryLine, 786, 151); ctx.font = '650 14px ui-monospace, SFMono-Regular, Menlo, monospace'; - const authoredChanges = ['components', 'connections', 'boundaries'].reduce((sum, collection) => { - const summary = receipt.summary[collection]; - return sum + summary.added + summary.changed + summary.removed; - }, 0); + const graphChanges = ['components', 'connections', 'boundaries'].reduce((sum, collection) => sum + (receipt.changes[collection] || []).length, 0); + const provenanceSummary = ${safeJson(provenanceChanged ? `provenance changed · ${provenanceFields}` : '')}; const movementSummary = '↔ moved ' + receipt.summary.components.moved + ' · rerouted ' + receipt.summary.connections.rerouted + ' · presentation ' + (receipt.summary.presentationChanged ? 'changed' : 'unchanged'); - const secondary = authoredChanges === 0 - ? 'No authored architecture changes · ' + movementSummary - : movementSummary; - ctx.fillText(secondary, 66, 181); + const secondary = graphChanges === 0 + ? receipt.summary.provenanceChanged + ? 'No graph changes · ' + provenanceSummary + : 'No authored architecture changes · ' + movementSummary + : movementSummary + (provenanceSummary ? ' · ' + provenanceSummary : ''); const proofLine = receipt.proofLevel === 'revision-pinned' ? 'REV ' + String(receipt.base.revision).slice(0, 8) + ' → ' + String(receipt.head.revision).slice(0, 8) + ' · REVISION-PINNED INPUTS' : 'AUTHORED SNAPSHOTS'; + function fitCanvasLine(text, maxWidth) { + if (ctx.measureText(text).width <= maxWidth) return text; + let low = 0; + let high = text.length; + while (low < high) { + const middle = Math.ceil((low + high) / 2); + if (ctx.measureText(text.slice(0, middle) + '…').width <= maxWidth) low = middle; + else high = middle - 1; + } + return text.slice(0, low).trimEnd() + '…'; + } + const proofWidth = ctx.measureText(proofLine).width; + const secondaryWidth = Math.max(0, 1044 - proofWidth); + ctx.fillText(fitCanvasLine(secondary, secondaryWidth), 66, 181); ctx.textAlign = 'right'; ctx.fillText(proofLine, 1134, 181); ctx.textAlign = 'left'; @@ -1196,6 +1262,21 @@ export function validateArchitectureDeltaHtml(html, receipt) { } if (!svgBalanced || svgDepth !== 0 || svgRoots !== 1 || deltaMarkup.slice(0, rootStart).trim() || deltaMarkup.slice(rootEnd).trim()) failures.push('expected exactly one root SVG in the Delta canvas'); if ((html.match(/id="archify-compare-receipt"/g) || []).length !== 1) failures.push('expected exactly one embedded compare receipt'); + const provenanceChanged = receipt.summary?.provenanceChanged === true; + const provenanceNotices = (html.match(/data-provenance-changed="true"/g) || []).length; + const provenanceFields = receipt.provenance?.changedFields; + const legacyProvenance = receipt.provenance === undefined; + const validProvenanceFields = Array.isArray(provenanceFields) && provenanceFields.length > 0; + const provenanceDescription = provenanceChanged && (legacyProvenance || validProvenanceFields) ? provenanceDetail(receipt.provenance) : ''; + const provenanceFieldLabel = legacyProvenance ? 'repository metadata' : validProvenanceFields ? provenanceFields.join(', ') : ''; + if (provenanceNotices !== (provenanceChanged ? 1 : 0) + || (!provenanceChanged && !legacyProvenance) + || (provenanceChanged && !legacyProvenance && !validProvenanceFields) + || (provenanceChanged && !html.includes(`data-provenance-fields="${esc(provenanceFieldLabel)}"`)) + || (provenanceChanged && !html.includes(`${esc(provenanceDescription)}`))) { + failures.push('provenance change notice does not match the receipt'); + } + if (provenanceChanged && rows.length === 0 && html.includes('No authored architecture changes.')) failures.push('provenance-only delta claims no authored changes'); if (!html.includes('aria-label="Authored change review"')) failures.push('missing exact-ID change navigator'); if ((html.match(/class="change-row"/g) || []).length !== rows.length) failures.push('change navigator row count does not match the receipt'); if (!html.includes('id="export-svg"') || !html.includes('id="share-card"') diff --git a/archify/examples/dataflow-product-analytics.html b/archify/examples/dataflow-product-analytics.html index 5eacdc4a4..bc94dc688 100644 --- a/archify/examples/dataflow-product-analytics.html +++ b/archify/examples/dataflow-product-analytics.html @@ -4084,6 +4084,12 @@ .t-messagebus { fill: var(--messagebus-stroke); } .t-external { fill: var(--external-stroke); } + /* Relationship labels share the exact color token used by their path. */ + .t-edge-default { fill: var(--arrow); } + .t-edge-emphasis { fill: var(--arrow-emphasis); } + .t-edge-security { fill: var(--security-stroke); } + .t-edge-dashed { fill: var(--database-stroke); } + /* Semantic Sigils are small authored role stamps, not icons from a brand pack and not viewer controls. Prefix selectors with svg so the canonical export's SVG-only stylesheet collector keeps them automatically. */ @@ -4348,6 +4354,7 @@ html[data-embed="true"] .semantic-lens-overlay { display: none; } svg[data-focus-active] [data-node-id], svg[data-focus-active] [data-edge-from] { opacity: 0.13; } + svg[data-focus-active] [data-lifecycle-rail] { opacity: 0; } svg[data-focus-active] [data-focus-match] { opacity: 1; } svg[data-focus-active] [data-focus-selected] { filter: drop-shadow(0 0 10px var(--frontend-stroke)); @@ -4430,6 +4437,7 @@ neighborhood focus. It changes emphasis only; exported geometry and the relationship's original solid/dashed visual language stay untouched. */ svg[data-relationship-preview-active] [data-focus-match] { opacity: 0.18; } + svg[data-relationship-preview-active] [data-lifecycle-rail] { opacity: 0; } svg[data-relationship-preview-active] [data-relationship-preview], svg[data-relationship-preview-active] [data-relationship-preview-node] { opacity: 1; } svg[data-relationship-preview-active] [data-relationship-preview] { @@ -4527,6 +4535,7 @@ cloned edge geometry; the authored edge remains untouched underneath. */ svg[data-intent-trace-active] [data-node-id], svg[data-intent-trace-active] [data-edge-from] { opacity: 0.2; } + svg[data-intent-trace-active] [data-lifecycle-rail] { opacity: 0; } svg[data-intent-trace-active] [data-intent-trace-match] { opacity: 1; } svg[data-intent-trace-active] [data-intent-trace-selected] { filter: drop-shadow(0 0 10px var(--frontend-stroke)); @@ -5320,52 +5329,52 @@

    Product Analytics Data Flow

    - clickstream + clickstream user events - app events + app events device events - identity + consent + identity + consent PII touch - accepted events + accepted events append-only - identity map + identity map encrypted PII - normalized facts + normalized facts non-PII - daily aggregates + daily aggregates batch - metrics SQL + metrics SQL read-only - feature vectors + feature vectors derived - restricted join + restricted join approved only @@ -7879,7 +7888,7 @@

    Derived Consumers

    if (options.reveal !== false && Archify.view && typeof Archify.view.reveal === 'function') { Archify.view.reveal(result.nodeIds, { includeNeighbors: false, reason: 'reachability' }); } - requestLensPlacement(); + placeRelationshipLens(); return true; } function reachabilitySnapshot() { @@ -8359,7 +8368,7 @@

    Derived Consumers

    target.removeAttribute('data-preview-active'); }); renderRelationshipCopyAction(); - requestLensPlacement(); + placeRelationshipLens(); } function previewRelationship(button, options) { options = options || {}; @@ -8386,7 +8395,7 @@

    Derived Consumers

    if (!chip.hidden && options.direct !== true) chip.setAttribute('data-relationship-previewing', 'true'); activeRelationshipPreview = button; renderRelationshipPulse(key); - requestLensPlacement(); + placeRelationshipLens(); } function syncRelationshipPreview() { var next = pinnedRelationship || focusedRelationship || hoveredRelationship; @@ -8667,7 +8676,10 @@

    Derived Consumers

    } var lensFrame = 0; function placeRelationshipLens() { - lensFrame = 0; + if (lensFrame) { + cancelAnimationFrame(lensFrame); + lensFrame = 0; + } if (chip.hidden || activeIds.length !== 1) return; var node = svg.querySelector('[data-node-id="' + activeIds[0] + '"]'); if (!node) return; @@ -8868,7 +8880,7 @@

    Derived Consumers

    chip.hidden = options.hideChip === true || normalized.length !== 1 || selectionMode; if (!chip.hidden) { renderRelationshipLens(normalized[0], byId); - requestLensPlacement(); + placeRelationshipLens(); } if (options.updateUrl !== false) { var key = options.urlKey || 'focus'; @@ -8954,7 +8966,7 @@

    Derived Consumers

    relationsBtn.setAttribute('aria-label', viewerText(expanded ? 'viewer.passport.relations.show' : 'viewer.passport.relations.hide')); - requestLensPlacement(); + placeRelationshipLens(); }); relationshipList.addEventListener('click', function (event) { var button = event.target.closest('[data-relationship-target]'); @@ -9070,7 +9082,7 @@

    Derived Consumers

    reachabilitySnapshot: reachabilitySnapshot, inspectRelationship: inspectRelationship, inspectRelationshipById: inspectRelationshipById, - reposition: requestLensPlacement, + reposition: placeRelationshipLens, relationship: function () { var record = pinnedRelationshipRecord(); return record ? { id: record.id || null, key: record.key, from: record.from, to: record.to, label: record.label } : null; diff --git a/archify/examples/lifecycle-agent-run.html b/archify/examples/lifecycle-agent-run.html index 37c84d2fd..7e95da980 100644 --- a/archify/examples/lifecycle-agent-run.html +++ b/archify/examples/lifecycle-agent-run.html @@ -4084,6 +4084,12 @@ .t-messagebus { fill: var(--messagebus-stroke); } .t-external { fill: var(--external-stroke); } + /* Relationship labels share the exact color token used by their path. */ + .t-edge-default { fill: var(--arrow); } + .t-edge-emphasis { fill: var(--arrow-emphasis); } + .t-edge-security { fill: var(--security-stroke); } + .t-edge-dashed { fill: var(--database-stroke); } + /* Semantic Sigils are small authored role stamps, not icons from a brand pack and not viewer controls. Prefix selectors with svg so the canonical export's SVG-only stylesheet collector keeps them automatically. */ @@ -4348,6 +4354,7 @@ html[data-embed="true"] .semantic-lens-overlay { display: none; } svg[data-focus-active] [data-node-id], svg[data-focus-active] [data-edge-from] { opacity: 0.13; } + svg[data-focus-active] [data-lifecycle-rail] { opacity: 0; } svg[data-focus-active] [data-focus-match] { opacity: 1; } svg[data-focus-active] [data-focus-selected] { filter: drop-shadow(0 0 10px var(--frontend-stroke)); @@ -4430,6 +4437,7 @@ neighborhood focus. It changes emphasis only; exported geometry and the relationship's original solid/dashed visual language stay untouched. */ svg[data-relationship-preview-active] [data-focus-match] { opacity: 0.18; } + svg[data-relationship-preview-active] [data-lifecycle-rail] { opacity: 0; } svg[data-relationship-preview-active] [data-relationship-preview], svg[data-relationship-preview-active] [data-relationship-preview-node] { opacity: 1; } svg[data-relationship-preview-active] [data-relationship-preview] { @@ -4527,6 +4535,7 @@ cloned edge geometry; the authored edge remains untouched underneath. */ svg[data-intent-trace-active] [data-node-id], svg[data-intent-trace-active] [data-edge-from] { opacity: 0.2; } + svg[data-intent-trace-active] [data-lifecycle-rail] { opacity: 0; } svg[data-intent-trace-active] [data-intent-trace-match] { opacity: 1; } svg[data-intent-trace-active] [data-intent-trace-selected] { filter: drop-shadow(0 0 10px var(--frontend-stroke)); @@ -5164,7 +5173,7 @@

    Agent Run Lifecycle

    03 / Terminal exits - + @@ -7814,7 +7823,7 @@

    Recovery + Terminal Exits

    if (options.reveal !== false && Archify.view && typeof Archify.view.reveal === 'function') { Archify.view.reveal(result.nodeIds, { includeNeighbors: false, reason: 'reachability' }); } - requestLensPlacement(); + placeRelationshipLens(); return true; } function reachabilitySnapshot() { @@ -8294,7 +8303,7 @@

    Recovery + Terminal Exits

    target.removeAttribute('data-preview-active'); }); renderRelationshipCopyAction(); - requestLensPlacement(); + placeRelationshipLens(); } function previewRelationship(button, options) { options = options || {}; @@ -8321,7 +8330,7 @@

    Recovery + Terminal Exits

    if (!chip.hidden && options.direct !== true) chip.setAttribute('data-relationship-previewing', 'true'); activeRelationshipPreview = button; renderRelationshipPulse(key); - requestLensPlacement(); + placeRelationshipLens(); } function syncRelationshipPreview() { var next = pinnedRelationship || focusedRelationship || hoveredRelationship; @@ -8602,7 +8611,10 @@

    Recovery + Terminal Exits

    } var lensFrame = 0; function placeRelationshipLens() { - lensFrame = 0; + if (lensFrame) { + cancelAnimationFrame(lensFrame); + lensFrame = 0; + } if (chip.hidden || activeIds.length !== 1) return; var node = svg.querySelector('[data-node-id="' + activeIds[0] + '"]'); if (!node) return; @@ -8803,7 +8815,7 @@

    Recovery + Terminal Exits

    chip.hidden = options.hideChip === true || normalized.length !== 1 || selectionMode; if (!chip.hidden) { renderRelationshipLens(normalized[0], byId); - requestLensPlacement(); + placeRelationshipLens(); } if (options.updateUrl !== false) { var key = options.urlKey || 'focus'; @@ -8889,7 +8901,7 @@

    Recovery + Terminal Exits

    relationsBtn.setAttribute('aria-label', viewerText(expanded ? 'viewer.passport.relations.show' : 'viewer.passport.relations.hide')); - requestLensPlacement(); + placeRelationshipLens(); }); relationshipList.addEventListener('click', function (event) { var button = event.target.closest('[data-relationship-target]'); @@ -9005,7 +9017,7 @@

    Recovery + Terminal Exits

    reachabilitySnapshot: reachabilitySnapshot, inspectRelationship: inspectRelationship, inspectRelationshipById: inspectRelationshipById, - reposition: requestLensPlacement, + reposition: placeRelationshipLens, relationship: function () { var record = pinnedRelationshipRecord(); return record ? { id: record.id || null, key: record.key, from: record.from, to: record.to, label: record.label } : null; diff --git a/archify/examples/sequence-cache-miss-request.html b/archify/examples/sequence-cache-miss-request.html index 9fa75af28..b5a2e7aef 100644 --- a/archify/examples/sequence-cache-miss-request.html +++ b/archify/examples/sequence-cache-miss-request.html @@ -4084,6 +4084,12 @@ .t-messagebus { fill: var(--messagebus-stroke); } .t-external { fill: var(--external-stroke); } + /* Relationship labels share the exact color token used by their path. */ + .t-edge-default { fill: var(--arrow); } + .t-edge-emphasis { fill: var(--arrow-emphasis); } + .t-edge-security { fill: var(--security-stroke); } + .t-edge-dashed { fill: var(--database-stroke); } + /* Semantic Sigils are small authored role stamps, not icons from a brand pack and not viewer controls. Prefix selectors with svg so the canonical export's SVG-only stylesheet collector keeps them automatically. */ @@ -4348,6 +4354,7 @@ html[data-embed="true"] .semantic-lens-overlay { display: none; } svg[data-focus-active] [data-node-id], svg[data-focus-active] [data-edge-from] { opacity: 0.13; } + svg[data-focus-active] [data-lifecycle-rail] { opacity: 0; } svg[data-focus-active] [data-focus-match] { opacity: 1; } svg[data-focus-active] [data-focus-selected] { filter: drop-shadow(0 0 10px var(--frontend-stroke)); @@ -4430,6 +4437,7 @@ neighborhood focus. It changes emphasis only; exported geometry and the relationship's original solid/dashed visual language stay untouched. */ svg[data-relationship-preview-active] [data-focus-match] { opacity: 0.18; } + svg[data-relationship-preview-active] [data-lifecycle-rail] { opacity: 0; } svg[data-relationship-preview-active] [data-relationship-preview], svg[data-relationship-preview-active] [data-relationship-preview-node] { opacity: 1; } svg[data-relationship-preview-active] [data-relationship-preview] { @@ -4527,6 +4535,7 @@ cloned edge geometry; the authored edge remains untouched underneath. */ svg[data-intent-trace-active] [data-node-id], svg[data-intent-trace-active] [data-edge-from] { opacity: 0.2; } + svg[data-intent-trace-active] [data-lifecycle-rail] { opacity: 0; } svg[data-intent-trace-active] [data-intent-trace-match] { opacity: 1; } svg[data-intent-trace-active] [data-intent-trace-selected] { filter: drop-shadow(0 0 10px var(--frontend-stroke)); @@ -5299,89 +5308,89 @@

    Cache Miss Request Sequence

    User · browser session · Sequence participant - - + + - User - browser session + User + browser session Web App · React UI · Sequence participant - - + + - Web App - React UI + Web App + React UI API · request handler · Sequence participant - - + + - API - request handler + API + request handler Auth · JWT verify · Sequence participant - - + + - Auth - JWT verify + Auth + JWT verify Redis · cache · Sequence participant - - + + - Redis - cache + Redis + cache Postgres · source of truth · Sequence participant - - + + - Postgres - source of truth + Postgres + source of truth Trace · async event · Sequence participant - - + + - Trace - async event + Trace + async event @@ -7894,7 +7903,7 @@

    Async Trace

    if (options.reveal !== false && Archify.view && typeof Archify.view.reveal === 'function') { Archify.view.reveal(result.nodeIds, { includeNeighbors: false, reason: 'reachability' }); } - requestLensPlacement(); + placeRelationshipLens(); return true; } function reachabilitySnapshot() { @@ -8374,7 +8383,7 @@

    Async Trace

    target.removeAttribute('data-preview-active'); }); renderRelationshipCopyAction(); - requestLensPlacement(); + placeRelationshipLens(); } function previewRelationship(button, options) { options = options || {}; @@ -8401,7 +8410,7 @@

    Async Trace

    if (!chip.hidden && options.direct !== true) chip.setAttribute('data-relationship-previewing', 'true'); activeRelationshipPreview = button; renderRelationshipPulse(key); - requestLensPlacement(); + placeRelationshipLens(); } function syncRelationshipPreview() { var next = pinnedRelationship || focusedRelationship || hoveredRelationship; @@ -8682,7 +8691,10 @@

    Async Trace

    } var lensFrame = 0; function placeRelationshipLens() { - lensFrame = 0; + if (lensFrame) { + cancelAnimationFrame(lensFrame); + lensFrame = 0; + } if (chip.hidden || activeIds.length !== 1) return; var node = svg.querySelector('[data-node-id="' + activeIds[0] + '"]'); if (!node) return; @@ -8883,7 +8895,7 @@

    Async Trace

    chip.hidden = options.hideChip === true || normalized.length !== 1 || selectionMode; if (!chip.hidden) { renderRelationshipLens(normalized[0], byId); - requestLensPlacement(); + placeRelationshipLens(); } if (options.updateUrl !== false) { var key = options.urlKey || 'focus'; @@ -8969,7 +8981,7 @@

    Async Trace

    relationsBtn.setAttribute('aria-label', viewerText(expanded ? 'viewer.passport.relations.show' : 'viewer.passport.relations.hide')); - requestLensPlacement(); + placeRelationshipLens(); }); relationshipList.addEventListener('click', function (event) { var button = event.target.closest('[data-relationship-target]'); @@ -9085,7 +9097,7 @@

    Async Trace

    reachabilitySnapshot: reachabilitySnapshot, inspectRelationship: inspectRelationship, inspectRelationshipById: inspectRelationshipById, - reposition: requestLensPlacement, + reposition: placeRelationshipLens, relationship: function () { var record = pinnedRelationshipRecord(); return record ? { id: record.id || null, key: record.key, from: record.from, to: record.to, label: record.label } : null; diff --git a/archify/examples/web-app-rendered.html b/archify/examples/web-app-rendered.html index 0493a6842..0a4685633 100644 --- a/archify/examples/web-app-rendered.html +++ b/archify/examples/web-app-rendered.html @@ -4084,6 +4084,12 @@ .t-messagebus { fill: var(--messagebus-stroke); } .t-external { fill: var(--external-stroke); } + /* Relationship labels share the exact color token used by their path. */ + .t-edge-default { fill: var(--arrow); } + .t-edge-emphasis { fill: var(--arrow-emphasis); } + .t-edge-security { fill: var(--security-stroke); } + .t-edge-dashed { fill: var(--database-stroke); } + /* Semantic Sigils are small authored role stamps, not icons from a brand pack and not viewer controls. Prefix selectors with svg so the canonical export's SVG-only stylesheet collector keeps them automatically. */ @@ -4348,6 +4354,7 @@ html[data-embed="true"] .semantic-lens-overlay { display: none; } svg[data-focus-active] [data-node-id], svg[data-focus-active] [data-edge-from] { opacity: 0.13; } + svg[data-focus-active] [data-lifecycle-rail] { opacity: 0; } svg[data-focus-active] [data-focus-match] { opacity: 1; } svg[data-focus-active] [data-focus-selected] { filter: drop-shadow(0 0 10px var(--frontend-stroke)); @@ -4430,6 +4437,7 @@ neighborhood focus. It changes emphasis only; exported geometry and the relationship's original solid/dashed visual language stay untouched. */ svg[data-relationship-preview-active] [data-focus-match] { opacity: 0.18; } + svg[data-relationship-preview-active] [data-lifecycle-rail] { opacity: 0; } svg[data-relationship-preview-active] [data-relationship-preview], svg[data-relationship-preview-active] [data-relationship-preview-node] { opacity: 1; } svg[data-relationship-preview-active] [data-relationship-preview] { @@ -4527,6 +4535,7 @@ cloned edge geometry; the authored edge remains untouched underneath. */ svg[data-intent-trace-active] [data-node-id], svg[data-intent-trace-active] [data-edge-from] { opacity: 0.2; } + svg[data-intent-trace-active] [data-lifecycle-rail] { opacity: 0; } svg[data-intent-trace-active] [data-intent-trace-match] { opacity: 1; } svg[data-intent-trace-active] [data-intent-trace-selected] { filter: drop-shadow(0 0 10px var(--frontend-stroke)); @@ -5294,29 +5303,29 @@

    Sample Web App

    - HTTPS + HTTPS - verify JWT + verify JWT - static + static - read-through + read-through - SQL + SQL - enqueue + enqueue @@ -7843,7 +7852,7 @@

    Security

    if (options.reveal !== false && Archify.view && typeof Archify.view.reveal === 'function') { Archify.view.reveal(result.nodeIds, { includeNeighbors: false, reason: 'reachability' }); } - requestLensPlacement(); + placeRelationshipLens(); return true; } function reachabilitySnapshot() { @@ -8323,7 +8332,7 @@

    Security

    target.removeAttribute('data-preview-active'); }); renderRelationshipCopyAction(); - requestLensPlacement(); + placeRelationshipLens(); } function previewRelationship(button, options) { options = options || {}; @@ -8350,7 +8359,7 @@

    Security

    if (!chip.hidden && options.direct !== true) chip.setAttribute('data-relationship-previewing', 'true'); activeRelationshipPreview = button; renderRelationshipPulse(key); - requestLensPlacement(); + placeRelationshipLens(); } function syncRelationshipPreview() { var next = pinnedRelationship || focusedRelationship || hoveredRelationship; @@ -8631,7 +8640,10 @@

    Security

    } var lensFrame = 0; function placeRelationshipLens() { - lensFrame = 0; + if (lensFrame) { + cancelAnimationFrame(lensFrame); + lensFrame = 0; + } if (chip.hidden || activeIds.length !== 1) return; var node = svg.querySelector('[data-node-id="' + activeIds[0] + '"]'); if (!node) return; @@ -8832,7 +8844,7 @@

    Security

    chip.hidden = options.hideChip === true || normalized.length !== 1 || selectionMode; if (!chip.hidden) { renderRelationshipLens(normalized[0], byId); - requestLensPlacement(); + placeRelationshipLens(); } if (options.updateUrl !== false) { var key = options.urlKey || 'focus'; @@ -8918,7 +8930,7 @@

    Security

    relationsBtn.setAttribute('aria-label', viewerText(expanded ? 'viewer.passport.relations.show' : 'viewer.passport.relations.hide')); - requestLensPlacement(); + placeRelationshipLens(); }); relationshipList.addEventListener('click', function (event) { var button = event.target.closest('[data-relationship-target]'); @@ -9034,7 +9046,7 @@

    Security

    reachabilitySnapshot: reachabilitySnapshot, inspectRelationship: inspectRelationship, inspectRelationshipById: inspectRelationshipById, - reposition: requestLensPlacement, + reposition: placeRelationshipLens, relationship: function () { var record = pinnedRelationshipRecord(); return record ? { id: record.id || null, key: record.key, from: record.from, to: record.to, label: record.label } : null; diff --git a/archify/examples/workflow-agent-tool-call-rendered.html b/archify/examples/workflow-agent-tool-call-rendered.html index 213061611..3e8ad5679 100644 --- a/archify/examples/workflow-agent-tool-call-rendered.html +++ b/archify/examples/workflow-agent-tool-call-rendered.html @@ -4084,6 +4084,12 @@ .t-messagebus { fill: var(--messagebus-stroke); } .t-external { fill: var(--external-stroke); } + /* Relationship labels share the exact color token used by their path. */ + .t-edge-default { fill: var(--arrow); } + .t-edge-emphasis { fill: var(--arrow-emphasis); } + .t-edge-security { fill: var(--security-stroke); } + .t-edge-dashed { fill: var(--database-stroke); } + /* Semantic Sigils are small authored role stamps, not icons from a brand pack and not viewer controls. Prefix selectors with svg so the canonical export's SVG-only stylesheet collector keeps them automatically. */ @@ -4348,6 +4354,7 @@ html[data-embed="true"] .semantic-lens-overlay { display: none; } svg[data-focus-active] [data-node-id], svg[data-focus-active] [data-edge-from] { opacity: 0.13; } + svg[data-focus-active] [data-lifecycle-rail] { opacity: 0; } svg[data-focus-active] [data-focus-match] { opacity: 1; } svg[data-focus-active] [data-focus-selected] { filter: drop-shadow(0 0 10px var(--frontend-stroke)); @@ -4430,6 +4437,7 @@ neighborhood focus. It changes emphasis only; exported geometry and the relationship's original solid/dashed visual language stay untouched. */ svg[data-relationship-preview-active] [data-focus-match] { opacity: 0.18; } + svg[data-relationship-preview-active] [data-lifecycle-rail] { opacity: 0; } svg[data-relationship-preview-active] [data-relationship-preview], svg[data-relationship-preview-active] [data-relationship-preview-node] { opacity: 1; } svg[data-relationship-preview-active] [data-relationship-preview] { @@ -4527,6 +4535,7 @@ cloned edge geometry; the authored edge remains untouched underneath. */ svg[data-intent-trace-active] [data-node-id], svg[data-intent-trace-active] [data-edge-from] { opacity: 0.2; } + svg[data-intent-trace-active] [data-lifecycle-rail] { opacity: 0; } svg[data-intent-trace-active] [data-intent-trace-match] { opacity: 1; } svg[data-intent-trace-active] [data-intent-trace-selected] { filter: drop-shadow(0 0 10px var(--frontend-stroke)); @@ -5356,29 +5365,29 @@

    Agent Tool Call Workflow

    - needs approval? + needs approval? - denied + denied - plan + plan - record result + record result - trace + memory + trace + memory @@ -7885,7 +7894,7 @@

    Runtime Semantics

    if (options.reveal !== false && Archify.view && typeof Archify.view.reveal === 'function') { Archify.view.reveal(result.nodeIds, { includeNeighbors: false, reason: 'reachability' }); } - requestLensPlacement(); + placeRelationshipLens(); return true; } function reachabilitySnapshot() { @@ -8365,7 +8374,7 @@

    Runtime Semantics

    target.removeAttribute('data-preview-active'); }); renderRelationshipCopyAction(); - requestLensPlacement(); + placeRelationshipLens(); } function previewRelationship(button, options) { options = options || {}; @@ -8392,7 +8401,7 @@

    Runtime Semantics

    if (!chip.hidden && options.direct !== true) chip.setAttribute('data-relationship-previewing', 'true'); activeRelationshipPreview = button; renderRelationshipPulse(key); - requestLensPlacement(); + placeRelationshipLens(); } function syncRelationshipPreview() { var next = pinnedRelationship || focusedRelationship || hoveredRelationship; @@ -8673,7 +8682,10 @@

    Runtime Semantics

    } var lensFrame = 0; function placeRelationshipLens() { - lensFrame = 0; + if (lensFrame) { + cancelAnimationFrame(lensFrame); + lensFrame = 0; + } if (chip.hidden || activeIds.length !== 1) return; var node = svg.querySelector('[data-node-id="' + activeIds[0] + '"]'); if (!node) return; @@ -8874,7 +8886,7 @@

    Runtime Semantics

    chip.hidden = options.hideChip === true || normalized.length !== 1 || selectionMode; if (!chip.hidden) { renderRelationshipLens(normalized[0], byId); - requestLensPlacement(); + placeRelationshipLens(); } if (options.updateUrl !== false) { var key = options.urlKey || 'focus'; @@ -8960,7 +8972,7 @@

    Runtime Semantics

    relationsBtn.setAttribute('aria-label', viewerText(expanded ? 'viewer.passport.relations.show' : 'viewer.passport.relations.hide')); - requestLensPlacement(); + placeRelationshipLens(); }); relationshipList.addEventListener('click', function (event) { var button = event.target.closest('[data-relationship-target]'); @@ -9076,7 +9088,7 @@

    Runtime Semantics

    reachabilitySnapshot: reachabilitySnapshot, inspectRelationship: inspectRelationship, inspectRelationshipById: inspectRelationshipById, - reposition: requestLensPlacement, + reposition: placeRelationshipLens, relationship: function () { var record = pinnedRelationshipRecord(); return record ? { id: record.id || null, key: record.key, from: record.from, to: record.to, label: record.label } : null; diff --git a/archify/package.json b/archify/package.json index 7cec5a798..9db690ce3 100644 --- a/archify/package.json +++ b/archify/package.json @@ -24,6 +24,7 @@ "build:start": "node ../scripts/build-start.mjs ../docs/start.html", "build:readme-showcase": "node ../scripts/build-readme-showcase.mjs", "test:webm": "node test/webm-artifact.smoke.mjs && node --test test/site-language-integration.mjs", + "test:browser": "node ../scripts/run-browser-tests.mjs", "test": "npm run check:viewer && npm run check:brand-marks && npm run check:validators && npm run check:release-identity && node test/golden.mjs && node ../scripts/run-tests.mjs", "render:examples": "node scripts/render-examples.mjs ../examples" }, diff --git a/archify/recipes/scenarios.mjs b/archify/recipes/scenarios.mjs index 5076134bc..539719956 100644 --- a/archify/recipes/scenarios.mjs +++ b/archify/recipes/scenarios.mjs @@ -250,6 +250,45 @@ const RAW_RECIPES = [ prompt: '用 Archify 生命周期模式建模部署对象。展示排队、构建、验证、等待审批、晋级、回滚以及所有终态,并标注允许每次状态转换的事件和守卫条件。', }, }, + { + id: 'layout-repair', type: 'architecture', proof: 'web-app', + presentation: { preset: 'classic', motion: 'static', views: 'optional' }, + signals: [ + ['layout repair', 20], ['repair order', 20], ['fix order', 20], + ['viewport overflow', 20], ['overflow', 16], ['scrollheight', 16], ['scrollwidth', 16], + ['overlap', 16], ['label overlap', 20], ['edge through node', 20], ['crossing', 8], + ['via', 7], ['waypoint', 14], + ['布局修复', 20], ['修复顺序', 20], ['视口溢出', 20], ['溢出', 16], ['滚动高度', 16], ['滚动宽度', 16], + ['重叠', 16], ['标签重叠', 20], ['连线穿过节点', 20], ['连线穿节点', 20], ['交叉', 8], + ['途经点', 14], ['路径点', 14], ['拐点', 14], + ], + en: { + title: 'Layout repair', question: 'Why does the existing diagram overflow, overlap, or route through nodes, and what should be fixed first?', + summary: 'Repair an existing diagram in its current mode using validation diagnostics, explicit waypoint semantics, and a measured desktop viewport budget.', + useWhen: 'An existing architecture, workflow, sequence, dataflow, or lifecycle diagram needs layout repair; keep its diagram type and presentation settings.', + avoidWhen: 'The task is choosing a new diagram type. Do not change topology, delete meaningful labels, or hide overflow just to pass checks.', + include: [ + 'repair order: schema → overlap → direction → crossings → labels', + 'via contract: absolute [x, y] intermediate points; route = [start, ...via, end]', + 'desktop viewport budget for the complete page, including header and necessary cards', + 'validate after each edit, then inspect the final HTML in a browser', + ], + prompt: 'Use Archify to repair this existing diagram while preserving its diagram type, topology, meaningful labels, and presentation settings. Follow references/authoring-contract.md in this order: (1) schema and missing/invalid meta.quality_profile; (2) node overlap or out-of-range placement; (3) edge-through-node and endpoint-direction errors; (4) crossings, ambiguous corridors, border runs, and route rhythm; (5) labels: label-to-node, label-to-label, then label-to-route clearance. Run validate after every edit and use diagnostics[] code, subject, evidence, and supportedFixes; apply one diagnosed geometry control at a time. Where the current schema supports via, it is an ordered array of absolute SVG [x, y] intermediate points: the route is [start, ...via, end], with start/end supplied by the node anchors. Explicit via points override automatic routing; they are not offsets or a request for automatic obstacle avoidance. For an orthogonal repair, align adjacent points on the same x or y and make the first/final segment respect fromSide/toSide; use only controls supported by the current diagram mode. Follow references/delivery-contract.md for the viewport budget: at 1440×900, 1600×1000, and 1920×1080 (also 2048×1320 for a large desktop), require document.documentElement.scrollWidth <= window.innerWidth and document.documentElement.scrollHeight <= window.innerHeight. Budget the entire page, including header, diagram, and necessary cards. Remove only redundant content or compact spacing first; do not hide overflow, clip content, introduce an internal diagram scroller, stretch the SVG, or shrink typography to force a pass. Inspect readability and vertical balance at the largest viewport; narrow/mobile layouts may scroll vertically. Validate, then deliver and inspect the final HTML; report browser evidence and visual review truthfully.', + }, + zh: { + title: '布局修复', question: '现有图为什么仍然溢出、重叠或连线穿过节点,应该先修什么?', + summary: '保留现有图表模式,依据验证诊断、途经点语义和实测桌面视口预算修复布局。', + useWhen: '已有架构图、工作流、时序图、数据流或生命周期图需要修复布局;保留原来的图表类型和表现设置。', + avoidWhen: '任务是为新图选择类型时不要使用。不要为了通过检查改变拓扑、删除有意义的标签或隐藏溢出。', + include: [ + '修复顺序:schema → 重叠 → 方向 → 交叉 → 标签', + 'via 契约:绝对 [x, y] 中间点;路径 = [start, ...via, end]', + '包含标题和必要卡片的整页桌面视口预算', + '每次修改后 validate,最终在浏览器中检查 HTML', + ], + prompt: '用 Archify 修复这张现有图,保留图表类型、拓扑、有意义的标签和表现设置。遵循 references/authoring-contract.md 的顺序:(1) schema 错误及缺失或无效的 meta.quality_profile;(2) 节点重叠或越界;(3) 连线穿过节点及端点方向错误;(4) 交叉、含混的共享通道、贴边走线和转弯节奏;(5) 标签与节点、标签与标签、标签与连线的间距。每次修改后运行 validate,依据 diagnostics[] 的 code、subject、evidence 和 supportedFixes,每次只应用一项有诊断依据的几何控制。当前 schema 支持 via 时,它是按顺序排列的绝对 SVG [x, y] 中间点数组:路径为 [start, ...via, end],起终点由节点锚点提供。显式 via 会覆盖自动路由,不是偏移量,也不会请求自动绕障。修复正交走线时,相邻点应共享 x 或 y,首尾线段应遵守 fromSide/toSide;只使用当前模式支持的控制字段。视口预算遵循 references/delivery-contract.md:在 1440×900、1600×1000、1920×1080 检查,大桌面还要检查 2048×1320;要求 document.documentElement.scrollWidth <= window.innerWidth 且 document.documentElement.scrollHeight <= window.innerHeight。预算覆盖整页,包括标题、主图和必要卡片。先移除冗余内容或压缩间距,不得靠隐藏溢出、裁切、内部滚动区、拉伸 SVG 或缩小字体强行通过。在最大视口检查可读性和纵向平衡;窄屏或移动端允许纵向滚动。验证后执行 deliver 并检查最终 HTML,如实报告浏览器证据和视觉检查结果。', + }, + }, ]; export const SCENARIO_RECIPES = Object.freeze(RAW_RECIPES.map((recipe) => Object.freeze({ diff --git a/archify/references/authoring-contract.md b/archify/references/authoring-contract.md index 6c5c8df34..3adeae227 100644 --- a/archify/references/authoring-contract.md +++ b/archify/references/authoring-contract.md @@ -105,6 +105,7 @@ in the generated viewer. - Node anchors start at side midpoints. `left`/`right` change the horizontal endpoint; `top`/`bottom` change the vertical endpoint. For an automatic Architecture relationship, unobstructed facing ports whose axis offset is under 16px may share one horizontal or vertical axis when both endpoints retain the 16px corner gutter. If exactly one endpoint belongs to a spread group, only its unshared counterpart moves; relationships spread at both endpoints keep their distinct ports and outside bridge. - A side is a direction contract. The first and final route segment must be perpendicular and outward/inward in the named direction. +- In architecture, data-flow, and lifecycle diagrams, explicit `route: "straight"` requests one direct segment, which may be diagonal when endpoint sides are not pinned. The artifact checker preserves this intent; explicit sides, opaque-node clearance, and other quality gates still apply. `via` takes precedence and retains existing rules, including data-flow's requirement for orthogonal via segments. - Automatic Port Spread is a default renderer behavior for architecture, workflow, data-flow, and lifecycle diagrams. Shared automatic endpoints spread deterministically and symmetrically with a 16px corner gutter. It does not apply to sequence messages, single relationships, or explicit `via`, `channelX`, `channelY`, `labelAt`, or non-`auto` routes. - Showcase route rhythm: every nonzero segment must be at least 8px; every interior segment must be at least 16px. When spread ports are nearly parallel, the router uses a 24px endpoint stub and a 16px outside bridge instead of manufacturing a tiny dogleg. - Shared endpoint corridors are allowed only when they remain semantically unambiguous. Unrelated collinear overlap of 8px or more fails showcase. @@ -133,7 +134,13 @@ its endpoints fully imply it, explain why the wording is redundant; this is a semantic authoring choice, not a spacing repair. In workflow v2, let the compiler allocate its measured mask before applying a diagnosed `labelAt`, `labelDx`/`labelDy`, or `labelSegment`. Apply one diagnosed geometry control at -a time. +a time unless several edges share a constrained channel. In that case, plan the smallest coupled change from measured geometry and +validate it together. Architecture/workflow provide layout evidence through +`validate --layout-json`; for other types, use validation +diagnostics and the rendered SVG geometry. +Before adding manual routes, check whether unnecessary agent-added controls +disable automatic port spread; preserve user-required route intent. Use the +measured clearance rules above rather than guessing coordinates. ### Repair order @@ -142,6 +149,7 @@ a time. 3. Fix edge-through-node and endpoint-direction errors. 4. Fix crossings, ambiguous corridors, border runs, and route rhythm. 5. Fix label-to-node, label-to-label, then label-to-route clearance. +6. Fix labels that leave the canvas: move the label with `labelAt`/`labelDx`/`labelDy`/`labelSegment`, or widen `meta.viewBox`. Suggested `labelDx`/`labelDy` values replace the authored field; they are not added to it. Run `validate` after every edit. Consume `diagnostics[]` by stable `code`, exact `subject`, measured `evidence`, and `supportedFixes`. If the diagnostic gives `labelAt`, use that point instead of estimating another offset. @@ -161,6 +169,27 @@ legacy geometry compatibility. Keep the happy path monotonic, preserve semantic edge labels, and route retries and exception returns outside the main lane corridor. +#### Workflow viewport repair + +When `viewer/viewport-overflow` includes `workflowLanes`, inspect the tallest +rendered frames and their node span before changing the source. Measurements +are CSS pixels; space above/below nodes includes lane titles and routing, so it +is not a removable-space budget. Frame IDs identify rendered lane indices. + +Run `validate workflow --layout-json` and match those frames to +source lanes and nodes. Check whether many steps share the last logical column +and use large `yOffset` values. Readable-v2 currently reserves symmetric space +around offsets and shares the base content height between lanes, so increasing +one offset can enlarge otherwise sparse lanes. + +Where the source's ownership and explicit geometry permit, redistribute steps +across logical columns and meaningful lanes, keeping the main path monotonic. +Preserve every required node, relationship, label and semantic check. If ownership +or absolute pins prevent reflow, report that constraint instead of merging lanes +or moving pins automatically. Validate the changed JSON, deliver a fresh HTML, +then rerun browser checks and inspect the first screen; a static pass alone does +not settle viewport fit. These are repair directions, not guaranteed coordinates. + ### Sequence Participants are ordered by conversation role. Messages own their vertical order. Use return/async/security variants for meaning, not decoration; sequence does not use Automatic Port Spread. diff --git a/archify/references/delivery-contract.md b/archify/references/delivery-contract.md index 11d0dfe6c..fef036f90 100644 --- a/archify/references/delivery-contract.md +++ b/archify/references/delivery-contract.md @@ -2,6 +2,15 @@ ## Validate and deliver +`render` and direct renderer entry points print classified authoring failures +to stderr as readable diagnostics and exit 1. Input read/JSON parse failures +use `input/read` or `input/json-parse`; output filesystem failures use +`output/write` and identify the output path. Schema and layout failures keep +their existing rule codes. Use the advertised `validate --json` or +`deliver --json` interface for a machine receipt; `render` has no `--json` flag. +Unexpected implementation failures retain debugging information in human +mode and remain `internal/unclassified` in machine receipts. + Use `validate` after every candidate edit. CLI HTML output paths must end in `.html`, including after symbolic-link resolution. Compare receipt paths must end in `.json`. Explicit CLI paths may be absolute or outside the current working directory; authored `meta.output` remains confined @@ -63,7 +72,13 @@ The zero-dependency command uses Chrome/Chromium through the DevTools pipe. It measures light-theme containment at 1440×900, 1600×1000, 1920×1080, and 2048×1320, then captures light/dark screenshots at 1440×900 and 2048×1320. It writes four PNG sidecars, one relative-path HTML contact sheet, and one JSON -receipt beside the artifact. The receipt binds the source artifact SHA-256 and +receipt beside the artifact by default — pass `--out-dir ` to write all of +them into a separate directory instead (created if missing) when a project +keeps its testing/evidence artifacts apart from the delivered `.json`/`.html` +result pair. When that directory differs from the artifact directory, the +receipt records its absolute path as `sidecars.directory`; sidecar filenames +resolve there, otherwise beside `artifact.path`. The contact sheet keeps its +image links relative for portability. The receipt binds the source artifact SHA-256 and byte count, identifies `evidenceKind: "automated-browser"`, records READ plus Still runtime state, and always reports `visualReview: "pending"`; automated browser evidence cannot claim perceptual review. diff --git a/archify/renderers/architecture/render-architecture.mjs b/archify/renderers/architecture/render-architecture.mjs index b21ac8e4e..38ce299d8 100644 --- a/archify/renderers/architecture/render-architecture.mjs +++ b/archify/renderers/architecture/render-architecture.mjs @@ -10,11 +10,11 @@ import { brandLabelFitWidth, brandMetadataFor, brandTopRailProblem, renderBrandM import { minimumReadableSourceTextPx } from '../shared/desktop-readability.mjs'; import { translateMessage as i18nText } from '../shared/i18n.mjs'; import { gridLayout, resolveComponentPos, validateGridPlacement } from './grid.mjs'; +import { createRouter } from './routing.mjs'; import { asArray, isFinitePoint, rectsOverlap, - segmentIntersectsRect, cleanEndpointSideProblems, cleanFlowProblems, cleanCrossingProblems, @@ -22,24 +22,17 @@ import { cleanBorderRunProblems, cleanRouteRhythmProblems, cleanLabelRouteClearanceProblems, + cleanLabelCanvasContainmentProblems, suggestLabelObstacleFix, suggestComponentSeparation, - anchor, - automaticPortSpread, - automaticPortRhythmBridge, - defaultFromSide, - defaultToSide, - chosenSide, - routeHonorsEndpointSides, - normalizeRoutePoints, polylinePath, routePointsValue, - roundedPath, + authoredStraightRouteAttrs, labelPoint, componentFill, componentText, arrowClassMap, - variantAccent, + edgeLabelAccent, } from '../shared/geometry.mjs'; const componentTextFit = { @@ -148,16 +141,39 @@ const architectureLegendEntries = resolveLegend( new Set([...components.values()].map((component) => component.type)), ); -function autoViewBoxFor(candidateBoundaries) { +// One source for connection label geometry: the rect the containment rule +// measures is the rect the SVG mask draws, the auto canvas covers, the legend +// avoids, and the layout report publishes. +function connectionLabelBox(conn) { + if (!conn.label) return null; + const [lx, ly] = labelPoint(conn, pathFor(conn).points); + const width = Math.max(30, textUnits(conn.label) * 4.8 + 10); + return { x: lx - width / 2, y: ly - 10, width, height: 14, lx, ly }; +} + +function connectionLabelRects() { + const rects = []; + for (const [relationIndex, conn] of asArray(arch.connections).entries()) { + if (!components.has(conn.from) || !components.has(conn.to)) continue; + const box = connectionLabelBox(conn); + if (!box) continue; + rects.push({ relation: conn, relationIndex, label: conn.label, ...box }); + } + return rects; +} + +function autoViewBoxFor(candidateBoundaries, extraRects = []) { const maxX = Math.max( 0, ...[...components.values()].map((component) => component.x + component.width), ...candidateBoundaries.map((boundary) => boundary.x + boundary.width), + ...extraRects.map((rect) => rect.x + rect.width), ); const maxY = Math.max( 0, ...[...components.values()].map((component) => component.y + component.height), ...candidateBoundaries.map((boundary) => boundary.y + boundary.height), + ...extraRects.map((rect) => rect.y + rect.height), ); let width = Math.ceil(maxX + layout.margin); let footprint = legendFootprint(architectureLegendEntries, { @@ -179,7 +195,7 @@ function resolvedViewBoxWidth(candidateBoundaries) { if (Array.isArray(arch.meta?.viewBox) && Number.isFinite(arch.meta.viewBox[0])) { return arch.meta.viewBox[0]; } - return autoViewBoxFor(candidateBoundaries)[0]; + return autoViewBoxFor(candidateBoundaries, connectionLabels)[0]; } function expandBoundaryForReadableTitle(boundary, minimumFontSize) { @@ -276,6 +292,17 @@ function layoutBoundaryTitles(rawBoundaries, minimumFontSize) { }); } +// ---- Routing state ---------------------------------------------------------- +// Initialized before the boundary-title work below: connection label rects are +// part of the derived canvas, so the title convergence must measure the same +// width the diagram actually renders into (a title sized for a narrower canvas +// would fall below the desktop-readability floor once labels grow it). Routing +// reads only components and connections, never boundaries or the viewBox. +const { pathFor, connectionSides, connectionEndpointSide } = createRouter(components, arch.connections); +// The auto canvas has to cover these rects; an authored viewBox is never +// resized to fit them — there the containment rule reports the clipping. +const connectionLabels = connectionLabelRects(); + const rawBoundaries = asArray(arch.boundaries).map(boundaryRect).filter(Boolean); function resolveBoundaryTitles() { if (!enforcesBoundaryTitleComposition || rawBoundaries.length === 0) { @@ -330,7 +357,10 @@ function componentContext(component) { } // ---- Auto viewBox: fit all geometry + the measured resolved legend ---------- -const viewBox = arch.meta?.viewBox || autoViewBoxFor(boundaries); +// Connection labels are diagram content, so an auto canvas that stopped at the +// component/boundary bbox would clip them; the label rects join the fit here +// and in the title convergence above, which sizes fonts for this same width. +const viewBox = arch.meta?.viewBox || autoViewBoxFor(boundaries, connectionLabels); const legendY = () => viewBox[1] - 16; // ---- Validation: mechanical correctness, never layout taste ----------------- @@ -564,17 +594,11 @@ function validateArchitecture() { })); // Connection labels must not land on top of components. - const labelRects = []; - for (const [connectionIndex, conn] of asArray(arch.connections).entries()) { - if (!conn.label || !components.has(conn.from) || !components.has(conn.to)) continue; - const [lx, ly] = labelPoint(conn, pathFor(conn).points); - const w = Math.max(30, textUnits(conn.label) * 4.8 + 10); - labelRects.push({ relation: conn, relationIndex: connectionIndex, label: conn.label, x: lx - w / 2, y: ly - 10, width: w, height: 14, lx, ly }); - } + const labelRects = connectionLabels; for (const rect of labelRects) { for (const c of components.values()) { if (rectsOverlap(rect, c, -2)) { - problems.push(`Label "${rect.label}" overlaps component "${c.id}" — adjust labelDx/labelDy/labelSegment or set labelAt.\n${suggestLabelObstacleFix(rect, rect.lx, rect.ly, c)}`); + problems.push(`Label "${rect.label}" overlaps component "${c.id}" — adjust labelDx/labelDy/labelSegment or set labelAt.\n${suggestLabelObstacleFix(rect, rect.lx, rect.ly, c, 'component', viewBox, components.values())}`); } } if (enforcesBoundaryTitleComposition) { @@ -595,6 +619,16 @@ function validateArchitecture() { relationCollection: 'connections', profile: arch.meta?.quality_profile, })); + // See collectLabelCanvasOverflow in shared/geometry.mjs. An auto canvas now + // covers these rects, so this reports authored viewBoxes and the origin side, + // which growth cannot reach. + problems.push(...cleanLabelCanvasContainmentProblems({ + labels: labelRects, + viewBox, + diagramType: 'architecture', + relationCollection: 'connections', + profile: arch.meta?.quality_profile, + })); if (problems.length) { throwDiagnosticProblems('Architecture layout validation failed', problems, { @@ -604,20 +638,14 @@ function validateArchitecture() { } function buildLayoutReport() { - const labels = []; - for (const conn of asArray(arch.connections)) { - if (!conn.label || !components.has(conn.from) || !components.has(conn.to)) continue; - const [lx, ly] = labelPoint(conn, pathFor(conn).points); - const w = Math.max(30, textUnits(conn.label) * 4.8 + 10); - labels.push({ - text: conn.label, - x: Math.round(lx - w / 2), - y: Math.round(ly - 10), - width: Math.round(w), - height: 14, - labelAt: [Math.round(lx), Math.round(ly)], - }); - } + const labels = connectionLabels.map((rect) => ({ + text: rect.label, + x: Math.round(rect.x), + y: Math.round(rect.y), + width: Math.round(rect.width), + height: 14, + labelAt: [Math.round(rect.lx), Math.round(rect.ly)], + })); return { ok: true, diagram_type: 'architecture', @@ -636,316 +664,6 @@ function buildLayoutReport() { }; } -// ---- Connection routing ------------------------------------------------------ -function routeClearsComponents(conn, points, clearance = 2) { - const endpointIds = new Set([conn.from, conn.to]); - for (const component of components.values()) { - if (endpointIds.has(component.id)) continue; - for (let index = 0; index < points.length - 1; index += 1) { - if (segmentIntersectsRect({ start: points[index], end: points[index + 1] }, component, clearance)) { - return false; - } - } - } - return true; -} - -function routeClearsEndpointComponents(points, from, to) { - const lastSegment = points.length - 2; - for (let index = 0; index <= lastSegment; index += 1) { - const segment = { start: points[index], end: points[index + 1] }; - if (index > 0 && segmentIntersectsRect(segment, from)) return false; - if (index < lastSegment && segmentIntersectsRect(segment, to)) return false; - } - return true; -} - -const OUTWARD_SIDE_VECTOR = { - left: [-1, 0], - right: [1, 0], - top: [0, -1], - bottom: [0, 1], -}; - -function outwardStub(point, side, distance = 24) { - const [dx, dy] = OUTWARD_SIDE_VECTOR[side] || [0, 0]; - return [point[0] + dx * distance, point[1] + dy * distance]; -} - -function collinearBacktrack(a, b, c) { - const first = [b[0] - a[0], b[1] - a[1]]; - const second = [c[0] - b[0], c[1] - b[1]]; - const cross = first[0] * second[1] - first[1] * second[0]; - const dot = first[0] * second[0] + first[1] * second[1]; - return Math.abs(cross) <= 0.0001 && dot < -0.0001; -} - -function sideAwareBridgeCandidates(start, end, fromSide, toSide) { - const startStub = outwardStub(start, fromSide); - const endStub = outwardStub(end, toSide); - const rawCandidates = []; - const minimumBridge = 16; - const verticalSides = new Set(['top', 'bottom']); - const horizontalSides = new Set(['left', 'right']); - - // Port spreading can leave parallel-side anchors only a few pixels apart. - // Route through a bounded outside channel so we keep both endpoint normals - // without introducing a tiny, noisy connector between the two stubs. - if (verticalSides.has(fromSide) && verticalSides.has(toSide) - && Math.abs(start[0] - end[0]) < minimumBridge) { - for (const channelX of [ - Math.max(start[0], end[0]) + minimumBridge, - Math.min(start[0], end[0]) - minimumBridge, - ]) { - rawCandidates.push([ - startStub, - [channelX, startStub[1]], - [channelX, endStub[1]], - endStub, - ]); - } - } - if (horizontalSides.has(fromSide) && horizontalSides.has(toSide) - && Math.abs(start[1] - end[1]) < minimumBridge) { - for (const channelY of [ - Math.max(start[1], end[1]) + minimumBridge, - Math.min(start[1], end[1]) - minimumBridge, - ]) { - rawCandidates.push([ - startStub, - [startStub[0], channelY], - [endStub[0], channelY], - endStub, - ]); - } - } - - rawCandidates.push( - [startStub, [endStub[0], startStub[1]], endStub], - [startStub, [startStub[0], endStub[1]], endStub], - ); - return rawCandidates.map((candidate) => normalizeRoutePoints([start, ...candidate, end])) - .filter((points) => points.length >= 2) - .filter((points) => !collinearBacktrack(points[0], points[1], points[2] || points[1])) - .filter((points) => !collinearBacktrack(points.at(-3) || points.at(-2), points.at(-2), points.at(-1))) - .filter((points) => routeHonorsEndpointSides(points, fromSide, toSide)) - .map((points) => points.slice(1, -1)); -} - -const AUTOMATIC_PORT_CORNER_GUTTER = 16; -const AUTOMATIC_PORT_ALIGNMENT_DELTA = 16; - -function portHasCornerClearance(rect, side, point) { - if (side === 'left' || side === 'right') { - const inset = Math.min(AUTOMATIC_PORT_CORNER_GUTTER, rect.height / 2); - return point[1] >= rect.y + inset && point[1] <= rect.y + rect.height - inset; - } - if (side === 'top' || side === 'bottom') { - const inset = Math.min(AUTOMATIC_PORT_CORNER_GUTTER, rect.width / 2); - return point[0] >= rect.x + inset && point[0] <= rect.x + rect.width - inset; - } - return false; -} - -function alignFacingPorts(conn, from, to, start, end, fromSide, toSide, ports) { - const hasExplicitGeometry = ( - conn.via - || (conn.route && conn.route !== 'auto') - || conn.channelX !== undefined - || conn.channelY !== undefined - || conn.labelAt - ); - const horizontallyFacing = ( - (fromSide === 'right' && toSide === 'left') - || (fromSide === 'left' && toSide === 'right') - ); - const verticallyFacing = ( - (fromSide === 'bottom' && toSide === 'top') - || (fromSide === 'top' && toSide === 'bottom') - ); - if (hasExplicitGeometry || (!horizontallyFacing && !verticallyFacing)) return { start, end }; - - const fromSpread = Boolean(ports?.from); - const toSpread = Boolean(ports?.to); - if (fromSpread && toSpread) return { start, end }; - const hasExplicitSides = ( - (conn.fromSide && conn.fromSide !== 'auto') - || (conn.toSide && conn.toSide !== 'auto') - ); - if (!fromSpread && !toSpread && hasExplicitSides) return { start, end }; - - const alignmentDelta = horizontallyFacing - ? Math.abs(start[1] - end[1]) - : Math.abs(start[0] - end[0]); - if (alignmentDelta >= AUTOMATIC_PORT_ALIGNMENT_DELTA) return { start, end }; - - // Keep the shared endpoint's distinct spread slot and move only the - // relationship's unshared endpoint onto that axis. With no spread endpoint, - // retain the existing least-movement choice between the two facing sides. - // If both endpoints are shared, preserve the outside bridge so no competing - // port is silently collapsed. - const alignEndToStart = horizontallyFacing - ? { start, end: [end[0], start[1]] } - : { start, end: [start[0], end[1]] }; - const alignStartToEnd = horizontallyFacing - ? { start: [start[0], end[1]], end } - : { start: [end[0], start[1]], end }; - const candidates = fromSpread - ? [alignEndToStart] - : toSpread - ? [alignStartToEnd] - : [alignEndToStart, alignStartToEnd]; - for (const candidate of candidates) { - const points = [candidate.start, candidate.end]; - if (portHasCornerClearance(from, fromSide, candidate.start) - && portHasCornerClearance(to, toSide, candidate.end) - && routeHonorsEndpointSides(points, fromSide, toSide) - && routeClearsEndpointComponents(points, from, to) - && routeClearsComponents(conn, points)) { - return candidate; - } - } - return { start, end }; -} - -function routeVia(conn, from, to, start, end, fromSide, toSide) { - if (conn.via) return conn.via; - switch (conn.route || 'auto') { - case 'straight': - return []; - case 'orthogonal-h': { - const midX = (start[0] + end[0]) / 2; - return [[midX, start[1]], [midX, end[1]]]; - } - case 'orthogonal-v': { - const midY = (start[1] + end[1]) / 2; - return [[start[0], midY], [end[0], midY]]; - } - case 'auto': - default: { - // Direct line unless the anchors are clearly orthogonal-friendly. - const deltaX = Math.abs(start[0] - end[0]); - const deltaY = Math.abs(start[1] - end[1]); - if ((deltaX < 4 || deltaY < 4) && routeHonorsEndpointSides([start, end], fromSide, toSide)) return []; - - const rhythmBridge = automaticPortRhythmBridge(start, end, fromSide, toSide, { - accept: (points) => ( - routeClearsEndpointComponents(points, from, to) - && routeClearsComponents(conn, points) - ), - }); - if (rhythmBridge) return rhythmBridge.slice(1, -1); - - // Automatic port spreading can leave otherwise aligned endpoints only a - // few pixels apart. A midpoint route would split that tiny difference - // into two unreadable endpoint stubs, so take a bounded outside channel - // when both anchors sit on parallel component sides. - const minimumStub = 8; - const fromVerticalSide = start[1] === from.y || start[1] === from.y + from.height; - const toVerticalSide = end[1] === to.y || end[1] === to.y + to.height; - if (fromVerticalSide && toVerticalSide && deltaX < minimumStub * 2) { - const outsideChannels = [ - Math.max(start[0], end[0]) + minimumStub * 2, - Math.min(start[0], end[0]) - minimumStub * 2, - ]; - for (const channelX of outsideChannels) { - const candidate = [[channelX, start[1]], [channelX, end[1]]]; - const points = [start, ...candidate, end]; - if (routeHonorsEndpointSides(points, fromSide, toSide) && routeClearsComponents(conn, points)) return candidate; - } - } - - const fromHorizontalSide = start[0] === from.x || start[0] === from.x + from.width; - const toHorizontalSide = end[0] === to.x || end[0] === to.x + to.width; - if (fromHorizontalSide && toHorizontalSide && deltaY < minimumStub * 2) { - const outsideChannels = [ - Math.max(start[1], end[1]) + minimumStub * 2, - Math.min(start[1], end[1]) - minimumStub * 2, - ]; - for (const channelY of outsideChannels) { - const candidate = [[start[0], channelY], [end[0], channelY]]; - const points = [start, ...candidate, end]; - if (routeHonorsEndpointSides(points, fromSide, toSide) && routeClearsComponents(conn, points)) return candidate; - } - } - - const midX = (start[0] + end[0]) / 2; - const horizontalFirst = [[midX, start[1]], [midX, end[1]]]; - const midY = (start[1] + end[1]) / 2; - const verticalFirst = [[start[0], midY], [end[0], midY]]; - const candidates = [horizontalFirst, verticalFirst]; - const sideSafe = candidates.filter((candidate) => ( - routeHonorsEndpointSides([start, ...candidate, end], fromSide, toSide) - )); - const sideAware = sideAwareBridgeCandidates(start, end, fromSide, toSide); - const nearParallelPorts = ( - ((fromSide === 'top' || fromSide === 'bottom') - && (toSide === 'top' || toSide === 'bottom') - && deltaX < minimumStub * 2) - || ((fromSide === 'left' || fromSide === 'right') - && (toSide === 'left' || toSide === 'right') - && deltaY < minimumStub * 2) - ); - const ordered = [ - ...(nearParallelPorts ? sideAware : sideSafe), - ...(nearParallelPorts ? sideSafe : sideAware), - ...candidates.filter((candidate) => !sideSafe.includes(candidate)), - ]; - for (const candidate of ordered) { - const points = [start, ...candidate, end]; - if (routeClearsEndpointComponents(points, from, to) && routeClearsComponents(conn, points)) return candidate; - } - - // Both bounded doglegs are blocked. Keep the best endpoint-safe route - // when one exists so the universal Clean Flow gate reports the actual - // obstacle; otherwise preserve the historical deterministic fallback - // and let the endpoint-direction gate explain the side mismatch. - return sideSafe[0] || sideAware[0] || horizontalFirst; - } - } -} - -const pathCache = new Map(); -const automaticPorts = automaticPortSpread(arch.connections, components); -function connectionSides(conn) { - const from = components.get(conn.from); - const to = components.get(conn.to); - return { - fromSide: chosenSide(conn.fromSide, defaultFromSide(from, to)), - toSide: chosenSide(conn.toSide, defaultToSide(from, to)), - }; -} - -function connectionEndpointSide(conn, endpoint) { - const field = endpoint === 'source' ? 'fromSide' : 'toSide'; - if (conn[field] && conn[field] !== 'auto') return conn[field]; - return connectionSides(conn)[field]; -} - -function pathFor(conn) { - if (pathCache.has(conn)) return pathCache.get(conn); - const from = components.get(conn.from); - const to = components.get(conn.to); - const ports = automaticPorts.get(conn); - const { fromSide, toSide } = connectionSides(conn); - const baseStart = ports?.from || anchor(from, fromSide); - const baseEnd = ports?.to || anchor(to, toSide); - const { start, end } = alignFacingPorts( - conn, - from, - to, - baseStart, - baseEnd, - fromSide, - toSide, - ports, - ); - const points = [start, ...routeVia(conn, from, to, start, end, fromSide, toSide), end]; - const routed = { d: roundedPath(points, 8), points }; - pathCache.set(conn, routed); - return routed; -} - // ---- Rendering --------------------------------------------------------------- function renderBoundaryFrame(b, index) { const cls = b.kind === 'security-group' ? 'c-security-group' : 'c-region'; @@ -965,16 +683,15 @@ function renderConnectionPath(conn, index) { const [cls, marker] = arrowClassMap[conn.variant || 'default'] || arrowClassMap.default; const routed = pathFor(conn); const strokeWidth = conn.width || (conn.variant === 'emphasis' ? 1.8 : 1.5); - return ` `; + return ` `; } function renderConnectionLabel(conn, index) { - if (!conn.label) return ''; - const [lx, ly] = labelPoint(conn, pathFor(conn).points); - const w = Math.max(30, textUnits(conn.label) * 4.8 + 10); + const box = connectionLabelBox(conn); + if (!box) return ''; return ` - - ${esc(conn.label)} + + ${esc(conn.label)} `; } @@ -1006,12 +723,7 @@ function renderLegend() { const entries = architectureLegendEntries; const relationshipObstacles = relationshipLegendObstacles(arch.connections, { pointsFor: (connection) => pathFor(connection).points, - labelRectFor: (connection) => { - if (!connection.label) return null; - const [x, y] = labelPoint(connection, pathFor(connection).points); - const width = Math.max(30, textUnits(connection.label) * 4.8 + 10); - return { x: x - width / 2, y: y - 10, width, height: 14 }; - }, + labelRectFor: connectionLabelBox, }); const contentBottom = Math.max( 0, diff --git a/archify/renderers/architecture/routing.mjs b/archify/renderers/architecture/routing.mjs new file mode 100644 index 000000000..d873a7378 --- /dev/null +++ b/archify/renderers/architecture/routing.mjs @@ -0,0 +1,336 @@ +// Internal architecture router shared by rendering and geometry inspection. +// Create a new router when measured boxes or connections change: port spreading +// is computed once and route results are cached for this scene. + +import { + segmentIntersectsRect, + anchor, + automaticPortSpread, + automaticPortRhythmBridge, + defaultFromSide, + defaultToSide, + chosenSide, + routeHonorsEndpointSides, + normalizeRoutePoints, + roundedPath, +} from '../shared/geometry.mjs'; + +/** + * Router bound to one set of measured component boxes. + * + * @param {Map} components measured boxes by id + * @param {Array} connections the connection list to spread ports across + */ +export function createRouter(components, connections) { + // ---- Connection routing ------------------------------------------------------ + function routeClearsComponents(conn, points, clearance = 2) { + const endpointIds = new Set([conn.from, conn.to]); + for (const component of components.values()) { + if (endpointIds.has(component.id)) continue; + for (let index = 0; index < points.length - 1; index += 1) { + if (segmentIntersectsRect({ start: points[index], end: points[index + 1] }, component, clearance)) { + return false; + } + } + } + return true; + } + + function routeClearsEndpointComponents(points, from, to) { + const lastSegment = points.length - 2; + for (let index = 0; index <= lastSegment; index += 1) { + const segment = { start: points[index], end: points[index + 1] }; + if (index > 0 && segmentIntersectsRect(segment, from)) return false; + if (index < lastSegment && segmentIntersectsRect(segment, to)) return false; + } + return true; + } + + const OUTWARD_SIDE_VECTOR = { + left: [-1, 0], + right: [1, 0], + top: [0, -1], + bottom: [0, 1], + }; + + function outwardStub(point, side, distance = 24) { + const [dx, dy] = OUTWARD_SIDE_VECTOR[side] || [0, 0]; + return [point[0] + dx * distance, point[1] + dy * distance]; + } + + function collinearBacktrack(a, b, c) { + const first = [b[0] - a[0], b[1] - a[1]]; + const second = [c[0] - b[0], c[1] - b[1]]; + const cross = first[0] * second[1] - first[1] * second[0]; + const dot = first[0] * second[0] + first[1] * second[1]; + return Math.abs(cross) <= 0.0001 && dot < -0.0001; + } + + function sideAwareBridgeCandidates(start, end, fromSide, toSide) { + const startStub = outwardStub(start, fromSide); + const endStub = outwardStub(end, toSide); + const rawCandidates = []; + const minimumBridge = 16; + const verticalSides = new Set(['top', 'bottom']); + const horizontalSides = new Set(['left', 'right']); + + // Port spreading can leave parallel-side anchors only a few pixels apart. + // Route through a bounded outside channel so we keep both endpoint normals + // without introducing a tiny, noisy connector between the two stubs. + if (verticalSides.has(fromSide) && verticalSides.has(toSide) + && Math.abs(start[0] - end[0]) < minimumBridge) { + for (const channelX of [ + Math.max(start[0], end[0]) + minimumBridge, + Math.min(start[0], end[0]) - minimumBridge, + ]) { + rawCandidates.push([ + startStub, + [channelX, startStub[1]], + [channelX, endStub[1]], + endStub, + ]); + } + } + if (horizontalSides.has(fromSide) && horizontalSides.has(toSide) + && Math.abs(start[1] - end[1]) < minimumBridge) { + for (const channelY of [ + Math.max(start[1], end[1]) + minimumBridge, + Math.min(start[1], end[1]) - minimumBridge, + ]) { + rawCandidates.push([ + startStub, + [startStub[0], channelY], + [endStub[0], channelY], + endStub, + ]); + } + } + + rawCandidates.push( + [startStub, [endStub[0], startStub[1]], endStub], + [startStub, [startStub[0], endStub[1]], endStub], + ); + return rawCandidates.map((candidate) => normalizeRoutePoints([start, ...candidate, end])) + .filter((points) => points.length >= 2) + .filter((points) => !collinearBacktrack(points[0], points[1], points[2] || points[1])) + .filter((points) => !collinearBacktrack(points.at(-3) || points.at(-2), points.at(-2), points.at(-1))) + .filter((points) => routeHonorsEndpointSides(points, fromSide, toSide)) + .map((points) => points.slice(1, -1)); + } + + const AUTOMATIC_PORT_CORNER_GUTTER = 16; + const AUTOMATIC_PORT_ALIGNMENT_DELTA = 16; + + function portHasCornerClearance(rect, side, point) { + if (side === 'left' || side === 'right') { + const inset = Math.min(AUTOMATIC_PORT_CORNER_GUTTER, rect.height / 2); + return point[1] >= rect.y + inset && point[1] <= rect.y + rect.height - inset; + } + if (side === 'top' || side === 'bottom') { + const inset = Math.min(AUTOMATIC_PORT_CORNER_GUTTER, rect.width / 2); + return point[0] >= rect.x + inset && point[0] <= rect.x + rect.width - inset; + } + return false; + } + + function alignFacingPorts(conn, from, to, start, end, fromSide, toSide, ports) { + const hasExplicitGeometry = ( + conn.via + || (conn.route && conn.route !== 'auto') + || conn.channelX !== undefined + || conn.channelY !== undefined + || conn.labelAt + ); + const horizontallyFacing = ( + (fromSide === 'right' && toSide === 'left') + || (fromSide === 'left' && toSide === 'right') + ); + const verticallyFacing = ( + (fromSide === 'bottom' && toSide === 'top') + || (fromSide === 'top' && toSide === 'bottom') + ); + if (hasExplicitGeometry || (!horizontallyFacing && !verticallyFacing)) return { start, end }; + + const fromSpread = Boolean(ports?.from); + const toSpread = Boolean(ports?.to); + if (fromSpread && toSpread) return { start, end }; + const hasExplicitSides = ( + (conn.fromSide && conn.fromSide !== 'auto') + || (conn.toSide && conn.toSide !== 'auto') + ); + if (!fromSpread && !toSpread && hasExplicitSides) return { start, end }; + + const alignmentDelta = horizontallyFacing + ? Math.abs(start[1] - end[1]) + : Math.abs(start[0] - end[0]); + if (alignmentDelta >= AUTOMATIC_PORT_ALIGNMENT_DELTA) return { start, end }; + + // Keep the shared endpoint's distinct spread slot and move only the + // relationship's unshared endpoint onto that axis. With no spread endpoint, + // retain the existing least-movement choice between the two facing sides. + // If both endpoints are shared, preserve the outside bridge so no competing + // port is silently collapsed. + const alignEndToStart = horizontallyFacing + ? { start, end: [end[0], start[1]] } + : { start, end: [start[0], end[1]] }; + const alignStartToEnd = horizontallyFacing + ? { start: [start[0], end[1]], end } + : { start: [end[0], start[1]], end }; + const candidates = fromSpread + ? [alignEndToStart] + : toSpread + ? [alignStartToEnd] + : [alignEndToStart, alignStartToEnd]; + for (const candidate of candidates) { + const points = [candidate.start, candidate.end]; + if (portHasCornerClearance(from, fromSide, candidate.start) + && portHasCornerClearance(to, toSide, candidate.end) + && routeHonorsEndpointSides(points, fromSide, toSide) + && routeClearsEndpointComponents(points, from, to) + && routeClearsComponents(conn, points)) { + return candidate; + } + } + return { start, end }; + } + + function routeVia(conn, from, to, start, end, fromSide, toSide) { + if (conn.via) return conn.via; + switch (conn.route || 'auto') { + case 'straight': + return []; + case 'orthogonal-h': { + const midX = (start[0] + end[0]) / 2; + return [[midX, start[1]], [midX, end[1]]]; + } + case 'orthogonal-v': { + const midY = (start[1] + end[1]) / 2; + return [[start[0], midY], [end[0], midY]]; + } + case 'auto': + default: { + // Direct line unless the anchors are clearly orthogonal-friendly. + const deltaX = Math.abs(start[0] - end[0]); + const deltaY = Math.abs(start[1] - end[1]); + if ((deltaX < 4 || deltaY < 4) && routeHonorsEndpointSides([start, end], fromSide, toSide)) return []; + + const rhythmBridge = automaticPortRhythmBridge(start, end, fromSide, toSide, { + accept: (points) => ( + routeClearsEndpointComponents(points, from, to) + && routeClearsComponents(conn, points) + ), + }); + if (rhythmBridge) return rhythmBridge.slice(1, -1); + + // Automatic port spreading can leave otherwise aligned endpoints only a + // few pixels apart. A midpoint route would split that tiny difference + // into two unreadable endpoint stubs, so take a bounded outside channel + // when both anchors sit on parallel component sides. + const minimumStub = 8; + const fromVerticalSide = start[1] === from.y || start[1] === from.y + from.height; + const toVerticalSide = end[1] === to.y || end[1] === to.y + to.height; + if (fromVerticalSide && toVerticalSide && deltaX < minimumStub * 2) { + const outsideChannels = [ + Math.max(start[0], end[0]) + minimumStub * 2, + Math.min(start[0], end[0]) - minimumStub * 2, + ]; + for (const channelX of outsideChannels) { + const candidate = [[channelX, start[1]], [channelX, end[1]]]; + const points = [start, ...candidate, end]; + if (routeHonorsEndpointSides(points, fromSide, toSide) && routeClearsComponents(conn, points)) return candidate; + } + } + + const fromHorizontalSide = start[0] === from.x || start[0] === from.x + from.width; + const toHorizontalSide = end[0] === to.x || end[0] === to.x + to.width; + if (fromHorizontalSide && toHorizontalSide && deltaY < minimumStub * 2) { + const outsideChannels = [ + Math.max(start[1], end[1]) + minimumStub * 2, + Math.min(start[1], end[1]) - minimumStub * 2, + ]; + for (const channelY of outsideChannels) { + const candidate = [[start[0], channelY], [end[0], channelY]]; + const points = [start, ...candidate, end]; + if (routeHonorsEndpointSides(points, fromSide, toSide) && routeClearsComponents(conn, points)) return candidate; + } + } + + const midX = (start[0] + end[0]) / 2; + const horizontalFirst = [[midX, start[1]], [midX, end[1]]]; + const midY = (start[1] + end[1]) / 2; + const verticalFirst = [[start[0], midY], [end[0], midY]]; + const candidates = [horizontalFirst, verticalFirst]; + const sideSafe = candidates.filter((candidate) => ( + routeHonorsEndpointSides([start, ...candidate, end], fromSide, toSide) + )); + const sideAware = sideAwareBridgeCandidates(start, end, fromSide, toSide); + const nearParallelPorts = ( + ((fromSide === 'top' || fromSide === 'bottom') + && (toSide === 'top' || toSide === 'bottom') + && deltaX < minimumStub * 2) + || ((fromSide === 'left' || fromSide === 'right') + && (toSide === 'left' || toSide === 'right') + && deltaY < minimumStub * 2) + ); + const ordered = [ + ...(nearParallelPorts ? sideAware : sideSafe), + ...(nearParallelPorts ? sideSafe : sideAware), + ...candidates.filter((candidate) => !sideSafe.includes(candidate)), + ]; + for (const candidate of ordered) { + const points = [start, ...candidate, end]; + if (routeClearsEndpointComponents(points, from, to) && routeClearsComponents(conn, points)) return candidate; + } + + // Both bounded doglegs are blocked. Keep the best endpoint-safe route + // when one exists so the universal Clean Flow gate reports the actual + // obstacle; otherwise preserve the historical deterministic fallback + // and let the endpoint-direction gate explain the side mismatch. + return sideSafe[0] || sideAware[0] || horizontalFirst; + } + } + } + + const pathCache = new Map(); + const automaticPorts = automaticPortSpread(connections, components); + function connectionSides(conn) { + const from = components.get(conn.from); + const to = components.get(conn.to); + return { + fromSide: chosenSide(conn.fromSide, defaultFromSide(from, to)), + toSide: chosenSide(conn.toSide, defaultToSide(from, to)), + }; + } + + function connectionEndpointSide(conn, endpoint) { + const field = endpoint === 'source' ? 'fromSide' : 'toSide'; + if (conn[field] && conn[field] !== 'auto') return conn[field]; + return connectionSides(conn)[field]; + } + + function pathFor(conn) { + if (pathCache.has(conn)) return pathCache.get(conn); + const from = components.get(conn.from); + const to = components.get(conn.to); + const ports = automaticPorts.get(conn); + const { fromSide, toSide } = connectionSides(conn); + const baseStart = ports?.from || anchor(from, fromSide); + const baseEnd = ports?.to || anchor(to, toSide); + const { start, end } = alignFacingPorts( + conn, + from, + to, + baseStart, + baseEnd, + fromSide, + toSide, + ports, + ); + const points = [start, ...routeVia(conn, from, to, start, end, fromSide, toSide), end]; + const routed = { d: roundedPath(points, 8), points }; + pathCache.set(conn, routed); + return routed; + } + + return { pathFor, connectionSides, connectionEndpointSide }; +} diff --git a/archify/renderers/dataflow/render-dataflow.mjs b/archify/renderers/dataflow/render-dataflow.mjs index 3eda9d4a9..b095185f1 100644 --- a/archify/renderers/dataflow/render-dataflow.mjs +++ b/archify/renderers/dataflow/render-dataflow.mjs @@ -18,6 +18,7 @@ import { cleanBorderRunProblems, cleanRouteRhythmProblems, cleanLabelRouteClearanceProblems, + cleanLabelCanvasContainmentProblems, suggestLabelObstacleFix, suggestLabelPairFix, anchor, @@ -27,11 +28,12 @@ import { chosenSide, polylinePath, routePointsValue, + authoredStraightRouteAttrs, labelPoint, componentFill, componentText, arrowClassMap, - variantAccent + edgeLabelAccent } from '../shared/geometry.mjs'; const nodeTextFit = { @@ -259,7 +261,7 @@ function validateDataflow() { for (const rect of labelRects) { for (const node of nodes.values()) { if (rectsOverlap(rect, node, -2)) { - problems.push(`Label "${rect.label}" overlaps node "${node.id}" — adjust labelDx/labelDy/labelSegment or set labelAt.\n${suggestLabelObstacleFix(rect, rect.lx, rect.ly, node, 'node')}`); + problems.push(`Label "${rect.label}" overlaps node "${node.id}" — adjust labelDx/labelDy/labelSegment or set labelAt.\n${suggestLabelObstacleFix(rect, rect.lx, rect.ly, node, 'node', viewBox, nodes.values())}`); } } } @@ -280,6 +282,13 @@ function validateDataflow() { profile: dataflow.meta?.quality_profile, routeHint: 'adjust labelAt, labelDx, labelDy, or labelSegment; otherwise adjust the other flow route/via/channelX/channelY' })); + problems.push(...cleanLabelCanvasContainmentProblems({ + labels: labelRects, + viewBox, + diagramType: 'dataflow', + relationCollection: 'flows', + profile: dataflow.meta?.quality_profile, + })); const lastStageX = stageX(asArray(dataflow.stages).length - 1); if (lastStageX + layout.stageW / 2 > viewBox[0] - 24) { @@ -399,7 +408,7 @@ function renderFlowPath(flow, index) { const [cls, marker] = arrowClassMap[flow.variant || 'default'] || arrowClassMap.default; const routed = pathFor(flow); const strokeWidth = flow.width || (flow.variant === 'emphasis' ? 1.8 : 1.4); - return ` `; + return ` `; } function renderFlowLabel(flow, index) { @@ -411,7 +420,7 @@ function renderFlowLabel(flow, index) { : ''; return ` - ${esc(flow.label)}${classification} + ${esc(flow.label)}${classification} `; } diff --git a/archify/renderers/lifecycle/README.md b/archify/renderers/lifecycle/README.md index b66a7ddf9..733d29608 100644 --- a/archify/renderers/lifecycle/README.md +++ b/archify/renderers/lifecycle/README.md @@ -89,8 +89,9 @@ corners; tune them with `cornerRadius` (default 10, `0` for sharp bends). - Use lower lanes only for interruptions, recovery, and terminal exits. - Keep transition labels out of the main SVG unless the label is essential; prefer node labels, tags, legend entries, and summary cards. -- Avoid diagonal and crossing lines. Terminal exits should drop vertically from - their source event whenever possible. +- Prefer axis-aligned lines and avoid crossings. Terminal exits should drop + vertically from their source event whenever possible. Explicit `straight` + routes remain supported; see the [authored routing contract](../../references/authoring-contract.md#executable-geometry-rules). - Use `success` for completion, `failure` for failure/terminal exits, `waiting` for pauses, and `decision` for quality gates. diff --git a/archify/renderers/lifecycle/render-lifecycle.mjs b/archify/renderers/lifecycle/render-lifecycle.mjs index eb7922c47..2725d9cb8 100644 --- a/archify/renderers/lifecycle/render-lifecycle.mjs +++ b/archify/renderers/lifecycle/render-lifecycle.mjs @@ -18,6 +18,7 @@ import { cleanBorderRunProblems, cleanRouteRhythmProblems, cleanLabelRouteClearanceProblems, + cleanLabelCanvasContainmentProblems, suggestLabelObstacleFix, suggestLabelPairFix, anchor, @@ -27,9 +28,10 @@ import { chosenSide, roundedPath, routePointsValue, + authoredStraightRouteAttrs, labelPoint, arrowClassMap, - variantAccent + edgeLabelAccent } from '../shared/geometry.mjs'; const stateTextFit = { @@ -298,7 +300,7 @@ function validateLifecycle() { for (const rect of labelRects) { for (const state of states.values()) { if (rectsOverlap(rect, state, -2)) { - problems.push(`Label "${rect.label}" overlaps state "${state.id}" — adjust labelDx/labelDy/labelSegment or set labelAt.\n${suggestLabelObstacleFix(rect, rect.lx, rect.ly, state, 'state')}`); + problems.push(`Label "${rect.label}" overlaps state "${state.id}" — adjust labelDx/labelDy/labelSegment or set labelAt.\n${suggestLabelObstacleFix(rect, rect.lx, rect.ly, state, 'state', viewBox, states.values())}`); } } } @@ -318,6 +320,13 @@ function validateLifecycle() { relationCollection: 'transitions', profile: lifecycle.meta?.quality_profile, })); + problems.push(...cleanLabelCanvasContainmentProblems({ + labels: labelRects, + viewBox, + diagramType: 'lifecycle', + relationCollection: 'transitions', + profile: lifecycle.meta?.quality_profile, + })); if (problems.length) { throwDiagnosticProblems('Lifecycle layout validation failed', problems, { @@ -465,7 +474,7 @@ function renderTransitionPath(transition, index) { const [cls, marker] = arrowClassMap[transition.variant || 'default'] || arrowClassMap.default; const routed = pathFor(transition); const strokeWidth = transition.width || (transition.variant === 'emphasis' ? 2 : 1.1); - return ` `; + return ` `; } function renderTransitionLabel(transition, index) { @@ -480,7 +489,7 @@ function renderTransitionLabel(transition, index) { : ''; return ` - ${esc(transition.label)}${note} + ${esc(transition.label)}${note} `; } @@ -519,7 +528,7 @@ function renderLifecycleRail() { .map((state) => state.col); if (!mainCols.length) return ''; const railEnd = layout.phaseXs[Math.max(...mainCols)] + 38; - return ` `; + return ` `; } function renderSvg() { diff --git a/archify/renderers/sequence/render-sequence.mjs b/archify/renderers/sequence/render-sequence.mjs index 1cfc032a1..b6e76267e 100644 --- a/archify/renderers/sequence/render-sequence.mjs +++ b/archify/renderers/sequence/render-sequence.mjs @@ -4,7 +4,7 @@ import { esc, renderDefinitions, renderSemanticSigil, textUnits } from '../share import { animateAttr, focusEdgeAttrs, focusNodeAttrs, focusNodeTitle, loadDiagramWithBrandMarks, writeDiagram, svgAccessibleText, svgRootAttrs } from '../shared/cli.mjs'; import { throwDiagnosticProblems } from '../shared/diagnostics.mjs'; import { resolveLegend, renderLegend as renderResolvedLegend } from '../shared/legend.mjs'; -import { componentFill, arrowClassMap, rectsOverlap, cleanFlowProblems, cleanCrossingProblems, cleanAmbiguousCorridorProblems, cleanBorderRunProblems, cleanRouteRhythmProblems, cleanLabelRouteClearanceProblems, routePointsValue, asArray, isFinitePoint } from '../shared/geometry.mjs'; +import { componentFill, arrowClassMap, rectsOverlap, cleanFlowProblems, cleanCrossingProblems, cleanAmbiguousCorridorProblems, cleanBorderRunProblems, cleanRouteRhythmProblems, cleanLabelRouteClearanceProblems, cleanLabelCanvasContainmentProblems, routePointsValue, asArray, isFinitePoint } from '../shared/geometry.mjs'; import { availableNodeTextWidth, fittedNodeFontSize, minimumNodeTextWidth } from '../shared/text-fit.mjs'; import { brandLabelFitWidth, brandMetadataFor, brandTopRailProblem, renderBrandMark } from '../shared/brand-marks.mjs'; import { translateMessage as i18nText } from '../shared/i18n.mjs'; @@ -41,7 +41,11 @@ const colGap = columnFit === 'spread' && participantCount > 1 const layout = { topY: 72, participantW, - participantH: 54, + // Keep a separate top rail for the 11px semantic sigil and 16px brand mark. + // Literal labels retain their fitted font size and full authored wording. + participantH: 60, + participantLabelY: 36, + participantSublabelY: 50, lifelineTop: 142, lifelineBottom: viewBox[1] - 65, legendY: viewBox[1] - 54, @@ -275,6 +279,14 @@ function validateSequence() { profile: sequence.meta?.quality_profile, routeHint: 'spread the message y values, shorten the label, or reorder participants so the adjacent route stays visible' })); + problems.push(...cleanLabelCanvasContainmentProblems({ + labels: labelRects, + viewBox, + diagramType: 'sequence', + relationCollection: 'messages', + profile: sequence.meta?.quality_profile, + routeHint: 'shorten the label, reorder participants, or enlarge meta.viewBox', + })); for (const segment of asArray(sequence.segments)) { if (segment.to <= segment.from) { @@ -313,7 +325,7 @@ function renderParticipant(participant) { const fill = componentFill[participant.type] || 'c-external'; const hasSub = participant.sublabel != null && participant.sublabel !== ''; const sub = hasSub - ? `\n ${esc(participant.sublabel)}` + ? `\n ${esc(participant.sublabel)}` : ''; const brand = renderBrandMark(participant, { x: participant.x + layout.participantW - 22, y: layout.topY + 6 }); const labelFontSize = fittedNodeFontSize(participant.label, brandLabelFitWidth(participant, layout.participantW), 11, 8); @@ -328,7 +340,7 @@ function renderParticipant(participant) { ${renderSemanticSigil(participant.type, { x: participant.x + 6, y: layout.topY + 6 })}${brand ? `\n ${brand}` : ''} - ${esc(participant.label)}${sub} + ${esc(participant.label)}${sub} `; } diff --git a/archify/renderers/shared/brand-marks.mjs b/archify/renderers/shared/brand-marks.mjs index 1e18e0b53..9b0cf868b 100644 --- a/archify/renderers/shared/brand-marks.mjs +++ b/archify/renderers/shared/brand-marks.mjs @@ -269,6 +269,42 @@ async function readLimited(response, maximum) { return Buffer.concat(chunks, total); } +// Read only the bounded head, independent of network chunk boundaries. Scan +// bytes once so many tiny chunks cannot cause repeated concatenation/rescanning. +async function readHtmlHead(response, maximum) { + const chunks = response.body && typeof response.body[Symbol.asyncIterator] === 'function' + ? response.body : [await readLimited(response, maximum)]; + const buffer = Buffer.alloc(maximum); + const closing = Buffer.from('= 65 && byte <= 90 ? byte + 32 : byte; + matched = lower === closing[matched] ? matched + 1 : (byte === 0x3c ? 1 : 0); + } + } + total += length; + if (chunk.length > length) { + response.body?.destroy?.(); + throw new Error('brand asset is too large'); + } + } + return buffer.toString('utf8', 0, total); +} + function attribute(tag, name) { const match = tag.match(new RegExp(`\\b${name}\\s*=\\s*(?:"([^"]*)"|'([^']*)'|([^\\s>]+))`, 'i')); return match ? (match[1] ?? match[2] ?? match[3] ?? '') : ''; @@ -378,7 +414,7 @@ async function captureRemoteBrand(value, deadline = Date.now() + captureTimeoutM page.response.body?.destroy?.(); return fallback('linked page is not HTML'); } - const html = (await readLimited(page.response, MAX_HTML_BYTES)).toString('utf8'); + const html = await readHtmlHead(page.response, MAX_HTML_BYTES); const iconErrors = []; for (const candidate of iconCandidates(html, page.finalUrl)) { try { diff --git a/archify/renderers/shared/cli.mjs b/archify/renderers/shared/cli.mjs index f4a6eed04..1d18d0681 100644 --- a/archify/renderers/shared/cli.mjs +++ b/archify/renderers/shared/cli.mjs @@ -3,23 +3,49 @@ import path from 'node:path'; import { applyTemplate, renderCards, esc } from './utils.mjs'; import { validateSchema } from './validator.mjs'; import { verifyRepositoryEvidence } from './repository-evidence.mjs'; -import { installRendererDiagnosticBoundary, throwDiagnosticProblems } from './diagnostics.mjs'; +import { installRendererDiagnosticBoundary, throwDiagnosticError, throwDiagnosticProblems } from './diagnostics.mjs'; import { validateEngineeringProfile } from './engineering-profiles.mjs'; import { resolveOutputPath } from './output-path.mjs'; import { prepareDiagramBrandMarks } from './brand-marks.mjs'; import { resolveLocale, translateMessage } from './i18n.mjs'; -installRendererDiagnosticBoundary(); - const outputPathGuards = new Map(); // Common CLI head: node render-.mjs [input.json] [output.html] // Keep this synchronous because callers also use it to establish the guarded // output path before testing a last-moment filesystem alias change. export function loadDiagram({ rendererDir, diagramType, defaultExample, argv = process.argv }) { + // Compilers also import this module for SVG helpers. Only CLI execution + // should install a process-level handler, before reading or validating input. + installRendererDiagnosticBoundary(); const skillRoot = path.resolve(rendererDir, '../..'); const inputPath = path.resolve(argv[2] || path.join(skillRoot, 'examples', defaultExample)); - const diagram = JSON.parse(fs.readFileSync(inputPath, 'utf8')); + let input; + try { + input = fs.readFileSync(inputPath, 'utf8'); + } catch (error) { + if (!isFilesystemError(error)) throw error; + const message = `Input could not be read: ${error.message}`; + throwDiagnosticError(message, [{ + code: 'input/read', message, + subject: { input: inputPath }, + evidence: { systemCode: error.code, reason: error.message }, + supportedFixes: ['provide one readable JSON input file'], + }]); + } + let diagram; + try { + diagram = JSON.parse(input); + } catch (error) { + if (!(error instanceof SyntaxError)) throw error; + const message = `Input JSON could not be parsed: ${error.message}`; + throwDiagnosticError(message, [{ + code: 'input/json-parse', message, + subject: { input: inputPath }, + evidence: { reason: error.message }, + supportedFixes: ['repair the JSON syntax and run validation again'], + }]); + } validateSchema(diagramType, diagram); validateGuidedViews(diagramType, diagram); validateRelationshipIds(diagramType, diagram); @@ -33,7 +59,12 @@ export function loadDiagram({ rendererDir, diagramType, defaultExample, argv = p inputPaths: [inputPath], cwd: process.cwd(), }; - const { outputPath: outPath } = resolveOutputPath(outputRequest); + let outPath; + try { + ({ outputPath: outPath } = resolveOutputPath(outputRequest)); + } catch (error) { + throwOutputError(error, path.resolve(outputRequest.requestedOutput || outputRequest.authoredOutput || outputRequest.defaultOutput)); + } outputPathGuards.set(outPath, outputRequest); return { diagram, template, outPath, sourceEvidence }; } @@ -49,13 +80,28 @@ export async function loadDiagramWithBrandMarks(options) { const START_TYPES = new Set(['architecture', 'workflow', 'sequence', 'dataflow', 'lifecycle']); +function isFilesystemError(error) { + return typeof error?.code === 'string' + && typeof error?.syscall === 'string' + && typeof error?.errno === 'number'; +} + +function throwOutputError(error, output) { + if (error?.archifyDiagnostics || !isFilesystemError(error)) throw error; + const message = `Output could not be written: ${error.message}`; + throwDiagnosticError(message, [{ + code: 'output/write', message, + subject: { output }, + evidence: { systemCode: error.code, reason: error.message }, + supportedFixes: ['choose a writable HTML file path and ensure its parent directories can be created'], + }]); +} + // Common CLI tail: fill the template and write the standalone HTML file. export function writeDiagram({ outPath, template, diagramType, meta, svg, cards, sourceEvidence = null }) { if (!START_TYPES.has(diagramType)) throw new Error(`writeDiagram: unknown diagram type ${JSON.stringify(diagramType)}`); const outputGuard = outputPathGuards.get(outPath); - if (outputGuard) resolveOutputPath(outputGuard); - fs.mkdirSync(path.dirname(outPath), { recursive: true }); - fs.writeFileSync(outPath, applyTemplate(template, { + const html = applyTemplate(template, { title: meta.title, subtitle: meta.subtitle, svg, @@ -64,7 +110,14 @@ export function writeDiagram({ outPath, template, diagramType, meta, svg, cards, visualPreset: meta.visual_preset || 'classic', guidedViews: meta.views || [], sourceEvidence, - })); + }); + try { + if (outputGuard) resolveOutputPath(outputGuard); + fs.mkdirSync(path.dirname(outPath), { recursive: true }); + fs.writeFileSync(outPath, html); + } catch (error) { + throwOutputError(error, outPath); + } outputPathGuards.delete(outPath); console.log(outPath); } diff --git a/archify/renderers/shared/diagnostics.mjs b/archify/renderers/shared/diagnostics.mjs index 5d74de7dc..0b2673638 100644 --- a/archify/renderers/shared/diagnostics.mjs +++ b/archify/renderers/shared/diagnostics.mjs @@ -68,26 +68,6 @@ export function throwDiagnosticProblems(prefix, problems, { code = 'layout/const function fallbackDiagnostic(error) { const input = process.argv[2] ? path.resolve(process.argv[2]) : undefined; - if (error instanceof SyntaxError) { - return normalizedDiagnostic({ - code: 'input/json-parse', - severity: 'error', - message: `Input JSON could not be parsed: ${error.message}`, - subject: { input }, - evidence: { reason: error.message }, - supportedFixes: ['repair the JSON syntax and run validation again'], - }); - } - if (error?.code === 'ENOENT' || error?.code === 'EACCES' || error?.code === 'EISDIR') { - return normalizedDiagnostic({ - code: 'input/read', - severity: 'error', - message: `Input could not be read: ${error.message}`, - subject: { input }, - evidence: { systemCode: error.code, reason: error.message }, - supportedFixes: ['provide one readable JSON input file'], - }); - } return normalizedDiagnostic({ code: 'internal/unclassified', severity: 'error', @@ -101,7 +81,10 @@ function rendererFailure(error) { const attached = Array.isArray(error?.archifyDiagnostics) ? error.archifyDiagnostics.map(normalizedDiagnostic) : []; - const diagnostics = recorded.length ? recorded : (attached.length ? attached : [fallbackDiagnostic(error)]); + // Earlier diagnostics do not classify a later, unrelated implementation error. + const diagnostics = attached.length + ? (recorded.length ? recorded : attached) + : [fallbackDiagnostic(error)]; return { schemaVersion: 1, ok: false, @@ -111,6 +94,19 @@ function rendererFailure(error) { }; } +// Match the public CLI's text format without making its standalone doctor +// bootstrap depend on this renderer runtime being present. +function formatDiagnostics(error, diagnostics = []) { + if (!diagnostics.length) return error; + return [ + error, + ...diagnostics.map((entry) => { + const fix = entry.supportedFixes?.length ? ` Fix: ${entry.supportedFixes.join('; ')}.` : ''; + return `[${entry.code}] ${entry.message}${fix}`; + }), + ].join('\n'); +} + const readerSignal = new Int32Array(new SharedArrayBuffer(4)); function waitForReader() { @@ -124,8 +120,24 @@ function waitForReader() { } export function installRendererDiagnosticBoundary() { - if (!DIAGNOSTIC_MODE || globalThis[boundaryKey]) return; + if (globalThis[boundaryKey]) return; globalThis[boundaryKey] = true; + if (!DIAGNOSTIC_MODE) { + process.once('uncaughtException', (error) => { + // Only errors classified at their operation boundary are author-facing. + // Preserve Node's debugging information for unexpected implementation errors. + if (!error?.archifyDiagnostics?.length) { + // The once-listener is already removed. Rethrow outside the exception + // handler so Node retains its normal stack and exit code (not code 7). + process.nextTick(() => { throw error; }); + return; + } + const payload = `${formatDiagnostics(error.message, error.archifyDiagnostics)}\n`; + process.stderr.once('error', () => process.exit(1)); + process.stderr.write(payload, () => process.exit(1)); + }); + return; + } process.on('uncaughtException', (error) => { const payload = `${JSON.stringify(rendererFailure(error))}\n`; try { diff --git a/archify/renderers/shared/geometry.mjs b/archify/renderers/shared/geometry.mjs index 733129c2a..50a7d43c1 100644 --- a/archify/renderers/shared/geometry.mjs +++ b/archify/renderers/shared/geometry.mjs @@ -104,8 +104,82 @@ export function segmentRectIntersectionLength(segment, rect) { return length * Math.max(0, leave - enter); } +const ROUTE_INDEX_CELL_SIZE = 128; +const ROUTE_INDEX_MAX_CELLS_PER_ROUTE = 256; + +function routeBounds(route) { + let x1 = Infinity; + let y1 = Infinity; + let x2 = -Infinity; + let y2 = -Infinity; + for (const [x, y] of route.points) { + x1 = Math.min(x1, x); + y1 = Math.min(y1, y); + x2 = Math.max(x2, x); + y2 = Math.max(y2, y); + } + return { x1, y1, x2, y2 }; +} + +function createRouteCandidateIndex(routes) { + const cells = new Map(); + const globalRouteIndexes = []; + + routes.forEach((route, routeIndex) => { + const bounds = routeBounds(route); + const x1 = Math.floor(bounds.x1 / ROUTE_INDEX_CELL_SIZE); + const y1 = Math.floor(bounds.y1 / ROUTE_INDEX_CELL_SIZE); + const x2 = Math.floor(bounds.x2 / ROUTE_INDEX_CELL_SIZE); + const y2 = Math.floor(bounds.y2 / ROUTE_INDEX_CELL_SIZE); + if (![x1, y1, x2, y2].every(Number.isSafeInteger)) { + globalRouteIndexes.push(routeIndex); + return; + } + const cellCount = (x2 - x1 + 1) * (y2 - y1 + 1); + + // Very long routes are cheaper to test once per label than to duplicate + // across a large part of the index. + if (!Number.isSafeInteger(cellCount) || cellCount > ROUTE_INDEX_MAX_CELLS_PER_ROUTE) { + globalRouteIndexes.push(routeIndex); + return; + } + for (let y = y1; y <= y2; y += 1) { + for (let x = x1; x <= x2; x += 1) { + const key = `${x}:${y}`; + const bucket = cells.get(key); + if (bucket) bucket.push(routeIndex); + else cells.set(key, [routeIndex]); + } + } + }); + + return function candidates(rect, threshold) { + const x1 = Math.floor((rect.x - threshold) / ROUTE_INDEX_CELL_SIZE); + const y1 = Math.floor((rect.y - threshold) / ROUTE_INDEX_CELL_SIZE); + const x2 = Math.floor((rect.x + rect.width + threshold) / ROUTE_INDEX_CELL_SIZE); + const y2 = Math.floor((rect.y + rect.height + threshold) / ROUTE_INDEX_CELL_SIZE); + const cellCount = (x2 - x1 + 1) * (y2 - y1 + 1); + + // A huge threshold offers no selectivity and can overflow cell iteration. + if (![x1, y1, x2, y2].every(Number.isSafeInteger) + || !Number.isSafeInteger(cellCount) + || cellCount > Math.max(64, cells.size * 4)) return routes; + + const routeIndexes = new Set(globalRouteIndexes); + for (let y = y1; y <= y2; y += 1) { + for (let x = x1; x <= x2; x += 1) { + for (const routeIndex of cells.get(`${x}:${y}`) || []) routeIndexes.add(routeIndex); + } + } + return [...routeIndexes] + .sort((left, right) => left - right) + .map((routeIndex) => routes[routeIndex]); + }; +} + export function collectLabelRouteClearance({ labels, routedRelations, threshold }) { if (!Number.isFinite(threshold) || threshold < 0) return []; + if (threshold === 0) return []; const routeCandidates = asArray(routedRelations).map((entry, fallbackIndex) => { const relation = entry?.relation || entry; const points = normalizeRoutePoints(entry?.points || relation?.routePoints); @@ -125,6 +199,7 @@ export function collectLabelRouteClearance({ labels, routedRelations, threshold }); const hits = []; const seenLabels = new Set(); + const candidateRoutes = createRouteCandidateIndex(routes); for (const [fallbackIndex, label] of asArray(labels).entries()) { const rect = label?.rect || label; @@ -133,7 +208,7 @@ export function collectLabelRouteClearance({ labels, routedRelations, threshold const labelIdentity = relationshipIdentity(label?.relation, relationIndex); if (seenLabels.has(labelIdentity)) continue; seenLabels.add(labelIdentity); - for (const route of routes) { + for (const route of candidateRoutes(rect, threshold)) { if (relationIndex === route.relationIndex || sameRelationship(label?.relation, route.relation)) continue; let nearest = null; for (let segmentIndex = 0; segmentIndex < route.points.length - 1; segmentIndex += 1) { @@ -167,6 +242,15 @@ export function collectLabelRouteClearance({ labels, routedRelations, threshold return hits; } +export function minimumLabelRouteClearance(measurements) { + if (!asArray(measurements).length) return null; + const minimum = measurements.reduce( + (value, hit) => Math.min(value, hit.clearance), + Infinity, + ); + return Math.round(minimum * 10) / 10; +} + function relationshipIdentity(relation, relationIndex) { if (relation?.key !== undefined) return `key:${relation.key}`; if (relation?.id) return `id:${relation.from || ''}\u0000${relation.to || ''}\u0000${relation.id}`; @@ -943,6 +1027,111 @@ export function cleanLabelRouteClearanceProblems({ }); } +// How far [start, start + size) leaves [0, extent), per end of one axis. This +// is the single definition of "inside the canvas" that both the containment +// rule and the repair hints measure against; an unknown extent bounds nothing. +function axisCanvasOverflow(start, size, extent, origin = 0) { + if (!Number.isFinite(extent)) return { start: 0, end: 0 }; + return { start: origin - start, end: start + size - (origin + extent) }; +} + +// THE CANVAS CONTAINMENT RATIONALE (referenced from the other call sites). +// +// The SVG canvas clips whatever leaves the viewBox, so an edge label that +// overhangs an edge ships as truncated text while every post-render check on +// the emitted markup still passes: clipped text is still well-formed markup. +// Renderers with a fixed canvas already bound their nodes, lanes, and legends +// against it; edge label rects were the exception. +// +// Not applied to readable-v2 workflows: that compiler grows its canvas around +// pinned label rects and rejects an authored viewBox that cannot hold the +// result, so the only gap growth cannot close is the origin its own rule +// already guards. Architecture's auto canvas grows the same way, which leaves +// this rule reporting authored viewBoxes and the origin side. +// +// Showcase only: a standard document authored before the rule exists may +// overhang by a few pixels, and failing it there would break compatibility +// instead of repairing a diagram. +export function collectLabelCanvasOverflow({ labels, viewBox, tolerance = 0.5 }) { + // Renderers author origin-zero canvases (meta.viewBox is [width, height] by + // schema), but `check` re-measures foreign artifacts whose SVG viewBox may + // carry a legal non-zero min-x/min-y; those pass all four numbers. + const box = asArray(viewBox); + const [originX, originY, canvasWidth, canvasHeight] = box.length === 4 ? box : [0, 0, box[0], box[1]]; + if (!isFinitePoint(originX, originY, canvasWidth, canvasHeight)) return []; + const hits = []; + for (const [fallbackIndex, label] of asArray(labels).entries()) { + const rect = label?.rect || label; + if (!rect || !isFinitePoint(rect.x, rect.y, rect.width, rect.height)) continue; + // `check` parses foreign markup, so a malformed negative-size rect is + // skipped like the other label collectors do — its flipped interval would + // otherwise read as contained. + if (rect.width < 0 || rect.height < 0) continue; + const horizontal = axisCanvasOverflow(rect.x, rect.width, canvasWidth, originX); + const vertical = axisCanvasOverflow(rect.y, rect.height, canvasHeight, originY); + const overflow = { + left: horizontal.start, + right: horizontal.end, + top: vertical.start, + bottom: vertical.end, + }; + const sides = Object.keys(overflow).filter((side) => overflow[side] > tolerance); + if (!sides.length) continue; + hits.push({ + label, + relation: label?.relation, + relationIndex: Number.isInteger(label?.relationIndex) ? label.relationIndex : fallbackIndex, + rect, + viewBox: [canvasWidth, canvasHeight], + viewBoxOrigin: [originX, originY], + sides, + overflowPx: Object.fromEntries(sides.map((side) => [side, Math.round(overflow[side] * 10) / 10])), + tolerance, + }); + } + return hits; +} + +export function describeLabelCanvasOverflow(hit) { + return hit.sides.map((side) => `${side} edge by ${hit.overflowPx[side]}px`).join(' and '); +} + +export function cleanLabelCanvasContainmentProblems({ + labels, + viewBox, + diagramType, + relationCollection, + profile, + profileIsAuthoritative = false, + routeHint = 'adjust labelAt, labelDx, labelDy, or labelSegment; otherwise enlarge meta.viewBox', + // Label widths are estimated from the text, not measured, so sub-pixel + // overhang is rounding noise rather than a visible truncation. + tolerance = 0.5, +}) { + if (qualityProfileForGate(profile, profileIsAuthoritative) !== 'showcase') return []; + return collectLabelCanvasOverflow({ labels, viewBox, tolerance }).map((hit) => { + const relation = hit.relation; + const relationId = relation?.id ? ` id "${relation.id}"` : ''; + const labelText = hit.label?.label || relation?.label || ''; + const message = `[composition/label-canvas-containment] showcase ${diagramType} label "${labelText}" on ${relationCollection}[${hit.relationIndex}]${relationId} "${relation?.from}" -> "${relation?.to}" extends past the ${describeLabelCanvasOverflow(hit)} (label rect ${formatRect(hit.rect)}; viewBox ${hit.viewBox[0]}x${hit.viewBox[1]}) — ${routeHint}.`; + recordDiagnostic({ + code: 'composition/label-canvas-containment', + severity: 'error', + message, + subject: relationshipSubject(diagramType, relationCollection, hit.relationIndex, relation), + evidence: { + label: labelText, + labelRect: { x: hit.rect.x, y: hit.rect.y, width: hit.rect.width, height: hit.rect.height }, + viewBox: hit.viewBox, + overflowPx: hit.overflowPx, + tolerancePx: hit.tolerance, + }, + supportedFixes: [routeHint], + }); + return message; + }); +} + function qualityProfileForGate(profile, profileIsAuthoritative) { return profileIsAuthoritative ? profile @@ -1300,6 +1489,16 @@ export function routePointsValue(points) { .join(';'); } +// Only a direct, explicitly authored diagonal needs an artifact-check exception. +// Nonempty via takes precedence; empty via adds no intermediate geometry. +export function authoredStraightRouteAttrs(relation, points) { + if (relation.route !== 'straight' || relation.via?.length || points.length !== 2) return ''; + const [start, end] = points; + return Math.abs(start[0] - end[0]) > 0.01 && Math.abs(start[1] - end[1]) > 0.01 + ? ' data-composition-route="straight"' + : ''; +} + export function roundedPath(points, radius) { if (points.length < 3 || radius <= 0) { return polylinePath(points); @@ -1370,38 +1569,138 @@ export const arrowClassMap = { dashed: ['a-dashed', 'arrowhead-dashed'] }; -// Label accent per edge variant. Workflow colors dashed (async trace) labels -// like the trace store it points at; the other renderers use the bus color. -export function variantAccent(variant, { dashed = 't-messagebus' } = {}) { +// Structural phase/group accents retain their existing semantic colors. +export function variantAccent(variant) { + return variant === 'security' ? 't-security' + : variant === 'emphasis' ? 't-backend' + : variant === 'dashed' ? 't-messagebus' : 't-muted'; +} + +// Relationship labels use the same theme token as their path. Keep this map +// edge-specific: node-kind text colors only coincide with some path colors in +// the classic preset and must not define the relationship's visual meaning. +export function edgeLabelAccent(variant) { return variant === 'security' - ? 't-security' + ? 't-edge-security' : variant === 'emphasis' - ? 't-backend' + ? 't-edge-emphasis' : variant === 'dashed' - ? dashed - : 't-muted'; + ? 't-edge-dashed' + : 't-edge-default'; } export function formatRect(r) { return `[${Math.round(r.x)}, ${Math.round(r.y)}, ${Math.round(r.width)}, ${Math.round(r.height)}]`; } -function formatDelta(n) { - const v = Math.round(n); - return v >= 0 ? `+${v}` : String(v); +function formatValue(n) { + return String(Math.round(n)); } -/** Actionable hint when an edge label rect hits a node/component box (#7). */ -export function suggestLabelObstacleFix(labelRect, lx, ly, obstacle, obstacleKind = 'component') { - const lxR = Math.round(lx); - const lyR = Math.round(ly); - const belowY = Math.round(obstacle.y + obstacle.height + 14); - const aboveY = Math.round(obstacle.y - 4); - return [ +// A label anchor is the text origin, so keeping a suggestion on the canvas +// means moving the whole rect: offset is `rect - anchor` on that axis. +function anchorFitsCanvas(anchorValue, offset, size, extent) { + const overflow = axisCanvasOverflow(anchorValue + offset, size, extent); + return overflow.start <= 0 && overflow.end <= 0; +} + +// Suggestions are emitted as integers, so the real-valued bounds are tightened +// inward with ceil/floor first: every integer between them keeps the whole rect +// on the canvas, which a bound like 616.7 would not. Returns null when the rect +// is wider than the canvas and no anchor can contain it. +function clampAnchorToCanvas(anchorValue, offset, size, extent) { + const rounded = Math.round(anchorValue); + if (!Number.isFinite(extent)) return rounded; + const min = Math.ceil(-offset); + const max = Math.floor(extent - size - offset); + return max < min ? null : Math.min(Math.max(rounded, min), max); +} + +/** + * Actionable hint when an edge label rect hits a node/component box (#7). + * Every suggested value is a replacement for the authored field, not an + * increment, and has to survive being applied to the document: + * - the absolute form is nudged along x so the rect stays on the canvas, and a + * vertical placement that cannot fit is dropped rather than clamped, since + * clamping it back would push the label onto the obstacle it must clear; + * - the relative form is offered only when labelDx/labelDy actually move this + * label (labelPoint returns an authored labelAt as-is) and only when the + * replacements, computed from the document's own labelDx/labelDy, land the + * rect inside the canvas. + * The authored values therefore come from `labelRect.relation`, the authored + * relationship the renderer already attaches to every label record. + * `obstacles` is every box the callers' own overlap loop tests (defaults to + * the named obstacle alone): a placement that merely traded the named obstacle + * for its neighbor would fail the same rule again when applied. + */ +export function suggestLabelObstacleFix(labelRect, lx, ly, obstacle, obstacleKind = 'component', viewBox, obstacles) { + // Renderer canvases are origin-zero [width, height] by schema, but read a + // 4-number [min-x, min-y, width, height] the same way the containment + // collector does — degraded no-ajv runs must not clamp against the origin + // pair as if it were the canvas size. + const box = asArray(viewBox); + const [canvasWidth, canvasHeight] = box.length === 4 ? box.slice(2) : box; + const xOffset = labelRect.x - lx; + const yOffset = labelRect.y - ly; + const anchorX = clampAnchorToCanvas(lx, xOffset, labelRect.width, canvasWidth); + // The placement anchors are derived from the label's own rect, not from the + // obstacle alone: an obstacle-only "above" anchor assumes the 14px + // single-line rect and lands a 27px two-line rect (dataflow classification, + // lifecycle note) back on the obstacle it must clear. The overlap filter + // uses the callers' own detection call, so a surviving hint cannot re-raise + // the problem it repairs. + const blockers = obstacles ? [...obstacles] : [obstacle]; + const placements = [ + { name: 'below', y: Math.round(obstacle.y + obstacle.height + 4 - yOffset) }, + { name: 'above', y: Math.round(obstacle.y - 4 - labelRect.height - yOffset) }, + ].filter(({ y }) => anchorX !== null + && anchorFitsCanvas(y, yOffset, labelRect.height, canvasHeight) + && blockers.every((blocker) => !rectsOverlap( + { x: anchorX + xOffset, y: y + yOffset, width: labelRect.width, height: labelRect.height }, + blocker, + -2, + ))); + const lines = [ ` label rect: ${formatRect(labelRect)}`, ` ${obstacleKind} "${obstacle.id}" rect: ${formatRect(obstacle)}`, - ` Suggested fix: labelAt [${lxR}, ${belowY}] or labelDy ${formatDelta(belowY - lyR)} (below); or labelAt [${lxR}, ${aboveY}] or labelDy ${formatDelta(aboveY - lyR)} (above)`, - ].join('\n'); + ]; + if (!placements.length) { + // Only the vertical slots beside the named obstacle were tried, so an + // unclaimed spot elsewhere may still exist — never assert that none does. + lines.push(anchorX === null + ? ` Suggested fix: the ${Math.round(labelRect.width)}px label rect cannot fit the ${canvasWidth}x${canvasHeight} viewBox at any anchor — shorten the label or enlarge meta.viewBox` + : ` Suggested fix: no placement directly above or below "${obstacle.id}" stays clear inside the ${canvasWidth}x${canvasHeight} viewBox — move the label to an open area with labelAt, shorten the label, move the ${obstacleKind}, or enlarge meta.viewBox`); + return lines.join('\n'); + } + const authored = labelRect.relation || {}; + const authoredDx = Number.isFinite(authored.labelDx) ? authored.labelDx : 0; + const authoredDy = Number.isFinite(authored.labelDy) ? authored.labelDy : 0; + const relativeHint = (y) => { + if (Array.isArray(authored.labelAt)) return null; + // Both replacements are measured from the automatic label point that the + // authored offsets are applied to, and against the unrounded anchor: a + // rounded base would shift the applied label by up to a pixel. + const dx = Math.round(authoredDx + anchorX - lx); + const dy = Math.round(authoredDy + y - ly); + // labelDx is authored as a number, so an authored fraction must not read as + // a needed nudge once the replacement is rounded to an integer. + const movesX = Math.abs(dx - authoredDx) >= 0.5; + const landsAtX = lx - authoredDx + (movesX ? dx : authoredDx); + const landsAtY = ly - authoredDy + dy; + if (!anchorFitsCanvas(landsAtX, xOffset, labelRect.width, canvasWidth)) return null; + if (!anchorFitsCanvas(landsAtY, yOffset, labelRect.height, canvasHeight)) return null; + return movesX + ? `set labelDx ${formatValue(dx)} with labelDy ${formatValue(dy)}` + : `set labelDy ${formatValue(dy)}`; + }; + const hint = placements + .map(({ name, y }) => { + const relative = relativeHint(y); + return `set labelAt [${anchorX}, ${y}]${relative ? ` or ${relative}` : ''} (${name})`; + }) + .join('; or '); + lines.push(` Suggested fix: ${hint}`); + return lines.join('\n'); } /** Hint when two edge labels collide. */ diff --git a/archify/renderers/shared/utils.mjs b/archify/renderers/shared/utils.mjs index 830751835..5226844a5 100644 --- a/archify/renderers/shared/utils.mjs +++ b/archify/renderers/shared/utils.mjs @@ -200,21 +200,16 @@ export function applyTemplate(template, { // ranges because V8 has no \p{East_Asian_Width=W} property escape. const FULLWIDTH_RE = /[\u1100-\u115F\u231A-\u231B\u2329-\u232A\u23E9-\u23EC\u23F0\u23F3\u25FD-\u25FE\u2614-\u2615\u2630-\u2637\u2648-\u2653\u267F\u268A-\u268F\u2693\u26A1\u26AA-\u26AB\u26BD-\u26BE\u26C4-\u26C5\u26CE\u26D4\u26EA\u26F2-\u26F3\u26F5\u26FA\u26FD\u2705\u270A-\u270B\u2728\u274C\u274E\u2753-\u2755\u2757\u2795-\u2797\u27B0\u27BF\u2B1B-\u2B1C\u2B50\u2B55\u2E80-\uA4CF\uA960-\uA97C\uAC00-\uD7A3\uF900-\uFAFF\uFE10-\uFE19\uFE30-\uFE6F\uFF01-\uFF60\uFFE0-\uFFE6\u{16FE0}-\u{18DFF}\u{1AFF0}-\u{1AFFF}\u{1B000}-\u{1B2FF}\u{1F000}-\u{1FAFF}\u{20000}-\u{3FFFD}]/u; -// A variation selector (U+FE00-U+FE0F) carries no advance of its own: it -// re-presents the character before it. VS15 (U+FE0E) asks for text -// presentation, which renders narrow; VS16 (U+FE0F) asks for emoji -// presentation, which renders at the square emoji advance. So a base plus a -// selector is measured from the selector, not from the base -- otherwise -// widening the emoji-presentation bases above turns U+2B50 U+FE0F from two -// units into three while the glyph on screen stays one square, and leaves -// U+2708 U+FE0F at two only because its base happens to be narrow. -// -// A selector following a base that cannot take emoji presentation is -// malformed input; measuring it wide is the safe direction here, since -// over-measuring pads a box while under-measuring spills the label out of it. +// Variation selectors contribute no separate unit. This is a conservative +// width estimate, not a measurement of the selected glyph: its actual advance +// depends on the font and presentation (Unicode UAX #11). +// VS16 requests emoji presentation, so reserve two units for the sequence. +// VS15 retains the base's width estimate; forcing every text-presentation +// sequence to one unit undercounts wide bases, including CJK characters whose +// font ignores that selector. Neutral bases remain one unit. Some selected +// text glyphs can be narrower than this estimate; prefer extra space to overflow. const VARIATION_SELECTOR_FIRST = 0xfe00; const VARIATION_SELECTOR_LAST = 0xfe0f; -const VARIATION_SELECTOR_TEXT = 0xfe0e; const VARIATION_SELECTOR_EMOJI = 0xfe0f; export function textUnits(text) { @@ -225,7 +220,6 @@ export function textUnits(text) { if (codePoint >= VARIATION_SELECTOR_FIRST && codePoint <= VARIATION_SELECTOR_LAST) continue; const next = i + 1 < chars.length ? chars[i + 1].codePointAt(0) : -1; if (next === VARIATION_SELECTOR_EMOJI) units += 2; - else if (next === VARIATION_SELECTOR_TEXT) units += 1; else units += FULLWIDTH_RE.test(chars[i]) ? 2 : 1; } return units; diff --git a/archify/renderers/workflow/workflow-compiler.mjs b/archify/renderers/workflow/workflow-compiler.mjs index a12348f9c..970ab8200 100644 --- a/archify/renderers/workflow/workflow-compiler.mjs +++ b/archify/renderers/workflow/workflow-compiler.mjs @@ -34,6 +34,7 @@ import { cleanBorderRunProblems, cleanRouteRhythmProblems, cleanLabelRouteClearanceProblems, + cleanLabelCanvasContainmentProblems, collectAmbiguousCorridors, collectLabelRouteClearance, collectBorderRuns, @@ -54,7 +55,8 @@ import { componentFill, componentText, arrowClassMap, - variantAccent + variantAccent, + edgeLabelAccent } from '../shared/geometry.mjs'; const LEGACY_COLUMN_CENTERS = Object.freeze([88, 220, 300, 430, 500, 625]); @@ -2451,7 +2453,7 @@ function validateWorkflow() { for (const rect of labelRects) { for (const node of nodes.values()) { if (rectsOverlap(rect, node, -2)) { - problems.push(`Label "${rect.label}" overlaps node "${node.id}" — adjust labelDx/labelDy/labelSegment or set labelAt.\n${suggestLabelObstacleFix(rect, rect.lx, rect.ly, node, 'node')}`); + problems.push(`Label "${rect.label}" overlaps node "${node.id}" — adjust labelDx/labelDy/labelSegment or set labelAt.\n${suggestLabelObstacleFix(rect, rect.lx, rect.ly, node, 'node', viewBox, nodes.values())}`); } } } @@ -2480,6 +2482,15 @@ function validateWorkflow() { if (legendY() + 18 > viewBox[1]) { problems.push(`Legend exceeds viewBox height ${viewBox[1]} — set meta.viewBox[1] to at least ${legendY() + 18}.`); } + // v1 only; see collectLabelCanvasOverflow in shared/geometry.mjs. + problems.push(...cleanLabelCanvasContainmentProblems({ + labels: labelRects, + viewBox, + diagramType: 'workflow', + relationCollection: 'edges', + profile: workflow.meta?.quality_profile, + profileIsAuthoritative: true, + })); } if (problems.length) { @@ -4226,7 +4237,7 @@ function renderEdgeLabel(edge, index) { const labelW = workflowLabelWidth(edge.label); return ` - ${esc(edge.label)} + ${esc(edge.label)} `; } diff --git a/archify/scripts/check-render-output.mjs b/archify/scripts/check-render-output.mjs index f71496879..9d10fdb62 100644 --- a/archify/scripts/check-render-output.mjs +++ b/archify/scripts/check-render-output.mjs @@ -2,7 +2,7 @@ import fs from 'node:fs'; import path from 'node:path'; -import { collectAmbiguousCorridors, collectBorderRuns, collectLabelRouteClearance, collectRouteRhythmIssues, routeBudgetMetrics } from '../renderers/shared/geometry.mjs'; +import { collectAmbiguousCorridors, collectBorderRuns, collectLabelCanvasOverflow, collectLabelRouteClearance, collectRouteRhythmIssues, describeLabelCanvasOverflow, formatRect, minimumLabelRouteClearance, routeBudgetMetrics } from '../renderers/shared/geometry.mjs'; import { DESKTOP_READABILITY_VIEWPORT, DESKTOP_READER_DIAGRAM_WIDTH, @@ -42,6 +42,7 @@ let composition = { containerBorderRuns: 0, labelRouteClearanceIssues: 0, minLabelRouteClearance: null, + labelCanvasOverflowIssues: 0, maxBends: 0, routesOverSuggestedBends: 0, maxStretch: null, @@ -108,7 +109,7 @@ if (svgMatches.length === 1) { addCheck( 'orthogonal_arrows', diagonal.length === 0, - diagonal.map(({ arrow, segmentIndex }) => `${arrow.kind} ${arrow.index} segment ${segmentIndex + 1}: ${arrow.raw}`), + diagonal.map(({ arrow, segmentIndex }) => `${arrow.kind} ${arrow.index} segment ${segmentIndex + 1}: expected an orthogonal segment or an explicitly authored direct straight route; ${arrow.raw}`), ); const relationshipCrossings = collectRelationshipCrossings(arrows); const compositionFrames = collectCompositionFrames(beforeLegend); @@ -140,21 +141,31 @@ if (svgMatches.length === 1) { routedRelations: arrows.map((arrow) => ({ relation: arrow, relationIndex: arrow.index, points: arrow.routePoints })), threshold: labelClearanceThreshold, }); + // The renderers bound their own label rects, but `check` also re-measures an + // artifact it did not produce; see collectLabelCanvasOverflow in + // shared/geometry.mjs. + const labelCanvasOverflow = collectLabelCanvasOverflow({ + labels: relationshipLabels, + viewBox: viewBoxRect(svgAttrs), + }); const crossingIsError = qualityProfile === 'showcase'; const corridorIsError = qualityProfile === 'showcase'; const rhythmIsError = qualityProfile === 'showcase'; const labelClearanceIsError = qualityProfile === 'showcase'; + const labelContainmentIsError = qualityProfile === 'showcase'; const desktopReadabilityIsError = qualityProfile === 'showcase'; const compositionErrors = (qualityGatesEnforced ? containerBorderRuns.length : 0) + (crossingIsError ? relationshipCrossings.length : 0) + (corridorIsError ? ambiguousCorridors.length : 0) + (labelClearanceIsError ? labelRouteClearance.length : 0) + + (labelContainmentIsError ? labelCanvasOverflow.length : 0) + (rhythmIsError ? routeRhythmIssues.length : 0) + (desktopReadabilityIsError && desktopReadabilityIssue ? 1 : 0); const compositionWarnings = (qualityGatesEnforced ? 0 : containerBorderRuns.length) + (crossingIsError ? 0 : relationshipCrossings.length) + (corridorIsError ? 0 : ambiguousCorridors.length) + (labelClearanceIsError ? 0 : labelRouteClearance.length) + + (labelContainmentIsError ? 0 : labelCanvasOverflow.length) + (rhythmIsError ? 0 : routeRhythmIssues.length) + (desktopReadabilityIsError || !desktopReadabilityIssue ? 0 : 1); composition = { @@ -170,9 +181,8 @@ if (svgMatches.length === 1) { ambiguousCorridors: ambiguousCorridors.length, containerBorderRuns: containerBorderRuns.length, labelRouteClearanceIssues: labelRouteClearance.length, - minLabelRouteClearance: labelRouteMeasurements.length - ? Math.round(Math.min(...labelRouteMeasurements.map((hit) => hit.clearance)) * 10) / 10 - : null, + labelCanvasOverflowIssues: labelCanvasOverflow.length, + minLabelRouteClearance: minimumLabelRouteClearance(labelRouteMeasurements), desktopReadabilityIssues: desktopReadabilityIssue ? 1 : 0, minProjectedNodeTextPx: desktopReadabilityIssue?.projectedFontPx ?? null, ...roundedRouteMetrics(routeMetrics), @@ -204,6 +214,20 @@ if (svgMatches.length === 1) { from: hit.start.map((value) => Math.round(value * 10) / 10), to: hit.end.map((value) => Math.round(value * 10) / 10), })), + ...labelCanvasOverflow.map((hit) => { + const label = hit.label?.label || hit.relation?.label || ''; + return { + severity: labelContainmentIsError ? 'error' : 'warning', + code: 'composition/label-canvas-containment', + label, + relationship: relationshipRecord(hit.relation), + labelRect: roundedRect(hit.rect), + viewBox: hit.viewBox, + viewBoxOrigin: hit.viewBoxOrigin, + overflowPx: hit.overflowPx, + detail: `[composition/label-canvas-containment] ${qualityProfile} label "${label}" on ${relationshipName(hit.relation)} extends past the ${describeLabelCanvasOverflow(hit)} (label rect ${formatRect(hit.rect)}; viewBox ${hit.viewBox[0]}x${hit.viewBox[1]}${hit.viewBoxOrigin.some(Boolean) ? ` at ${hit.viewBoxOrigin[0]},${hit.viewBoxOrigin[1]}` : ''}) — use renderer-supported label controls (shorten the label or reorder participants for sequence; otherwise labelAt, labelDx, labelDy, or labelSegment), or enlarge meta.viewBox.`, + }; + }), ...relationshipCrossings.map((hit) => ({ severity: crossingIsError ? 'error' : 'warning', code: 'composition/proper-crossing', @@ -235,6 +259,7 @@ if (svgMatches.length === 1) { ...(desktopReadabilityIssue ? [{ severity: desktopReadabilityIsError ? 'error' : 'warning', code: 'composition/desktop-readability', + ...(desktopReadabilityIssue.nodeId ? { nodeId: desktopReadabilityIssue.nodeId } : {}), viewportWidth: DESKTOP_READABILITY_VIEWPORT.width, viewportHeight: DESKTOP_READABILITY_VIEWPORT.height, availableDiagramWidth: DESKTOP_READER_DIAGRAM_WIDTH, @@ -322,6 +347,12 @@ function collectArrows(fragment) { kind: tag[1].toLowerCase(), index: index += 1, raw, + // Trust route intent only for a semantic edge with one visible direct + // segment. A stale marker on bent/curved geometry cannot waive the gate. + authoredStraight: attrs['data-composition-route'] === 'straight' + && Boolean(attrs['data-edge-from'] && attrs['data-edge-to']) + && segments.length === 1 && borderSegments.length === 1 + && (tag[1].toLowerCase() === 'line' || /^\s*M\s+[-+\d.eE]+\s+[-+\d.eE]+\s+L\s+[-+\d.eE]+\s+[-+\d.eE]+\s*$/.test(attrs.d || '')), segments, borderSegments, routePoints: parseRoutePoints(attrs['data-composition-points']) || ( @@ -583,6 +614,7 @@ function straightPathSegments(d) { } function diagonalStraightSegments(arrow) { + if (arrow.authoredStraight) return []; return arrow.borderSegments.flatMap(({ start, end }, segmentIndex) => ( Math.abs(start[0] - end[0]) > 0.01 && Math.abs(start[1] - end[1]) > 0.01 ? [{ segmentIndex, start, end }] @@ -646,13 +678,36 @@ function textBox(attrs, text) { }; } -function collectDesktopReadability(svgAttrs, fragment) { +// A foreign artifact may author a legal non-zero viewBox origin, so containment +// needs all four numbers; sizing checks read the trailing pair. +function viewBoxRect(svgAttrs) { const viewBox = String(svgAttrs.viewBox || '').trim().split(/[\s,]+/).map(Number); - const viewBoxWidth = viewBox.length === 4 ? viewBox[2] : Number.NaN; + return viewBox.length === 4 ? viewBox : [Number.NaN, Number.NaN, Number.NaN, Number.NaN]; +} + +function viewBoxSize(svgAttrs) { + return viewBoxRect(svgAttrs).slice(2); +} + +function collectDesktopReadability(svgAttrs, fragment) { + const [viewBoxWidth] = viewBoxSize(svgAttrs); if (!Number.isFinite(viewBoxWidth) || viewBoxWidth <= 0) return null; const scale = Math.min(1, DESKTOP_READER_DIAGRAM_WIDTH / viewBoxWidth); let worst = null; - for (const match of fragment.matchAll(/]*)>([\s\S]*?)<\/text>/gi)) { + const nodeOwners = []; + // Walk groups alongside text so nested decoration retains the owning node, + // without leaking that identity into a following boundary or loose label. + for (const match of fragment.matchAll(/|$)||$)|]*)>([\s\S]*?)<\/text>|]*>|<\/g\s*>/gi)) { + // Comment and CDATA contents cannot open or close a real SVG group. + if (match[0].startsWith('$/.test(match[0])) { + const attrs = parseAttrs(match[0]); + nodeOwners.push(attrs['data-node-id'] || nodeOwners.at(-1)); + } + continue; + } const primary = /\bdata-node-label(?:\s*=|\s|$)/i.test(match[1]); const boundary = /\bdata-boundary-label(?:\s*=|\s|$)/i.test(match[1]); const context = /\bdata-detail\s*=\s*"context"/i.test(match[1]); @@ -663,6 +718,7 @@ function collectDesktopReadability(svgAttrs, fragment) { const projected = projectedNodeTextPx(fontSize, viewBoxWidth); if (projected >= MIN_PROJECTED_NODE_TEXT_PX) continue; const candidate = { + ...(nodeOwners.at(-1) ? { nodeId: nodeOwners.at(-1) } : {}), viewBoxWidth, scale, text: stripTags(match[2]).trim(), diff --git a/archify/test/architecture-delta.test.mjs b/archify/test/architecture-delta.test.mjs index 667092172..ed43dbbb4 100644 --- a/archify/test/architecture-delta.test.mjs +++ b/archify/test/architecture-delta.test.mjs @@ -1,6 +1,6 @@ import { test } from 'node:test'; import assert from 'node:assert/strict'; -import { spawnSync } from 'node:child_process'; +import { execFileSync, spawnSync } from 'node:child_process'; import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; @@ -11,6 +11,7 @@ import { architectureDeltaChangeRows, canonicalArchitectureJson, compareArchitecture, + renderArchitectureDeltaHtml, validateArchitectureDeltaHtml, } from '../delta/architecture-delta.mjs'; @@ -26,6 +27,70 @@ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'archify-delta-')); const read = (file) => JSON.parse(fs.readFileSync(file, 'utf8')); const run = (args) => spawnSync(process.execPath, [cli, ...args], { cwd: skillRoot, encoding: 'utf8' }); +function git(repo, ...args) { + return execFileSync('git', ['-C', repo, ...args], { + encoding: 'utf8', + env: { + ...process.env, + GIT_AUTHOR_DATE: '2026-01-01T00:00:00Z', + GIT_COMMITTER_DATE: '2026-01-01T00:00:00Z', + }, + }).trim(); +} + +function provenanceFixture() { + const root = fs.mkdtempSync(path.join(tmp, 'provenance-repo-')); + const sourceDirectory = path.join(root, 'src'); + fs.mkdirSync(sourceDirectory); + git(root, 'init'); + git(root, 'config', 'user.name', 'Archify Tests'); + git(root, 'config', 'user.email', 'archify@example.test'); + git(root, 'remote', 'add', 'origin', 'git@github.com:example/provenance-repo.git'); + fs.writeFileSync(path.join(sourceDirectory, 'service.js'), 'export const version = 1;\n'); + git(root, 'add', 'src/service.js'); + git(root, 'commit', '-m', 'base'); + const baseRevision = git(root, 'rev-parse', 'HEAD'); + fs.writeFileSync(path.join(sourceDirectory, 'service.js'), 'export const version = 2;\n'); + git(root, 'add', 'src/service.js'); + git(root, 'commit', '-m', 'head'); + const headRevision = git(root, 'rev-parse', 'HEAD'); + const diagram = { + schema_version: 1, + diagram_type: 'architecture', + meta: { title: 'Provenance-only delta' }, + components: [{ + id: 'service', + type: 'backend', + label: 'Service', + pos: [100, 100], + size: [160, 80], + sources: [{ path: 'src/service.js', line: 1 }], + }], + boundaries: [], + connections: [], + cards: [], + }; + const base = structuredClone(diagram); + base.meta.repository = { url: 'https://github.com/example/provenance-repo', revision: baseRevision }; + const head = structuredClone(diagram); + head.meta.repository = { url: 'https://github.com/example/provenance-repo', revision: headRevision }; + const basePath = path.join(root, 'base.architecture.json'); + const headPath = path.join(root, 'head.architecture.json'); + fs.writeFileSync(basePath, JSON.stringify(base)); + fs.writeFileSync(headPath, JSON.stringify(head)); + return { root, basePath, headPath, baseRevision, headRevision }; +} + +const renderDelta = (receipt) => renderArchitectureDeltaHtml({ + receipt, + baseSvg: '', + deltaSvg: '', + headSvg: '', + baseHtml: 'Before', + headHtml: 'After', + artifactCss: '', +}); + test('architecture compare classifies authored facts separately from geometry and presentation', () => { const receipt = compareArchitecture(read(baseFixture), read(headFixture)); assert.equal(receipt.command, 'compare'); @@ -311,6 +376,140 @@ test('repository mismatch fails and verified matching revisions remain evidence- assert.equal(receipt.summary.provenanceChanged, true); }); +test('provenance-only changes stay separate from graph changes and remain visible', () => { + const base = read(baseFixture); + const head = read(baseFixture); + base.meta.repository = { + url: 'https://github.com/example/one', + revision: 'a'.repeat(40), + link_mode: 'web', + }; + head.meta.repository = { + url: 'https://github.com/example/one', + revision: 'b'.repeat(40), + provider: 'github', + link_mode: 'local-only', + }; + + const receipt = compareArchitecture(base, head, { baseVerified: true, headVerified: true }); + assert.equal(receipt.summary.provenanceChanged, true); + assert.deepEqual(receipt.provenance, { + changedFields: ['/link_mode', '/provider', '/revision'], + base: { revision: 'a'.repeat(40), linkMode: 'web' }, + head: { revision: 'b'.repeat(40), provider: 'github', linkMode: 'local-only' }, + }); + assert.deepEqual(receipt.changes, { components: [], connections: [], boundaries: [] }); + assert.deepEqual(architectureDeltaChangeRows(receipt), []); + + const html = renderDelta(receipt); + assert.match(html, /data-provenance-changed="true"/); + assert.match(html, /Repository provenance changed/); + assert.match(html, /\/revision: aaaaaaaa → bbbbbbbb/); + assert.match(html, /\/provider: automatic → github/); + assert.match(html, /\/link_mode: web → local-only/); + assert.match(html, /\/link_mode, \/provider, \/revision/); + assert.match(html, /No component, relationship, or boundary changes; repository provenance changed\./); + assert.doesNotMatch(html, /No authored architecture changes\./); + assert.match(html, /Overview · 0 authored graph changes · provenance changed/); + assert.match(html, /No graph changes · ' \+ provenanceSummary/); + assert.match(html, /const proofWidth = ctx\.measureText\(proofLine\)\.width/); + assert.match(html, /fitCanvasLine\(secondary, secondaryWidth\)/); + assert.doesNotMatch(html, /data-change-key="provenance/); + assert.deepEqual(validateArchitectureDeltaHtml(html, receipt), { ok: true, checksPassed: 10, checkCount: 10 }); + + const missingNotice = html.replace(/