From 8b416a5cb446bd2476cdaafbfd953f8bbca3fa9d Mon Sep 17 00:00:00 2001
From: Wes McKinney
Date: Fri, 4 Sep 2026 08:17:58 -0500
Subject: [PATCH 1/3] docs: define Docbank's tiered public site
Docbank's incoming processing and retrieval stack needs a public identity
that explains source authority, governed derivatives, bounded agent access,
and recovery as one system.
Fix the audience, authority-lifecycle narrative, flat teal visual language,
human and machine route parity, screenshot boundary, and release-gated
deployment before implementation begins.
- docs: make the tiered site design executable
- test: prove documentation subpath rendering
- feat: separate complete documentation captures
- feat: pin reviewed documentation assets
- feat: assemble the public documentation tiers
- feat: establish Docbank's authority-led site
- feat: preview every documentation tier together
- fix: make documentation deployment narrow and verifiable
- test: verify the public documentation boundary
- ci: gate documentation promotion on released sources
- test: keep browser specs out of the unit runner
- fix: enforce documentation publication boundaries
- feat: simplify the landing hero and link community surfaces
- fix: redraw the interface map with orthogonal routing
- feat: tell the AI intelligence and git-contrast story
- fix: serve every published screenshot from the site itself
Generated with Codex
Generated with Claude Code (claude-fable-5-1)
Co-authored-by: Codex <198982749+openai-codex[bot]@users.noreply.github.com>
Co-authored-by: Claude Fable 5.1
---
.github/workflows/ci.yml | 60 ++
.github/workflows/deploy-docs.yml | 71 ++
.gitignore | 6 +
.vercelignore | 50 ++
AGENTS.md | 25 +-
LICENSES/Inter-OFL-1.1.txt | 92 +++
LICENSES/JetBrains-Mono-OFL-1.1.txt | 93 +++
Makefile | 42 +-
README.md | 2 +-
docs/README.md | 76 +-
docs/index.md | 2 +-
docs/llms.txt | 80 +-
docs/scripts/check_built_site.py | 26 +-
docs/scripts/check_built_site_test.py | 35 +
docs/scripts/check_markdown_sources.py | 10 +-
docs/scripts/check_zensical_subpath.py | 145 ++++
docs/stylesheets/extra.css | 322 ++------
...-08-30-tiered-documentation-site-design.md | 248 ++++++
docs/tour.md | 10 +-
docs/usage/audited-history.md | 2 +-
docs/usage/organizing.md | 2 +-
docs/usage/storage.md | 2 +-
docs/usage/tui.md | 2 +-
docs/usage/web.md | 4 +-
docs/vercel.json | 3 -
docs/zensical-docs.sh | 8 +-
docs/zensical.toml | 3 +-
frontend/docs-site/playwright.config.ts | 50 ++
frontend/docs-site/site.spec.ts | 176 ++++
frontend/docs-site/tsconfig.json | 15 +
frontend/package.json | 4 +-
frontend/screenshots/README.md | 21 +-
frontend/screenshots/publish.mjs | 99 +++
frontend/screenshots/publish.node-test.mjs | 102 +++
frontend/screenshots/run.mjs | 24 +-
frontend/screenshots/web-trash.screenshot.ts | 104 +--
frontend/vite.config.ts | 2 +-
prek.toml | 2 +-
scripts/deploy-docs.sh | 47 ++
scripts/docs-assets-sync.test.sh | 131 +++
scripts/docs-assets.ref | 1 +
scripts/docs-assets.txt | 11 +
scripts/docs/assert-vercel-dry-run.mjs | 141 ++++
scripts/docs/assert-vercel-dry-run.test.mjs | 97 +++
scripts/docs/build.mjs | 203 +++++
scripts/docs/build.test.mjs | 127 +++
scripts/docs/serve.mjs | 252 ++++++
scripts/docs/serve.test.mjs | 149 ++++
scripts/docs/verify-site.mjs | 278 +++++++
scripts/docs/verify-site.test.mjs | 209 +++++
scripts/sync-docs-assets.sh | 149 ++++
scripts/validate-docs-release.sh | 66 ++
scripts/validate-docs-release.test.sh | 91 +++
scripts/vercel-build-docs.sh | 15 +
scripts/vercel-install-docs.sh | 41 +
vercel.json | 21 +
website/assets/authority-ledger.svg | 25 +
website/assets/derivative-cycle.svg | 47 ++
website/assets/intelligence-pipeline.svg | 62 ++
website/assets/interface-map.svg | 61 ++
website/assets/recovery-flow.svg | 25 +
website/favicon.svg | 6 +
website/fonts/Inter-Medium.woff2 | Bin 0 -> 114348 bytes
website/fonts/Inter-Regular.woff2 | Bin 0 -> 111268 bytes
website/fonts/Inter-SemiBold.woff2 | Bin 0 -> 114812 bytes
website/fonts/JetBrainsMono-Bold.woff2 | Bin 0 -> 94588 bytes
website/fonts/JetBrainsMono-Regular.woff2 | Bin 0 -> 92164 bytes
website/fonts/JetBrainsMono-SemiBold.woff2 | Bin 0 -> 94472 bytes
website/guide.md | 123 +++
website/guide/index.html | 281 +++++++
website/index.html | 329 ++++++++
website/index.md | 145 ++++
website/scripts/site.js | 127 +++
website/styles/site.css | 771 ++++++++++++++++++
74 files changed, 5636 insertions(+), 415 deletions(-)
create mode 100644 .github/workflows/deploy-docs.yml
create mode 100644 .vercelignore
create mode 100644 LICENSES/Inter-OFL-1.1.txt
create mode 100644 LICENSES/JetBrains-Mono-OFL-1.1.txt
create mode 100644 docs/scripts/check_built_site_test.py
create mode 100644 docs/scripts/check_zensical_subpath.py
create mode 100644 docs/superpowers/specs/2026-08-30-tiered-documentation-site-design.md
delete mode 100644 docs/vercel.json
create mode 100644 frontend/docs-site/playwright.config.ts
create mode 100644 frontend/docs-site/site.spec.ts
create mode 100644 frontend/docs-site/tsconfig.json
create mode 100644 frontend/screenshots/publish.mjs
create mode 100644 frontend/screenshots/publish.node-test.mjs
create mode 100755 scripts/deploy-docs.sh
create mode 100755 scripts/docs-assets-sync.test.sh
create mode 100644 scripts/docs-assets.ref
create mode 100644 scripts/docs-assets.txt
create mode 100644 scripts/docs/assert-vercel-dry-run.mjs
create mode 100644 scripts/docs/assert-vercel-dry-run.test.mjs
create mode 100644 scripts/docs/build.mjs
create mode 100644 scripts/docs/build.test.mjs
create mode 100644 scripts/docs/serve.mjs
create mode 100644 scripts/docs/serve.test.mjs
create mode 100644 scripts/docs/verify-site.mjs
create mode 100644 scripts/docs/verify-site.test.mjs
create mode 100755 scripts/sync-docs-assets.sh
create mode 100755 scripts/validate-docs-release.sh
create mode 100755 scripts/validate-docs-release.test.sh
create mode 100755 scripts/vercel-build-docs.sh
create mode 100755 scripts/vercel-install-docs.sh
create mode 100644 vercel.json
create mode 100644 website/assets/authority-ledger.svg
create mode 100644 website/assets/derivative-cycle.svg
create mode 100644 website/assets/intelligence-pipeline.svg
create mode 100644 website/assets/interface-map.svg
create mode 100644 website/assets/recovery-flow.svg
create mode 100644 website/favicon.svg
create mode 100644 website/fonts/Inter-Medium.woff2
create mode 100644 website/fonts/Inter-Regular.woff2
create mode 100644 website/fonts/Inter-SemiBold.woff2
create mode 100644 website/fonts/JetBrainsMono-Bold.woff2
create mode 100644 website/fonts/JetBrainsMono-Regular.woff2
create mode 100644 website/fonts/JetBrainsMono-SemiBold.woff2
create mode 100644 website/guide.md
create mode 100644 website/guide/index.html
create mode 100644 website/index.html
create mode 100644 website/index.md
create mode 100644 website/scripts/site.js
create mode 100644 website/styles/site.css
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 6a027d36..f46bc000 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -12,6 +12,66 @@ concurrency:
cancel-in-progress: true
jobs:
+ docs:
+ name: Public documentation
+ runs-on: ${{ github.repository == 'kenn-io/docbank' && (github.event_name == 'push' && github.ref == 'refs/heads/main' || (github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository && github.event.pull_request.base.repo.full_name == github.repository)) && 'kenn-linux-x64-public' || 'ubuntu-latest' }}
+ steps:
+ - name: Check out full repository history
+ uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
+ with:
+ fetch-depth: 0
+ persist-credentials: false
+
+ - name: Set up Go
+ uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0
+ with:
+ go-version-file: go.mod
+ cache: ${{ github.repository == 'kenn-io/docbank' && (github.event_name == 'push' && github.ref == 'refs/heads/main' || (github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository && github.event.pull_request.base.repo.full_name == github.repository)) && 'false' || 'true' }}
+
+ - name: Set up Node.js
+ uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
+ with:
+ node-version: "24"
+ cache: npm
+ cache-dependency-path: frontend/package-lock.json
+
+ - name: Install pinned documentation tools
+ run: |
+ ./scripts/vercel-install-docs.sh
+ echo "$PWD/.vercel-tools/bin" >> "$GITHUB_PATH"
+
+ - name: Install browser test dependencies
+ run: |
+ npm ci --prefix frontend
+ node frontend/node_modules/@playwright/test/cli.js install --with-deps chromium webkit
+
+ - name: Verify documentation sources and assets
+ run: |
+ bash scripts/docs-assets-sync.test.sh
+ npm --prefix frontend run screenshots:check
+ make docs-subpath-test
+ node --test scripts/docs/*.test.mjs
+ make docs-build
+
+ - name: Verify Vercel upload boundary
+ if: github.event_name == 'push' && github.ref == 'refs/heads/main'
+ env:
+ VERCEL_TOKEN: ${{ secrets.VERCEL_TOKEN }}
+ run: |
+ if [ -z "$VERCEL_TOKEN" ]; then
+ echo "VERCEL_TOKEN must be configured for trusted main-branch documentation checks" >&2
+ exit 1
+ fi
+ npm install --global vercel@58.4.4
+ mkdir -p .superpowers
+ vercel deploy --dry --json --project docbank.ai --scope kenn-software --token "$VERCEL_TOKEN" > .superpowers/vercel-dry-run.json
+ node scripts/docs/assert-vercel-dry-run.mjs .superpowers/vercel-dry-run.json
+
+ - name: Verify documentation in Chromium and WebKit
+ run: |
+ npm --prefix frontend run docs-site:check
+ npm --prefix frontend run docs-site:test
+
frontend:
name: Web application
runs-on: ${{ github.repository == 'kenn-io/docbank' && (github.event_name == 'push' && github.ref == 'refs/heads/main' || (github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository && github.event.pull_request.base.repo.full_name == github.repository)) && 'kenn-linux-x64-public' || 'ubuntu-latest' }}
diff --git a/.github/workflows/deploy-docs.yml b/.github/workflows/deploy-docs.yml
new file mode 100644
index 00000000..4e4e4489
--- /dev/null
+++ b/.github/workflows/deploy-docs.yml
@@ -0,0 +1,71 @@
+name: Deploy documentation
+
+on:
+ workflow_dispatch:
+ inputs:
+ source_sha:
+ description: Full eligible documentation source commit SHA
+ required: true
+ type: string
+
+permissions:
+ contents: read
+
+concurrency:
+ group: docbank-documentation-production
+ cancel-in-progress: false
+
+jobs:
+ validate:
+ name: Validate documentation source
+ runs-on: ubuntu-latest
+ outputs:
+ release_tag: ${{ steps.release.outputs.release_tag }}
+ source_sha: ${{ steps.release.outputs.source_sha }}
+ steps:
+ - name: Check out trusted release policy
+ uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
+ with:
+ fetch-depth: 0
+ persist-credentials: false
+ ref: main
+
+ - name: Validate requested source against the release boundary
+ id: release
+ env:
+ SOURCE_SHA: ${{ inputs.source_sha }}
+ run: |
+ git fetch --quiet origin refs/heads/main:refs/remotes/origin/main --tags
+ release_tag=$(git describe --tags --abbrev=0 --match 'v[0-9]*.[0-9]*.[0-9]*' origin/main)
+ ./scripts/validate-docs-release.sh "$SOURCE_SHA" "$release_tag"
+ echo "source_sha=$SOURCE_SHA" >> "$GITHUB_OUTPUT"
+ echo "release_tag=$release_tag" >> "$GITHUB_OUTPUT"
+
+ deploy:
+ name: Build and promote documentation
+ needs: validate
+ runs-on: ubuntu-latest
+ environment: production
+ steps:
+ - name: Check out the validated source
+ uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
+ with:
+ fetch-depth: 0
+ persist-credentials: false
+ ref: ${{ needs.validate.outputs.source_sha }}
+
+ - name: Set up Node.js
+ uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
+ with:
+ node-version: "24"
+
+ - name: Install pinned Vercel CLI
+ run: npm install --global vercel@58.4.4
+
+ - name: Verify, build, inspect, and promote
+ env:
+ DOCS_SOURCE: ${{ needs.validate.outputs.source_sha }}
+ VERCEL_ORG_ID: ${{ secrets.VERCEL_ORG_ID }}
+ VERCEL_PROJECT_ID: ${{ secrets.VERCEL_PROJECT_ID }}
+ VERCEL_TOKEN: ${{ secrets.VERCEL_TOKEN }}
+ run: ./scripts/deploy-docs.sh
diff --git a/.gitignore b/.gitignore
index 21ba218d..972248f8 100644
--- a/.gitignore
+++ b/.gitignore
@@ -10,8 +10,14 @@ internal/web/dist/*
!internal/web/dist/.keep
# docs build
+**/__pycache__/
+/.cache/docs-assets/
+/site/
+/.vercel/
+/.vercel-tools/
docs/.venv/
docs/site/
docs/zensical-public-docs.*
docs/.zensical-build.*
.kata.local.toml
+.env*
diff --git a/.vercelignore b/.vercelignore
new file mode 100644
index 00000000..817eb397
--- /dev/null
+++ b/.vercelignore
@@ -0,0 +1,50 @@
+# Vercel receives only the static documentation build inputs. Product source,
+# screenshots, tests, internal plans, generated output, and local state remain
+# outside the upload boundary.
+/*
+
+!/vercel.json
+
+!/LICENSES
+/LICENSES/*
+!/LICENSES/Inter-OFL-1.1.txt
+!/LICENSES/JetBrains-Mono-OFL-1.1.txt
+
+!/website
+
+!/docs
+/docs/*
+!/docs/*.md
+/docs/README.md
+!/docs/agents
+!/docs/architecture
+!/docs/usage
+!/docs/overrides
+/docs/overrides/*
+!/docs/overrides/main.html
+!/docs/stylesheets
+/docs/stylesheets/*
+!/docs/stylesheets/extra.css
+!/docs/scripts
+/docs/scripts/*
+!/docs/scripts/check_built_site.py
+!/docs/scripts/check_markdown_sources.py
+!/docs/llms.txt
+!/docs/pyproject.toml
+!/docs/uv.lock
+!/docs/zensical-docs.sh
+!/docs/zensical.toml
+
+!/scripts
+/scripts/*
+!/scripts/docs
+/scripts/docs/*
+!/scripts/docs/build.mjs
+!/scripts/docs/verify-site.mjs
+!/scripts/docs-assets.ref
+!/scripts/docs-assets.txt
+!/scripts/install.ps1
+!/scripts/install.sh
+!/scripts/sync-docs-assets.sh
+!/scripts/vercel-build-docs.sh
+!/scripts/vercel-install-docs.sh
diff --git a/AGENTS.md b/AGENTS.md
index 4aaa4003..b19d7481 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -21,11 +21,30 @@ Instructions for autonomous coding agents working in this repository.
`frontend/package-lock.json`. Run `make frontend-test` for type, kit-ui,
unit, and production-build checks. Release CI builds the frontend once and
embeds those exact assets in all six platform archives.
-- Real web screenshots use the repository-owned Playwright harness in
- `frontend/screenshots/`. Run `make frontend-screenshots`; captures must use
+- Real documentation screenshots use the repository-owned Playwright harness in
+ `frontend/screenshots/`. Run `make docs-screenshots`; captures must use
its temporary synthetic vault and real daemon, never a developer vault or
mocked API data. Generated images stay under `.superpowers/screenshots/`
- until they are visually inspected and attached to the relevant pull request.
+ until they are visually inspected and published as a complete reviewed set.
+ Publish the complete set as one orphan `docs-assets` commit, then pin that
+ exact commit in `scripts/docs-assets.ref`. Documentation builds consume the
+ pin and never generate screenshots or follow a mutable branch head.
+- Run `make docs-serve` from the repository root to build, watch, and preview
+ the product page, authority guide, and operating documentation on one origin.
+ Run `make docs-build` after the final source or asset-pin edit. The strict
+ build stages only the explicit public allowlist into `site/`; internal plans,
+ scripts, and agent instructions must not enter the output.
+- A software release makes a documentation source eligible; it does not publish
+ that source. The selected source is normally the post-tag documentation-only
+ follow-up. Deployment still requires maintainer authorization. From the
+ repository root, `make docs-deploy DOCS_SOURCE=$(git rev-parse HEAD)` uploads
+ an unpromoted production build, verifies it and the release boundary, and
+ then promotes it. The deploy path does not generate screenshots, build the
+ product, run Docker, or install frontend dependencies. Pull-request jobs
+ never receive Vercel credentials; the authenticated upload dry run is a
+ trusted `main`-push check, and the manual production workflow validates its
+ requested source without credentials before entering the protected
+ environment.
## Private Data Boundary
diff --git a/LICENSES/Inter-OFL-1.1.txt b/LICENSES/Inter-OFL-1.1.txt
new file mode 100644
index 00000000..9b2ca37b
--- /dev/null
+++ b/LICENSES/Inter-OFL-1.1.txt
@@ -0,0 +1,92 @@
+Copyright (c) 2016 The Inter Project Authors (https://github.com/rsms/inter)
+
+This Font Software is licensed under the SIL Open Font License, Version 1.1.
+This license is copied below, and is also available with a FAQ at:
+http://scripts.sil.org/OFL
+
+-----------------------------------------------------------
+SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
+-----------------------------------------------------------
+
+PREAMBLE
+The goals of the Open Font License (OFL) are to stimulate worldwide
+development of collaborative font projects, to support the font creation
+efforts of academic and linguistic communities, and to provide a free and
+open framework in which fonts may be shared and improved in partnership
+with others.
+
+The OFL allows the licensed fonts to be used, studied, modified and
+redistributed freely as long as they are not sold by themselves. The
+fonts, including any derivative works, can be bundled, embedded,
+redistributed and/or sold with any software provided that any reserved
+names are not used by derivative works. The fonts and derivatives,
+however, cannot be released under any other type of license. The
+requirement for fonts to remain under this license does not apply
+to any document created using the fonts or their derivatives.
+
+DEFINITIONS
+"Font Software" refers to the set of files released by the Copyright
+Holder(s) under this license and clearly marked as such. This may
+include source files, build scripts and documentation.
+
+"Reserved Font Name" refers to any names specified as such after the
+copyright statement(s).
+
+"Original Version" refers to the collection of Font Software components as
+distributed by the Copyright Holder(s).
+
+"Modified Version" refers to any derivative made by adding to, deleting,
+or substituting -- in part or in whole -- any of the components of the
+Original Version, by changing formats or by porting the Font Software to a
+new environment.
+
+"Author" refers to any designer, engineer, programmer, technical
+writer or other person who contributed to the Font Software.
+
+PERMISSION AND CONDITIONS
+Permission is hereby granted, free of charge, to any person obtaining
+a copy of the Font Software, to use, study, copy, merge, embed, modify,
+redistribute, and sell modified and unmodified copies of the Font
+Software, subject to the following conditions:
+
+1) Neither the Font Software nor any of its individual components,
+in Original or Modified Versions, may be sold by itself.
+
+2) Original or Modified Versions of the Font Software may be bundled,
+redistributed and/or sold with any software, provided that each copy
+contains the above copyright notice and this license. These can be
+included either as stand-alone text files, human-readable headers or
+in the appropriate machine-readable metadata fields within text or
+binary files as long as those fields can be easily viewed by the user.
+
+3) No Modified Version of the Font Software may use the Reserved Font
+Name(s) unless explicit written permission is granted by the corresponding
+Copyright Holder. This restriction only applies to the primary font name as
+presented to the users.
+
+4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
+Software shall not be used to promote, endorse or advertise any
+Modified Version, except to acknowledge the contribution(s) of the
+Copyright Holder(s) and the Author(s) or with their explicit written
+permission.
+
+5) The Font Software, modified or unmodified, in part or in whole,
+must be distributed entirely under this license, and must not be
+distributed under any other license. The requirement for fonts to
+remain under this license does not apply to any document created
+using the Font Software.
+
+TERMINATION
+This license becomes null and void if any of the above conditions are
+not met.
+
+DISCLAIMER
+THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
+MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
+OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
+COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
+INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
+DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
+OTHER DEALINGS IN THE FONT SOFTWARE.
diff --git a/LICENSES/JetBrains-Mono-OFL-1.1.txt b/LICENSES/JetBrains-Mono-OFL-1.1.txt
new file mode 100644
index 00000000..23a3dca4
--- /dev/null
+++ b/LICENSES/JetBrains-Mono-OFL-1.1.txt
@@ -0,0 +1,93 @@
+Copyright 2020 The JetBrains Mono Project Authors (https://github.com/JetBrains/JetBrainsMono)
+
+This Font Software is licensed under the SIL Open Font License, Version 1.1.
+This license is copied below, and is also available with a FAQ at:
+https://scripts.sil.org/OFL
+
+
+-----------------------------------------------------------
+SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
+-----------------------------------------------------------
+
+PREAMBLE
+The goals of the Open Font License (OFL) are to stimulate worldwide
+development of collaborative font projects, to support the font creation
+efforts of academic and linguistic communities, and to provide a free and
+open framework in which fonts may be shared and improved in partnership
+with others.
+
+The OFL allows the licensed fonts to be used, studied, modified and
+redistributed freely as long as they are not sold by themselves. The
+fonts, including any derivative works, can be bundled, embedded,
+redistributed and/or sold with any software provided that any reserved
+names are not used by derivative works. The fonts and derivatives,
+however, cannot be released under any other type of license. The
+requirement for fonts to remain under this license does not apply
+to any document created using the fonts or their derivatives.
+
+DEFINITIONS
+"Font Software" refers to the set of files released by the Copyright
+Holder(s) under this license and clearly marked as such. This may
+include source files, build scripts and documentation.
+
+"Reserved Font Name" refers to any names specified as such after the
+copyright statement(s).
+
+"Original Version" refers to the collection of Font Software components as
+distributed by the Copyright Holder(s).
+
+"Modified Version" refers to any derivative made by adding to, deleting,
+or substituting -- in part or in whole -- any of the components of the
+Original Version, by changing formats or by porting the Font Software to a
+new environment.
+
+"Author" refers to any designer, engineer, programmer, technical
+writer or other person who contributed to the Font Software.
+
+PERMISSION & CONDITIONS
+Permission is hereby granted, free of charge, to any person obtaining
+a copy of the Font Software, to use, study, copy, merge, embed, modify,
+redistribute, and sell modified and unmodified copies of the Font
+Software, subject to the following conditions:
+
+1) Neither the Font Software nor any of its individual components,
+in Original or Modified Versions, may be sold by itself.
+
+2) Original or Modified Versions of the Font Software may be bundled,
+redistributed and/or sold with any software, provided that each copy
+contains the above copyright notice and this license. These can be
+included either as stand-alone text files, human-readable headers or
+in the appropriate machine-readable metadata fields within text or
+binary files as long as those fields can be easily viewed by the user.
+
+3) No Modified Version of the Font Software may use the Reserved Font
+Name(s) unless explicit written permission is granted by the corresponding
+Copyright Holder. This restriction only applies to the primary font name as
+presented to the users.
+
+4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
+Software shall not be used to promote, endorse or advertise any
+Modified Version, except to acknowledge the contribution(s) of the
+Copyright Holder(s) and the Author(s) or with their explicit written
+permission.
+
+5) The Font Software, modified or unmodified, in part or in whole,
+must be distributed entirely under this license, and must not be
+distributed under any other license. The requirement for fonts to
+remain under this license does not apply to any document created
+using the Font Software.
+
+TERMINATION
+This license becomes null and void if any of the above conditions are
+not met.
+
+DISCLAIMER
+THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
+MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
+OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
+COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
+INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
+DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
+OTHER DEALINGS IN THE FONT SOFTWARE.
diff --git a/Makefile b/Makefile
index 2ed16392..9dc3ec76 100644
--- a/Makefile
+++ b/Makefile
@@ -21,7 +21,7 @@ DEFAULT_GOLANGCI_LINT_CACHE := $(shell git rev-parse --path-format=absolute --gi
GOLANGCI_LINT_CACHE ?= $(DEFAULT_GOLANGCI_LINT_CACHE)
export GOLANGCI_LINT_CACHE
-.PHONY: build install clean test test-v release-scripts-test frontend frontend-test frontend-dev frontend-screenshots fmt lint lint-ci tidy install-hooks docs-install docs-build docs-serve docs-link docs-deploy help
+.PHONY: build install clean test test-v release-scripts-test frontend frontend-test frontend-dev docs-screenshots fmt lint lint-ci tidy install-hooks docs-install docs-subpath-test docs-assets-test docs-assets-sync docs-build docs-serve docs-link docs-deploy help
build: frontend
CGO_ENABLED=1 go build -tags "$(BUILD_TAGS)" -ldflags="$(LDFLAGS)" -o docbank ./cmd/docbank
@@ -60,7 +60,7 @@ frontend-test:
frontend-dev:
cd frontend && npm run dev
-frontend-screenshots:
+docs-screenshots:
cd frontend && npm run screenshots
fmt:
@@ -93,14 +93,23 @@ install-hooks:
docs-install:
cd docs && uv sync --frozen
+docs-subpath-test:
+ cd docs && uv run --project . --frozen --no-dev python scripts/check_zensical_subpath.py
+
bridge-contract:
go test -tags fts5 ./document/bridge -run '^TestBridgeContractNormativeDocuments'
-docs-build: bridge-contract
- cd docs && ./zensical-docs.sh build
+docs-assets-test:
+ bash scripts/docs-assets-sync.test.sh
+
+docs-assets-sync:
+ ./scripts/sync-docs-assets.sh
+
+docs-build: bridge-contract docs-subpath-test
+ node scripts/docs/build.mjs
docs-serve:
- cd docs && ./zensical-docs.sh serve
+ node scripts/docs/serve.mjs
# Deploys use the operator's installed Vercel CLI; install it with
# `npm install -g vercel` or from https://vercel.com/docs/cli.
@@ -109,20 +118,19 @@ docs-link:
echo "vercel CLI not found. Install: https://vercel.com/docs/cli" >&2; \
exit 1; \
fi
- cd docs && vercel link
-
-docs-deploy: docs-build
- @if ! command -v vercel >/dev/null 2>&1; then \
- echo "vercel CLI not found. Install: https://vercel.com/docs/cli" >&2; \
+ vercel link
+ @test -f .vercel/project.json || { \
+ echo "Vercel did not create .vercel/project.json at the repository root." >&2; \
exit 1; \
- fi
- @if [ ! -f docs/.vercel/project.json ]; then \
- echo "docs are not linked to a Vercel project yet." >&2; \
- echo "Run: vercel login && make docs-link" >&2; \
+ }
+
+docs-deploy:
+ @if [ -z "$(DOCS_SOURCE)" ]; then \
+ echo "DOCS_SOURCE is required and must be a full source commit SHA." >&2; \
+ echo "Run: make docs-deploy DOCS_SOURCE=$$(git rev-parse HEAD)" >&2; \
exit 1; \
fi
- cp -R docs/.vercel docs/site/.vercel
- vercel deploy docs/site --prod --yes
+ DOCS_SOURCE="$(DOCS_SOURCE)" ./scripts/deploy-docs.sh
help:
- @echo "Targets: build install clean test test-v release-scripts-test frontend frontend-test frontend-dev frontend-screenshots fmt lint lint-ci tidy install-hooks docs-install docs-build docs-serve docs-link docs-deploy"
+ @echo "Targets: build install clean test test-v release-scripts-test frontend frontend-test frontend-dev docs-screenshots fmt lint lint-ci tidy install-hooks docs-install docs-subpath-test docs-assets-test docs-assets-sync docs-build docs-serve docs-link docs-deploy"
diff --git a/README.md b/README.md
index 0d5bf72d..72325490 100644
--- a/README.md
+++ b/README.md
@@ -16,7 +16,7 @@ recoverable deletion, verified backup, and optional permanent audited history.
The vault catalog stays under your control instead of inside a provider
account.
-
+
The standalone CLI, web application, TUI, scripts, and agents all use the same
authenticated daemon contract. Go applications can instead embed independently
diff --git a/docs/README.md b/docs/README.md
index df1d4229..d725c050 100644
--- a/docs/README.md
+++ b/docs/README.md
@@ -15,24 +15,76 @@ plus internal design material.
metadata, assets, and publishing boundary; all Python tools run through
the locked `uv` environment
-Every rendered directory route also publishes its exact Markdown source at
-the sibling `.md` path: `/setup/` has `/setup.md`, and
-`/usage/importing/` has `/usage/importing.md`. This gives agents and other
-text-first clients a stable representation without scraping rendered HTML.
-Section landing pages use a sibling source such as `usage.md`, not
-`usage/index.md`, so relative links keep the same base at `/usage.md`.
+Every rendered directory route also publishes its exact Markdown source at the
+sibling `.md` path. The operating page `/docs/setup/` has `/docs/setup.md`, and
+`/docs/usage/importing/` has `/docs/usage/importing.md`. The product page and
+authority guide follow the same rule at `/index.md` and `/guide.md`. Agents and
+other text-first clients can use these stable representations without scraping
+rendered HTML.
## Building
+Run documentation commands from the repository root:
+
+```bash
+make docs-install # one-time: install the locked Zensical environment
+make docs-serve # build and watch all three tiers on http://127.0.0.1:8000
+make docs-build # strict production build into site/
+```
+
+`make docs-serve` serves the product page, authority guide, and operating
+documentation from one origin. It rebuilds after changes beneath `website/` or
+`docs/`, or after the pinned screenshot commit or manifest changes. A failed
+rebuild leaves the last successful `site/` available for inspection.
+
+## Screenshots
+
+Screenshot generation is a separate reviewed workflow. It never runs during a
+documentation build or deployment.
+
+1. Run `make docs-screenshots`. The harness starts a real daemon with a
+ temporary synthetic vault and writes the complete capture set beneath
+ `.superpowers/screenshots/`.
+2. Inspect every generated image and its metadata.
+3. Publish the complete reviewed set as one orphan `docs-assets` commit.
+4. Put that exact commit in `scripts/docs-assets.ref` and run
+ `make docs-assets-sync`.
+5. Run `make docs-build` after the final source or asset-pin edit.
+
+Never capture a developer vault, publish a partial set, or point the build at a
+mutable branch head.
+
+## Deployment
+
+A software release makes a documentation source eligible; it does not publish
+that source. The selected source is normally the documentation-only follow-up
+after the tag and release notes exist. A maintainer must still authorize every
+deployment.
+
+Link the Vercel project once from the repository root with `make docs-link`.
+Deploy an exact eligible source from a clean checkout with:
+
```bash
-cd docs
-uv sync --frozen # one-time: installs zensical into docs/.venv
-./zensical-docs.sh serve # live-reload preview
-./zensical-docs.sh build # strict production build into docs/site/
+make docs-deploy DOCS_SOURCE=$(git rev-parse HEAD)
```
-Or from the repository root: `make docs-install`, `make docs-serve`,
-`make docs-build`.
+The command checks that the source is on `origin/main`, descends from the latest
+software release, and contains only approved documentation changes. It uploads
+an unpromoted production build, waits for Vercel to verify it, repeats the
+release check, and only then promotes the build. Deployment does not generate
+screenshots, build the product, run Docker, or install frontend dependencies.
+
+The protected `Deploy documentation` workflow provides the same path for an
+explicitly supplied source SHA. A credential-free job checks that SHA against
+the release policy from `main` before the protected production job receives
+the validated SHA. It has no automatic push, pull-request, tag, or release
+trigger.
+
+Pull-request documentation checks never receive Vercel credentials. The
+authenticated upload dry run runs only after a trusted push to `main` and
+requires the repository `VERCEL_TOKEN` secret. Production deployment requires
+the `VERCEL_TOKEN`, `VERCEL_ORG_ID`, and `VERCEL_PROJECT_ID` secrets on the
+protected `production` environment.
## Documentation boundary
diff --git a/docs/index.md b/docs/index.md
index bac0580f..420452e4 100644
--- a/docs/index.md
+++ b/docs/index.md
@@ -34,7 +34,7 @@ curl -fsSL https://docbank.ai/install.sh | sh
Build agent workflows
-
+
A real synthetic vault in the local web application. See the visual tour.
diff --git a/docs/llms.txt b/docs/llms.txt
index a414da96..25d90f54 100644
--- a/docs/llms.txt
+++ b/docs/llms.txt
@@ -2,59 +2,63 @@
> Docbank is a self-sovereign document system for people and agents, with indexed retrieval, stable identity, verifiable content, incremental recovery, and audited history.
-Every documentation page is also published as raw Markdown at the same path with a `.md` suffix; the links below point at those Markdown routes.
+Every public page is also published as raw Markdown at the route listed below.
+
+## Public Site
+- [Docbank](https://docbank.ai/index.md): The system of record for documents your agents can use.
+- [Authority lifecycle guide](https://docbank.ai/guide.md): Follow one document from exact source ingestion through governed understanding, bounded retrieval, and verified recovery.
## Overview
-- [Your documents. Your agents. One system.](https://docbank.ai/index.md): Docbank is a self-sovereign document storage system for you and your agents, with indexed retrieval, stable identity, verifiable content, incremental recovery, and audited history.
+- [Your documents. Your agents. One system.](https://docbank.ai/docs/index.md): Docbank is a self-sovereign document storage system for you and your agents, with indexed retrieval, stable identity, verifiable content, incremental recovery, and audited history.
## Changelog
-- [Changelog](https://docbank.ai/changelog.md): Release history.
+- [Changelog](https://docbank.ai/docs/changelog.md): Release history.
## Start Here
-- [Setup](https://docbank.ai/setup.md): Install docbank on Linux, macOS, or Windows and create the vault.
-- [Quickstart](https://docbank.ai/quickstart.md): A ten-minute tour of the docbank CLI.
-- [Capabilities](https://docbank.ai/capabilities.md): What Docbank does for people, agents, applications, recovery, and physical storage.
-- [Visual Tour](https://docbank.ai/tour.md): See Docbank's real web and terminal interfaces running against synthetic document vaults.
+- [Setup](https://docbank.ai/docs/setup.md): Install docbank on Linux, macOS, or Windows and create the vault.
+- [Quickstart](https://docbank.ai/docs/quickstart.md): A ten-minute tour of the docbank CLI.
+- [Capabilities](https://docbank.ai/docs/capabilities.md): What Docbank does for people, agents, applications, recovery, and physical storage.
+- [Visual Tour](https://docbank.ai/docs/tour.md): See Docbank's real web and terminal interfaces running against synthetic document vaults.
## Work with Documents
-- [Importing Documents](https://docbank.ai/usage/importing.md): Bulk import semantics — recursion, idempotency, collision suffixing, and failure handling.
-- [Organizing & Tagging](https://docbank.ai/usage/organizing.md): Browsing, moving, renaming, and tagging in the virtual tree.
-- [Searching](https://docbank.ai/usage/searching.md): Ranked, prefix-matching search over document names and verified text content.
-- [Web application](https://docbank.ai/usage/web.md): Upload, browse, search, and organize the local vault in a responsive, authenticated web interface.
-- [Interactive terminal browser](https://docbank.ai/usage/tui.md): Browse documents, inspect authority, storage, backups, and permanent history, and safely move or restore recoverable trash from the TUI.
+- [Importing Documents](https://docbank.ai/docs/usage/importing.md): Bulk import semantics — recursion, idempotency, collision suffixing, and failure handling.
+- [Organizing & Tagging](https://docbank.ai/docs/usage/organizing.md): Browsing, moving, renaming, and tagging in the virtual tree.
+- [Searching](https://docbank.ai/docs/usage/searching.md): Ranked, prefix-matching search over document names and verified text content.
+- [Web application](https://docbank.ai/docs/usage/web.md): Upload, browse, search, and organize the local vault in a responsive, authenticated web interface.
+- [Interactive terminal browser](https://docbank.ai/docs/usage/tui.md): Browse documents, inspect authority, storage, backups, and permanent history, and safely move or restore recoverable trash from the TUI.
## Protect & Operate
-- [Vault Lifecycle](https://docbank.ai/usage/lifecycle.md): Operate a docbank vault safely from first import through maintenance, upgrades, snapshots, and recovery.
-- [Permanent Audited History](https://docbank.ai/usage/audited-history.md): Permanently retain every version and recorded change beneath a reviewed directory scope.
-- [Trash, GC, Repack & Verify](https://docbank.ai/usage/trash-and-gc.md): The explicit deletion and physical-reclamation lifecycle.
-- [Multi-store Storage](https://docbank.ai/usage/storage.md): Keep document bytes local-first, add filesystem or S3-compatible stores, and move verified authority deliberately.
-- [Backup & Restore](https://docbank.ai/usage/backup.md): Create incremental, verifiable snapshots in an immutable repository.
-- [Troubleshooting](https://docbank.ai/troubleshooting.md): Diagnose docbank startup, daemon, import, integrity, update, and HTTP API failures without risking the vault.
+- [Vault Lifecycle](https://docbank.ai/docs/usage/lifecycle.md): Operate a docbank vault safely from first import through maintenance, upgrades, snapshots, and recovery.
+- [Permanent Audited History](https://docbank.ai/docs/usage/audited-history.md): Permanently retain every version and recorded change beneath a reviewed directory scope.
+- [Trash, GC, Repack & Verify](https://docbank.ai/docs/usage/trash-and-gc.md): The explicit deletion and physical-reclamation lifecycle.
+- [Multi-store Storage](https://docbank.ai/docs/usage/storage.md): Keep document bytes local-first, add filesystem or S3-compatible stores, and move verified authority deliberately.
+- [Backup & Restore](https://docbank.ai/docs/usage/backup.md): Create incremental, verifiable snapshots in an immutable repository.
+- [Troubleshooting](https://docbank.ai/docs/troubleshooting.md): Diagnose docbank startup, daemon, import, integrity, update, and HTTP API failures without risking the vault.
## Automate & Integrate
-- [Docbank for Agents](https://docbank.ai/agents.md): Why agents use docbank, which interface to choose, and the safety model for document automation.
-- [Agent Integration Guide](https://docbank.ai/agents/integration.md): Connect an agent to docbank safely using its OpenAPI contract, authenticated HTTP API, revisions, and dry-run maintenance operations.
-- [Embed in Go](https://docbank.ai/embedding.md): Own one or more independently rooted Docbank vaults inside a Go application, with CGO or pure-Go SQLite.
-- [Document Understanding in Go](https://docbank.ai/document-understanding.md): Normalize and prepare documents for search, use bounded Mistral OCR, and embed images and video through Voyage without opening a Docbank vault.
+- [Docbank for Agents](https://docbank.ai/docs/agents.md): Why agents use docbank, which interface to choose, and the safety model for document automation.
+- [Agent Integration Guide](https://docbank.ai/docs/agents/integration.md): Connect an agent to docbank safely using its OpenAPI contract, authenticated HTTP API, revisions, and dry-run maintenance operations.
+- [Embed in Go](https://docbank.ai/docs/embedding.md): Own one or more independently rooted Docbank vaults inside a Go application, with CGO or pure-Go SQLite.
+- [Document Understanding in Go](https://docbank.ai/docs/document-understanding.md): Normalize and prepare documents for search, use bounded Mistral OCR, and embed images and video through Voyage without opening a Docbank vault.
## Reference
-- [CLI Reference](https://docbank.ai/cli-reference.md): Every docbank command, flag, output format, and error behavior.
-- [Configuration](https://docbank.ai/configuration.md): Vault location, data layout, config.toml, and environment variables.
-- [HTTP API](https://docbank.ai/architecture/http-api.md): The agent-first HTTP API — filesystem-shaped endpoints, revision preconditions, and the daemon's error contract.
+- [CLI Reference](https://docbank.ai/docs/cli-reference.md): Every docbank command, flag, output format, and error behavior.
+- [Configuration](https://docbank.ai/docs/configuration.md): Vault location, data layout, config.toml, and environment variables.
+- [HTTP API](https://docbank.ai/docs/architecture/http-api.md): The agent-first HTTP API — filesystem-shaped endpoints, revision preconditions, and the daemon's error contract.
## How It Works
-- [How Docbank Works](https://docbank.ai/architecture/overview.md): A guided model of vaults, document identity, immutable content, storage authority, deletion, and recovery.
-- [Source Metadata](https://docbank.ai/architecture/source-metadata.md): How Docbank records bounded facts found inside original files.
-- [Visual Previews](https://docbank.ai/architecture/visual-previews.md): How Docbank identifies and retains canonical visual derivatives.
-- [Storage](https://docbank.ai/architecture/storage.md): The SQLite schema, blob store layout, durability discipline, and enforced invariants.
-- [Loose & Packed Content](https://docbank.ai/architecture/packed-storage.md): The shared Kit packed-CAS layer and docbank's application-owned authority boundary.
-- [Editing & Versions](https://docbank.ai/architecture/editing-and-versions.md): Stable content-version identity, retrieval, replacement, reversion, and retention over immutable content.
-- [Ownership & Concurrency](https://docbank.ai/architecture/locking.md): How daemon and embedded owners coordinate concurrent access with SQLite, hierarchy locks, and owner-local operation gates.
-- [Daemon & Process Model](https://docbank.ai/architecture/daemon.md): docbank daemon run — the single process that owns the vault, and how the CLI discovers, auto-starts, and stops it.
-- [Backup & Recovery](https://docbank.ai/architecture/backup.md): Docbank's JSONL-native Kit snapshot and restore architecture.
-- [Integrity & Trust](https://docbank.ai/architecture/integrity.md): What docbank defends against, which layer owns each integrity guarantee, and the trade-offs that were considered and deliberately not taken.
-- [Audited History](https://docbank.ai/architecture/audited-history.md): The permanent, tamper-evident history model for protected directory scopes, content versions, backup, agents, and future interactive clients.
+- [How Docbank Works](https://docbank.ai/docs/architecture/overview.md): A guided model of vaults, document identity, immutable content, storage authority, deletion, and recovery.
+- [Source Metadata](https://docbank.ai/docs/architecture/source-metadata.md): How Docbank records bounded facts found inside original files.
+- [Visual Previews](https://docbank.ai/docs/architecture/visual-previews.md): How Docbank identifies and retains canonical visual derivatives.
+- [Storage](https://docbank.ai/docs/architecture/storage.md): The SQLite schema, blob store layout, durability discipline, and enforced invariants.
+- [Loose & Packed Content](https://docbank.ai/docs/architecture/packed-storage.md): The shared Kit packed-CAS layer and docbank's application-owned authority boundary.
+- [Editing & Versions](https://docbank.ai/docs/architecture/editing-and-versions.md): Stable content-version identity, retrieval, replacement, reversion, and retention over immutable content.
+- [Ownership & Concurrency](https://docbank.ai/docs/architecture/locking.md): How daemon and embedded owners coordinate concurrent access with SQLite, hierarchy locks, and owner-local operation gates.
+- [Daemon & Process Model](https://docbank.ai/docs/architecture/daemon.md): docbank daemon run — the single process that owns the vault, and how the CLI discovers, auto-starts, and stops it.
+- [Backup & Recovery](https://docbank.ai/docs/architecture/backup.md): Docbank's JSONL-native Kit snapshot and restore architecture.
+- [Integrity & Trust](https://docbank.ai/docs/architecture/integrity.md): What docbank defends against, which layer owns each integrity guarantee, and the trade-offs that were considered and deliberately not taken.
+- [Audited History](https://docbank.ai/docs/architecture/audited-history.md): The permanent, tamper-evident history model for protected directory scopes, content versions, backup, agents, and future interactive clients.
## Project
-- [Roadmap](https://docbank.ai/roadmap.md): What is implemented today and what each phase adds.
-- [License](https://docbank.ai/license.md): Docbank is open-source software licensed under the Apache License, Version 2.0.
+- [Roadmap](https://docbank.ai/docs/roadmap.md): What is implemented today and what each phase adds.
+- [License](https://docbank.ai/docs/license.md): Docbank is open-source software licensed under the Apache License, Version 2.0.
diff --git a/docs/scripts/check_built_site.py b/docs/scripts/check_built_site.py
index 7d636919..524528ed 100644
--- a/docs/scripts/check_built_site.py
+++ b/docs/scripts/check_built_site.py
@@ -36,13 +36,26 @@ def handle_starttag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None
def local_target(
- site: pathlib.Path, page: pathlib.Path, raw: str
+ site: pathlib.Path, page: pathlib.Path, raw: str, url_prefix: str = ""
) -> tuple[pathlib.Path, str] | None:
parsed = urllib.parse.urlsplit(raw)
if parsed.scheme or parsed.netloc:
return None
if parsed.path:
- target = site / parsed.path.lstrip("/") if parsed.path.startswith("/") else page.parent / parsed.path
+ if parsed.path.startswith("/"):
+ normalized_prefix = f"/{url_prefix.strip('/')}" if url_prefix else ""
+ if normalized_prefix:
+ if parsed.path == normalized_prefix:
+ local_path = "/"
+ elif parsed.path.startswith(f"{normalized_prefix}/"):
+ local_path = parsed.path[len(normalized_prefix) :]
+ else:
+ return None
+ else:
+ local_path = parsed.path
+ target = site / local_path.lstrip("/")
+ else:
+ target = page.parent / parsed.path
if parsed.path.endswith("/") or target.suffix == "":
target /= "index.html"
else:
@@ -63,6 +76,7 @@ def markdown_output(site: pathlib.Path, rel: pathlib.Path) -> pathlib.Path:
def main() -> None:
site = pathlib.Path(sys.argv[1] if len(sys.argv) > 1 else "site").resolve()
source = pathlib.Path(sys.argv[2]).resolve() if len(sys.argv) > 2 else None
+ url_prefix = sys.argv[3] if len(sys.argv) > 3 else ""
errors: list[str] = []
llms_source = source / "llms.txt" if source is not None else None
@@ -120,14 +134,8 @@ def main() -> None:
f"og:{field}"
):
errors.append(f"{rel}: Twitter {field} differs from Open Graph {field}")
- if (
- rel == pathlib.Path("index.html")
- and parser.metadata.get("og:title")
- != "Your documents. Your agents. One system."
- ):
- errors.append("index.html: social title differs from the homepage message")
for raw in parser.urls:
- local = local_target(site, page, raw)
+ local = local_target(site, page, raw, url_prefix)
if local is None:
continue
target, fragment = local
diff --git a/docs/scripts/check_built_site_test.py b/docs/scripts/check_built_site_test.py
new file mode 100644
index 00000000..0cda3d43
--- /dev/null
+++ b/docs/scripts/check_built_site_test.py
@@ -0,0 +1,35 @@
+from __future__ import annotations
+
+import pathlib
+import tempfile
+import unittest
+
+from check_built_site import local_target
+
+
+class LocalTargetTest(unittest.TestCase):
+ def test_strips_the_published_subpath_from_root_relative_urls(self) -> None:
+ with tempfile.TemporaryDirectory() as temporary:
+ site = pathlib.Path(temporary) / "docs"
+ page = site / "usage" / "example" / "index.html"
+
+ target, fragment = local_target(
+ site,
+ page,
+ "/docs/assets/stylesheets/main.css#theme",
+ "/docs",
+ )
+
+ self.assertEqual(target, (site / "assets/stylesheets/main.css").resolve())
+ self.assertEqual(fragment, "theme")
+
+ def test_ignores_root_routes_owned_by_another_site_tier(self) -> None:
+ with tempfile.TemporaryDirectory() as temporary:
+ site = pathlib.Path(temporary) / "docs"
+ page = site / "index.html"
+
+ self.assertIsNone(local_target(site, page, "/guide/", "/docs"))
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/docs/scripts/check_markdown_sources.py b/docs/scripts/check_markdown_sources.py
index 34782e8a..da2b5c88 100644
--- a/docs/scripts/check_markdown_sources.py
+++ b/docs/scripts/check_markdown_sources.py
@@ -59,7 +59,13 @@ def nav_paths(value: object) -> set[str]:
return result
-LLMS_BASE_URL = "https://docbank.ai/"
+LLMS_BASE_URL = "https://docbank.ai/docs/"
+LLMS_PUBLIC_SITE = [
+ "## Public Site",
+ "- [Docbank](https://docbank.ai/index.md): The system of record for documents your agents can use.",
+ "- [Authority lifecycle guide](https://docbank.ai/guide.md): Follow one document from exact source ingestion through governed understanding, bounded retrieval, and verified recovery.",
+ "",
+]
def frontmatter_field(path: pathlib.Path, field: str) -> str:
@@ -114,7 +120,7 @@ def check_llms_txt(config: dict, errors: list[str]) -> None:
actual = [line.rstrip() for line in lines[first_section:]]
while actual and not actual[-1]:
actual.pop()
- expected = expected_llms_sections(config["project"]["nav"])
+ expected = LLMS_PUBLIC_SITE + expected_llms_sections(config["project"]["nav"])
while expected and not expected[-1]:
expected.pop()
if actual != expected:
diff --git a/docs/scripts/check_zensical_subpath.py b/docs/scripts/check_zensical_subpath.py
new file mode 100644
index 00000000..d64590e6
--- /dev/null
+++ b/docs/scripts/check_zensical_subpath.py
@@ -0,0 +1,145 @@
+#!/usr/bin/env python3
+from __future__ import annotations
+
+import html.parser
+import pathlib
+import shutil
+import subprocess
+import tempfile
+import urllib.parse
+
+
+DOCS_ROOT = pathlib.Path(__file__).resolve().parents[1]
+
+
+class ParsedPage(html.parser.HTMLParser):
+ def __init__(self) -> None:
+ super().__init__()
+ self.canonical = ""
+ self.metadata: dict[str, str] = {}
+ self.urls: list[str] = []
+
+ def handle_starttag(
+ self, tag: str, attrs: list[tuple[str, str | None]]
+ ) -> None:
+ values = {key: value or "" for key, value in attrs}
+ if tag == "link" and values.get("rel") == "canonical":
+ self.canonical = values.get("href", "")
+ if tag == "meta" and values.get("property") and values.get("content"):
+ self.metadata[values["property"]] = values["content"]
+ for key in ("href", "src"):
+ if values.get(key):
+ self.urls.append(values[key])
+
+ @classmethod
+ def from_file(cls, path: pathlib.Path) -> ParsedPage:
+ parsed = cls()
+ parsed.feed(path.read_text(encoding="utf-8"))
+ return parsed
+
+
+def write_project(root: pathlib.Path) -> pathlib.Path:
+ source = root / "source"
+ output = root / "site"
+ (source / "usage").mkdir(parents=True)
+ (source / "stylesheets").mkdir()
+ shutil.copytree(DOCS_ROOT / "overrides", root / "overrides")
+ (source / "index.md").write_text(
+ "---\ntitle: Overview\ndescription: Subpath overview.\n---\n\n"
+ "# Overview\n\n[Example](usage/example.md)\n",
+ encoding="utf-8",
+ )
+ (source / "usage" / "example.md").write_text(
+ "---\ntitle: Example\ndescription: Subpath example.\n---\n\n"
+ "# Example\n\n[Overview](../index.md)\n",
+ encoding="utf-8",
+ )
+ shutil.copyfile(
+ DOCS_ROOT / "stylesheets" / "extra.css",
+ source / "stylesheets" / "extra.css",
+ )
+
+ config = root / "zensical.toml"
+ config.write_text(
+ "\n".join(
+ [
+ "[project]",
+ 'site_name = "docbank subpath probe"',
+ 'site_url = "https://docbank.ai/docs/"',
+ 'site_description = "Executable Zensical subpath probe."',
+ 'docs_dir = "source"',
+ 'site_dir = "site"',
+ 'extra_css = ["stylesheets/extra.css"]',
+ "use_directory_urls = true",
+ 'nav = [{"Overview" = "index.md"}, {"Usage" = [{"Example" = "usage/example.md"}]}]',
+ "",
+ "[project.theme]",
+ 'variant = "modern"',
+ "font = false",
+ 'custom_dir = "overrides"',
+ "",
+ ]
+ ),
+ encoding="utf-8",
+ )
+ return output
+
+
+def assert_site(output: pathlib.Path) -> None:
+ expected = {
+ "index.html": "https://docbank.ai/docs/",
+ "usage/example/index.html": "https://docbank.ai/docs/usage/example/",
+ }
+ for relative, canonical in expected.items():
+ page = output / relative
+ if not page.is_file():
+ raise AssertionError(f"missing rendered page: {relative}")
+ parsed = ParsedPage.from_file(page)
+ if parsed.canonical != canonical:
+ raise AssertionError(
+ f"{relative}: canonical is {parsed.canonical!r}, expected {canonical!r}"
+ )
+ if parsed.metadata.get("og:url") != canonical:
+ raise AssertionError(
+ f"{relative}: og:url is {parsed.metadata.get('og:url')!r}, "
+ f"expected {canonical!r}"
+ )
+ for raw_url in parsed.urls:
+ resolved = urllib.parse.urljoin(canonical, raw_url)
+ parsed_url = urllib.parse.urlsplit(resolved)
+ if parsed_url.netloc == "docbank.ai" and not parsed_url.path.startswith(
+ "/docs/"
+ ):
+ raise AssertionError(
+ f"{relative}: local URL escapes /docs/: {raw_url!r}"
+ )
+
+def main() -> None:
+ with tempfile.TemporaryDirectory(
+ prefix="zensical-subpath-", dir=DOCS_ROOT
+ ) as temporary:
+ root = pathlib.Path(temporary)
+ output = write_project(root)
+ subprocess.run(
+ [
+ "uv",
+ "run",
+ "--project",
+ str(DOCS_ROOT),
+ "--frozen",
+ "--no-dev",
+ "zensical",
+ "build",
+ "--strict",
+ "--config-file",
+ str(root / "zensical.toml"),
+ ],
+ cwd=DOCS_ROOT,
+ check=True,
+ )
+ assert_site(output)
+ print("Zensical /docs/ subpath behavior passed")
+
+
+if __name__ == "__main__":
+ main()
diff --git a/docs/stylesheets/extra.css b/docs/stylesheets/extra.css
index 8d5102fa..6ac19498 100644
--- a/docs/stylesheets/extra.css
+++ b/docs/stylesheets/extra.css
@@ -1,276 +1,120 @@
-/* docbank: an archive desk at night. Deep ink surfaces, paper-warm details. */
+@font-face { font-family: "Inter"; src: url("/fonts/Inter-Regular.woff2") format("woff2"); font-style: normal; font-weight: 400; font-display: swap; }
+@font-face { font-family: "Inter"; src: url("/fonts/Inter-Medium.woff2") format("woff2"); font-style: normal; font-weight: 500; font-display: swap; }
+@font-face { font-family: "Inter"; src: url("/fonts/Inter-SemiBold.woff2") format("woff2"); font-style: normal; font-weight: 600; font-display: swap; }
+@font-face { font-family: "JetBrains Mono"; src: url("/fonts/JetBrainsMono-Regular.woff2") format("woff2"); font-style: normal; font-weight: 400; font-display: swap; }
+@font-face { font-family: "JetBrains Mono"; src: url("/fonts/JetBrainsMono-SemiBold.woff2") format("woff2"); font-style: normal; font-weight: 600; font-display: swap; }
+@font-face { font-family: "JetBrains Mono"; src: url("/fonts/JetBrainsMono-Bold.woff2") format("woff2"); font-style: normal; font-weight: 700; font-display: swap; }
:root {
- --md-text-font: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
- --md-code-font: "SFMono-Regular", Consolas, "Liberation Mono", monospace;
+ --md-text-font: "Inter", system-ui, sans-serif;
+ --md-code-font: "JetBrains Mono", ui-monospace, monospace;
}
[data-md-color-scheme="slate"] {
- --md-hue: 205;
- --db-bg: #0a0f14;
- --db-raised: #0e151c;
- --db-panel: #121b23;
- --db-panel-hover: #17232d;
- --db-border: #263541;
- --db-border-strong: #466170;
- --db-text: #e8edf0;
- --db-muted: #9aabb5;
- --db-paper: #e5c991;
- --db-accent: #79c9bc;
- --db-accent-bright: #a1e1d6;
- --md-default-bg-color: var(--db-bg);
+ --md-hue: 165;
+ --db-ink: #071411;
+ --db-panel: #0c1e1a;
+ --db-panel-raised: #102822;
+ --db-rule: #25473f;
+ --db-teal: #41d7bd;
+ --db-teal-strong: #78ead6;
+ --db-text: #edf8f4;
+ --db-muted: #a7c1ba;
+ --md-default-bg-color: var(--db-ink);
--md-default-fg-color: var(--db-text);
- --md-default-fg-color--light: #c2ccd1;
- --md-default-fg-color--lighter: #85959f;
- --md-primary-fg-color: var(--db-raised);
- --md-primary-fg-color--light: var(--db-panel);
- --md-primary-fg-color--dark: var(--db-bg);
+ --md-default-fg-color--light: var(--db-muted);
+ --md-default-fg-color--lighter: #76948c;
+ --md-primary-fg-color: var(--db-panel);
+ --md-primary-fg-color--light: var(--db-panel-raised);
+ --md-primary-fg-color--dark: var(--db-ink);
--md-primary-bg-color: var(--db-text);
- --md-accent-fg-color: var(--db-accent-bright);
- --md-typeset-a-color: var(--db-accent);
- --md-code-bg-color: #101820;
- /* Mermaid inherits accent/code variables this palette doesn't define;
- set the diagram colors explicitly. All pairs are WCAG-checked against
- the dark surface: node text 12.7:1, edge labels 8.1:1, borders 7.8:1. */
- --md-mermaid-node-bg-color: #1a292c;
- --md-mermaid-node-fg-color: var(--db-accent);
+ --md-accent-fg-color: var(--db-teal-strong);
+ --md-typeset-a-color: var(--db-teal);
+ --md-code-bg-color: var(--db-panel);
+ --md-mermaid-node-bg-color: var(--db-panel-raised);
+ --md-mermaid-node-fg-color: var(--db-teal);
--md-mermaid-edge-color: var(--db-muted);
- --md-mermaid-label-bg-color: var(--db-bg);
+ --md-mermaid-label-bg-color: var(--db-ink);
--md-mermaid-label-fg-color: var(--db-text);
}
.md-header,
.md-tabs {
- background: color-mix(in srgb, var(--db-raised) 94%, transparent);
- border-bottom: 1px solid var(--db-border);
- backdrop-filter: blur(12px);
-}
-
-.md-typeset {
- font-size: 0.82rem;
-}
-
-.md-typeset a {
- color: var(--db-accent);
- text-decoration-thickness: 0.06em;
- text-underline-offset: 0.18em;
+ background: var(--db-panel);
+ border-bottom: 1px solid var(--db-rule);
}
+.md-typeset { font-size: 0.84rem; }
+.md-typeset a { color: var(--db-teal); text-decoration-thickness: 0.06em; text-underline-offset: 0.18em; }
.md-typeset a:hover,
.md-nav__link:hover,
-.md-nav__link--active {
- color: var(--db-accent-bright);
-}
-
-.md-nav__item .md-nav__link--active {
- background: rgba(121, 201, 188, 0.07);
- box-shadow: inset 2px 0 0 var(--db-accent);
-}
-
+.md-nav__link--active { color: var(--db-teal-strong); }
+.md-nav__item .md-nav__link--active { padding-left: 0.6rem; border-left: 2px solid var(--db-teal); background: var(--db-panel); }
.md-nav__item--section > .md-nav__link,
-.md-nav__title {
- letter-spacing: 0.015em;
-}
+.md-nav__title { letter-spacing: 0.015em; }
.md-typeset h1,
.md-typeset h2,
.md-typeset h3,
-.md-typeset h4 {
- color: #f4f6f7;
- font-weight: 620;
- letter-spacing: -0.018em;
-}
-
-.md-typeset h1 {
- max-width: 24ch;
- margin-bottom: 0.65em;
- font-size: clamp(1.4rem, 3.5vw, 2.3rem);
- line-height: 1.1;
-}
-
-.md-typeset h2 {
- padding-bottom: 0.32em;
- border-bottom: 1px solid var(--db-border);
-}
-
-.md-typeset .eyebrow {
- margin: 0 0 0.8rem;
- color: var(--db-paper);
- font-family: var(--md-code-font);
- font-size: 0.7rem;
- font-weight: 700;
- letter-spacing: 0.16em;
-}
-
-.md-typeset .hero-actions {
- display: flex;
- flex-wrap: wrap;
- gap: 0.7rem;
- margin: 1.4rem 0 2rem;
-}
-
-.md-typeset .image-caption {
- margin: -0.65rem 0 2rem;
- color: var(--db-muted);
- font-size: 0.72rem;
- text-align: center;
-}
-
-.md-typeset img {
- border: 1px solid var(--db-border);
- border-radius: 4px;
- box-shadow: 0 18px 48px rgba(0, 0, 0, 0.28);
-}
-
-.md-typeset .md-button {
- border-color: var(--db-border-strong);
- border-radius: 2px;
-}
-
-.md-typeset .md-button--primary {
- background: var(--db-accent);
- border-color: var(--db-accent);
- color: #07100f;
-}
-
+.md-typeset h4 { color: var(--db-text); font-weight: 600; letter-spacing: -0.018em; }
+.md-typeset h1 { max-width: 24ch; margin-bottom: 0.65em; font-size: clamp(1.5rem, 3.5vw, 2.4rem); line-height: 1.1; }
+.md-typeset h2 { padding-bottom: 0.32em; border-bottom: 1px solid var(--db-rule); }
+.md-typeset .eyebrow { margin: 0 0 0.8rem; color: var(--db-teal); font-family: var(--md-code-font); font-size: 0.7rem; font-weight: 700; letter-spacing: 0.14em; }
+.md-typeset .hero-actions { display: flex; flex-wrap: wrap; gap: 0.7rem; margin: 1.4rem 0 2rem; }
+.md-typeset .image-caption { margin: -0.65rem 0 2rem; color: var(--db-muted); font-size: 0.72rem; text-align: center; }
+.md-typeset img { border: 1px solid var(--db-rule); border-radius: 4px; }
+
+.md-typeset .md-button { border-color: var(--db-rule); border-radius: 3px; }
+.md-typeset .md-button:hover,
+.md-typeset .md-button:focus { border-color: var(--db-teal); background: var(--db-panel-raised); }
+.md-typeset .md-button--primary { border-color: var(--db-teal); color: var(--db-ink); background: var(--db-teal); }
.md-typeset .md-button--primary:hover,
-.md-typeset .md-button--primary:focus {
- background: var(--db-accent-bright);
- border-color: var(--db-accent-bright);
- color: #07100f;
-}
+.md-typeset .md-button--primary:focus { border-color: var(--db-teal-strong); color: var(--db-ink); background: var(--db-teal-strong); }
.md-typeset .signal-grid,
-.md-typeset .feature-grid {
- display: grid;
- gap: 0.8rem;
- margin: 1.4rem 0 2.2rem;
-}
-
-.md-typeset .signal-grid {
- grid-template-columns: repeat(3, minmax(0, 1fr));
-}
-
+.md-typeset .feature-grid { display: grid; gap: 1px; margin: 1.4rem 0 2.2rem; padding: 1px; background: var(--db-rule); }
+.md-typeset .signal-grid { grid-template-columns: repeat(3, minmax(0, 1fr)); }
.md-typeset .signal-grid section,
-.md-typeset .feature-grid section {
- min-width: 0;
- padding: 1rem;
- background: linear-gradient(145deg, var(--db-panel), #0f171e);
- border: 1px solid var(--db-border);
- border-radius: 2px;
-}
-
-.md-typeset .signal-grid section {
- display: flex;
- flex-direction: column;
- gap: 0.38rem;
-}
-
-.md-typeset .signal-grid strong {
- color: var(--db-paper);
- font-family: var(--md-code-font);
- font-size: 0.72rem;
- letter-spacing: 0.08em;
- text-transform: uppercase;
-}
-
-.md-typeset .signal-grid span {
- color: var(--db-muted);
- font-size: 0.78rem;
- line-height: 1.45;
-}
-
-.md-typeset .feature-grid {
- grid-template-columns: repeat(2, minmax(0, 1fr));
-}
-
-.md-typeset .feature-grid section:hover {
- background: var(--db-panel-hover);
- border-color: var(--db-border-strong);
-}
-
-.md-typeset .feature-grid h3 {
- margin: 0 0 0.42rem;
- color: var(--db-paper);
- font-size: 1rem;
-}
-
-.md-typeset .feature-grid p {
- margin: 0;
- color: var(--db-muted);
-}
+.md-typeset .feature-grid section { min-width: 0; padding: 1rem; background: var(--db-panel); }
+.md-typeset .signal-grid section { display: flex; flex-direction: column; gap: 0.38rem; }
+.md-typeset .signal-grid strong { color: var(--db-teal-strong); font-family: var(--md-code-font); font-size: 0.72rem; letter-spacing: 0.08em; text-transform: uppercase; }
+.md-typeset .signal-grid span,
+.md-typeset .feature-grid p { color: var(--db-muted); }
+.md-typeset .feature-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
+.md-typeset .feature-grid section:hover { background: var(--db-panel-raised); }
+.md-typeset .feature-grid h3 { margin: 0 0 0.42rem; color: var(--db-teal-strong); font-size: 1rem; }
+.md-typeset .feature-grid p { margin: 0; }
.md-typeset .admonition,
-.md-typeset details {
- background: var(--db-panel);
- border-color: var(--db-border-strong);
- border-radius: 2px;
-}
-
+.md-typeset details { border-color: var(--db-rule); border-radius: 3px; background: var(--db-panel); }
.md-typeset .admonition-title,
-.md-typeset summary {
- background: rgba(229, 201, 145, 0.07);
-}
-
-.md-typeset table:not([class]) {
- border-collapse: collapse;
- font-size: 0.76rem;
-}
-
-.md-typeset table:not([class]) th {
- background: #15212a;
- color: var(--db-paper);
- font-weight: 650;
-}
-
+.md-typeset summary { background: var(--db-panel-raised); }
+.md-typeset table:not([class]) { border-collapse: collapse; font-size: 0.76rem; }
+.md-typeset table:not([class]) th { color: var(--db-teal-strong); background: var(--db-panel-raised); font-weight: 600; }
.md-typeset table:not([class]) th,
-.md-typeset table:not([class]) td {
- padding: 0.58em 0.76em;
- border-color: var(--db-border);
- vertical-align: top;
-}
-
-.md-typeset table:not([class]) tr:nth-child(even) {
- background: rgba(255, 255, 255, 0.016);
-}
-
-.md-typeset code:not(pre code) {
- padding: 0.1em 0.3em;
- background: var(--md-code-bg-color);
- border: 1px solid var(--db-border);
- border-radius: 2px;
-}
-
+.md-typeset table:not([class]) td { padding: 0.58em 0.76em; border-color: var(--db-rule); vertical-align: top; }
+.md-typeset table:not([class]) tr:nth-child(even) { background: var(--db-panel); }
+.md-typeset code:not(pre code) { padding: 0.1em 0.3em; border: 1px solid var(--db-rule); border-radius: 2px; background: var(--md-code-bg-color); }
.md-typeset .highlight pre,
-.md-typeset .mermaid {
- background: #0e161d;
- border: 1px solid var(--db-border);
- border-radius: 2px;
-}
-
-.md-typeset .mermaid {
- padding: 1rem;
- overflow-x: auto;
-}
-
-::selection {
- background: #31564f;
- color: #ffffff;
-}
+.md-typeset .mermaid { border: 1px solid var(--db-rule); border-radius: 3px; background: var(--db-panel); }
+.md-typeset .mermaid { padding: 1rem; overflow-x: auto; }
-:focus-visible {
- outline: 2px solid var(--db-accent);
- outline-offset: 2px;
-}
+::selection { color: var(--db-ink); background: var(--db-teal-strong); }
+:focus-visible { outline: 2px solid var(--db-teal); outline-offset: 3px; }
@media (max-width: 50rem) {
.md-typeset .signal-grid,
- .md-typeset .feature-grid {
- grid-template-columns: 1fr;
- }
-
- .md-typeset .hero-actions .md-button {
- width: 100%;
- text-align: center;
+ .md-typeset .feature-grid { grid-template-columns: 1fr; }
+ .md-typeset .hero-actions .md-button { width: 100%; text-align: center; }
+}
+
+@media (prefers-reduced-motion: reduce) {
+ *,
+ *::before,
+ *::after {
+ scroll-behavior: auto !important;
+ transition-duration: 0.01ms !important;
+ animation-duration: 0.01ms !important;
+ animation-iteration-count: 1 !important;
}
}
diff --git a/docs/superpowers/specs/2026-08-30-tiered-documentation-site-design.md b/docs/superpowers/specs/2026-08-30-tiered-documentation-site-design.md
new file mode 100644
index 00000000..3d1ca9dc
--- /dev/null
+++ b/docs/superpowers/specs/2026-08-30-tiered-documentation-site-design.md
@@ -0,0 +1,248 @@
+# Tiered Documentation Site Design
+
+## Objective
+
+Give Docbank one public site with three deliberate levels of depth:
+
+- `https://docbank.ai/` establishes the product identity and install path.
+- `https://docbank.ai/guide/` explains the document authority lifecycle through a visual operator tour.
+- `https://docbank.ai/docs/` contains precise operating and architecture documentation.
+
+The site describes Docbank as it will exist after the current document-processing, retrieval, interface, and Model Context Protocol pull-request stack lands. Implementation begins from `origin/main`, but this branch must not merge or deploy until the dependent stack through PR #239 lands. After the stack lands, the branch is updated from `main`; its capability wording, navigation, examples, screenshots, and tests are reconciled against the landed code rather than the earlier pull-request descriptions. Publication waits for the corresponding software release and post-tag documentation follow-up.
+
+## Audience and position
+
+The primary reader is a highly technical operator or builder responsible for durable AI document workflows. They evaluate systems through authority boundaries, failure behavior, data portability, deployment control, and recovery evidence. The site must explain those properties directly instead of borrowing generic AI-product language.
+
+The core position is:
+
+> The system of record for documents your agents can use.
+
+Docbank keeps original documents authoritative while governed processing produces searchable renditions and embeddings. Every derivative and retrieval result remains tied to exact source identity, explicit disclosure, and a recovery path controlled by the operator.
+
+The organizing idea is the **authority lifecycle**:
+
+1. Ingest exact source bytes without changing the source.
+2. Assign stable document identity and immutable content-version identity.
+3. Review provider disclosure, retained classes, estimates, and consent before processing.
+4. Publish renditions and embedding sets as derivatives, never replacement authority.
+5. Retrieve lexically, semantically, or in hybrid mode behind an exact source-version fence.
+6. Serve bounded access through the CLI, web application, terminal UI, HTTP API, embedded Go API, and MCP server.
+7. Verify, audit, back up, restore, and rebuild without converting a provider or index into authority.
+
+“AI-native” describes the resulting operating model, not a visual theme or an excuse for vague claims. Copy uses the actual Docbank nouns: source version, processing plan, consent, rendition, embedding set, source fence, receipt, audit, backup, and restore.
+
+## Public tiers
+
+### Product page: `/`
+
+The static product page answers four questions in order:
+
+1. What is Docbank? An authoritative document system for technical operators and agents.
+2. What makes it different? Original authority remains distinct from derived machine understanding.
+3. What can an operator do? Store, version, process, retrieve, expose, audit, and recover documents through one governed system.
+4. What is the next action? Install the current release or read the guide.
+
+The page contains:
+
+- A compact header with Docbank, Guide, Docs, and GitHub.
+- A hero with the core position, concrete supporting copy, install and guide actions, and platform/license facts.
+- A document authority ledger showing Source, Authorization, Understanding, Retrieval, and Recovery states.
+- Four proof sections: keep the source sovereign, govern machine understanding, constrain every answer, and restore the record.
+- A real product capture from the pinned screenshot set.
+- A section explaining local, self-hosted, and hosted processing choices without turning a provider list into the product identity.
+- A section showing one authority across the CLI, web, TUI, HTTP, embedded Go API, and MCP.
+- A direct comparison with cloud drives and storage appliances that preserves Docbank’s existing archive/system-of-record boundary.
+- A final guide/install call to action and a restrained legal footer.
+
+The page does not use synthetic AI artwork, network-node illustrations, fabricated dashboards, vanity metrics, customer logos, or unprovable security language.
+
+### Operator guide: `/guide/`
+
+The guide is a seven-stop visual explanation of one document’s path through Docbank:
+
+1. **Ingest** — copy exact source bytes into a virtual tree while leaving the source untouched.
+2. **Identify** — show stable node identity, immutable versions, checksums, moves, and replacement history.
+3. **Authorize** — inspect a processing plan, provider flow, retained classes, estimate, fingerprint, and consent state before egress.
+4. **Understand** — build and inspect retained sanitized Markdown, normalized evidence, embedding sets, independent coverage, and durable jobs.
+5. **Retrieve** — run lexical, semantic, hybrid, or automatic retrieval over an exact source-version fence and inspect the evidence used.
+6. **Serve** — use the same authority through human interfaces, automation APIs, embedding, and the bounded MCP server.
+7. **Prove** — verify content, inspect audited history, create and verify a backup, restore without provider calls, and rebuild disposable indexes.
+
+Each stop uses an actual synthetic-vault capture or a compact native diagram derived from product contracts. Each stop links to the corresponding operating documentation. The guide avoids duplicating full command references.
+
+### Operating documentation: `/docs/`
+
+The existing Zensical content moves under `/docs/`. Its navigation continues to separate starting, document work, protection and operations, automation and integration, reference, architecture, and project status.
+
+The incoming stack’s document-processing, source-fenced retrieval, processing-configuration, derivative-architecture, and MCP pages remain operating documentation. Their final routes remove the ambiguous duplicate names introduced by the stack:
+
+- Existing `usage/searching.md` becomes **Catalog search** and continues to cover names, verified current plain text, tags, media types, directories, and modification times.
+- Incoming `usage/search.md` moves to `usage/retrieval.md` as **Source-fenced retrieval** and covers lexical, semantic, hybrid, and automatic retrieval over processed derivatives.
+- Existing root `configuration.md` becomes **Vault and daemon configuration**.
+- Incoming `usage/configuration.md` moves to `usage/processing-configuration.md` as **Processing configuration**.
+
+The post-stack reconciliation performs those moves, updates links and `llms.txt`, and preserves the exact contracts of both workflows.
+
+## Visual language
+
+The site uses a flat teal-on-ink system:
+
+- Solid ink backgrounds and slightly raised solid panels.
+- Thin, precise rules to establish hierarchy.
+- One restrained teal accent for active states, links, and primary actions.
+- Off-white primary text and quiet blue-green gray secondary text.
+- Square or minimally rounded geometry.
+- No gradients, glows, glass effects, translucent floating panels, decorative blobs, particle fields, or “AI network” imagery.
+- No ornamental motion. Any transition must clarify interaction state and respect reduced-motion preferences.
+
+The authority ledger is the main graphic motif. It presents meaningful identities and states rather than decoration.
+
+### Typography
+
+Vendor pinned WOFF2 files and their license texts for the fonts required by the public site:
+
+- **JetBrains Mono** for the wordmark, headings, navigation, labels, code, identifiers, and lifecycle states.
+- **Inter** for paragraphs, tables, captions, long-form documentation, and other continuous reading.
+
+Body text uses Inter 400 with generous line height. Inter 500 or 600 provides emphasis. The site does not use font weights below 400; visual lightness comes from spacing, scale, and color contrast rather than fragile hairline glyphs. JetBrains Mono uses 600 or 700 for display hierarchy and 400 for code.
+
+All text and interactive states must meet WCAG AA contrast. Focus states are visible and use the same teal system.
+
+Zensical sets `project.theme.font = false` so it does not load a second copy from Google Fonts. The built-site verifier rejects `fonts.googleapis.com` and `fonts.gstatic.com`; all font requests resolve to the vendored files.
+
+## Human- and machine-readable publishing contract
+
+Every substantive page has a stable HTML route and a Markdown peer:
+
+| Human route | Markdown route |
+| --- | --- |
+| `/` | `/index.md` |
+| `/guide/` | `/guide.md` |
+| `/docs/` | `/docs/index.md` |
+| `/docs//` | `/docs/.md` |
+| `/docs///` | `/docs//.md` |
+
+`https://docbank.ai/llms.txt` is the hand-maintained root index for all published Markdown pages across the three tiers. It uses canonical `docbank.ai` URLs.
+
+The production build fails when:
+
+- a substantive HTML page lacks its Markdown peer;
+- a published Markdown page lacks its HTML route;
+- `llms.txt` omits a published Markdown page or names a missing page;
+- the copied Markdown differs from its source;
+- a local link, anchor, image, canonical URL, or required social metadata is invalid; or
+- private/internal material crosses the publishing boundary.
+
+The static product page and guide use dedicated text-first Markdown sources. They contain the same claims, examples, and links as their visual HTML pages without trying to reproduce visual layout in Markdown.
+
+## Screenshot generation and publication
+
+Screenshot generation is independent from documentation builds and deployment.
+
+`make docs-screenshots` replaces the existing `make frontend-screenshots` target; there is no compatibility alias. `AGENTS.md`, `frontend/screenshots/README.md`, the Makefile help text, and the `.superpowers` hook message all name the replacement command. It runs the repository-owned Playwright harness against a real temporary daemon and synthetic vault. It never reads a developer vault, uses mocked API data, or writes captures into tracked documentation sources.
+
+Expanding that harness is part of this project. The current branch emits only a subset of the historical `docs-assets` branch, and the stack head adds processing-plan, partial-coverage, and rendition captures. After the stack lands, the harness must emit every screenshot named by the new manifest before this branch can merge. Guide stops without a meaningful product screen—such as the cross-surface authority map and provider-free recovery flow—use tracked native diagrams instead of fabricated UI captures.
+
+Publishing replaces the complete root of the orphan `docs-assets` branch with the reviewed capture set. `scripts/docs-assets.txt` is a sorted newline-delimited manifest containing one root-level PNG filename per line; directory components, duplicate names, blank entries, and non-PNG extensions are invalid. `scripts/docs-assets.ref` records the full lowercase 40-character reviewed commit SHA. The first publication under this contract flattens the current ad-hoc feature/version directories and removes the branch README so the orphan branch contains exactly the manifested PNG files.
+
+Documentation builds fetch the exact pinned commit, validate every expected asset, reject unmanifested files, and fail closed on missing, duplicate, malformed, oversized, or unsupported files. They never fall back to the mutable branch head. The pinned Git commit supplies content identity; the local synced-generation record stores each PNG’s SHA-256 for cache verification.
+
+Documentation screenshots are PNG unless a specific reviewed vector diagram is maintained as tracked site source. Remote SVG screenshots are not accepted. The generated set is staged beside its destination and published atomically so a failed move cannot delete the previous usable set.
+
+The build materializes pinned assets into an ignored local cache before rendering. Published Markdown and root `README.md` use canonical `https://docbank.ai/assets/generated/.png` URLs so images render both on the site and on GitHub after the first production publication. The current `raw.githubusercontent.com/.../docs-assets/...` URLs are removed during migration. Local HTML may use root-relative `/assets/generated/.png` URLs.
+
+## Build architecture
+
+The repository root owns documentation commands:
+
+- `make docs-install` installs the locked Zensical environment.
+- `make docs-serve` runs a small dependency-free Node watcher/server that builds the same three tiers, serves the generated `site/` root, rebuilds after changes to public website or documentation sources, and keeps `/`, `/guide/`, and `/docs/` on one local origin.
+- `make docs-build` produces and verifies the complete static site.
+- `make docs-screenshots` generates the separate capture set and is the only screenshot target.
+- `make docs-deploy` performs the verified production deployment from the repository root.
+
+The static build follows an explicit public allowlist:
+
+1. Copy `website/` into a scratch site root for `/`, `/guide/`, fonts, favicon, and static scripts.
+2. Copy installer scripts from their repository-owned sources.
+3. Fetch and validate the pinned screenshot generation.
+4. Stage only publishable Zensical sources into a scratch project.
+5. Render Zensical under `/docs/`.
+6. Publish every Markdown peer and root `llms.txt`.
+7. Verify routes, links, metadata, assets, Markdown parity, and the public boundary.
+8. Atomically replace the local `site/` output.
+
+Zensical remains locked through `docs/uv.lock`. Before the rest of the build migration, an executable spike builds a minimal page with Zensical 0.0.45 at `site_url = "https://docbank.ai/docs/"` and verifies its canonical URL, asset URLs, navigation, and Markdown peer under that subpath. A failed spike stops implementation for a design revision; the plan does not assume subpath behavior that has not been observed.
+
+Static website production code has no frontend framework or package-install step. Small browser behavior such as screenshot zoom and current-release download selection uses dependency-free JavaScript. Browser verification is a development/CI concern and may install the repository’s locked frontend test dependencies.
+
+The Vercel install script downloads the standalone `uv` 0.12.1 archive for the detected Linux x86-64 or ARM64 architecture, verifies the architecture-specific SHA-256 pinned in the script, and installs only `uv` and `uvx` into a project-local Vercel tools directory. The build path then uses the locked Zensical environment, syncs the pinned assets, and builds the static site. It does not install or run Go, npm dependencies, the product frontend, Docker, Playwright, screenshot generation, provider runtimes, or browser tests.
+
+Root `.vercelignore` acts as an upload allowlist. It includes only the static website, allowlisted documentation sources, fonts/favicon, installer sources, and the scripts/configuration required for the static build. It excludes repository source, caches, generated product assets, local state, credentials, internal documentation, plans, reports, and screenshot tooling.
+
+Vercel project ownership moves completely to the repository root. Root `vercel.json` defines the install command, build command, `site` output directory, and trailing-slash behavior. `make docs-link` runs `vercel link` from the root and checks root `.vercel/project.json`. Tracked `docs/vercel.json` is deleted, and no command reads, copies, or falls back to the old local `docs/.vercel` link. Existing operators run `make docs-link` once to create the new root link.
+
+## Release and deployment contract
+
+Candidate feature documentation must not become public before the corresponding software release.
+
+The successful software release makes a documentation source eligible but does not publish it immediately. The intended production source is the post-tag documentation-only follow-up after the final release changelog and wording corrections merge. Deployment verifies that the latest release tag is an ancestor of the selected source and that every commit between the tag and that source changes only the approved documentation and publishing surface. The selected source may be an exact commit on `main` if unrelated next-release work has already advanced the branch.
+
+The GitHub deployment workflow is explicitly dispatched with that eligible source after the post-tag follow-up. `make docs-deploy` uses the same release-aware validation and remains the recommended manual production command. The Vercel upload uses the root allowlist and does not send the full repository.
+
+Deployment and promotion are separate operations. The workflow verifies the intended latest release before build, creates a production-target deployment with automatic domain assignment disabled (`vercel deploy --prod --skip-domain`), waits for that deployment to become ready, and verifies the intended latest release again. Only then does it run `vercel promote `. A failed or stale check leaves an unpromoted deployment and cannot replace the current production aliases.
+
+The canonical host is `https://docbank.ai/`. `www.docbank.ai` redirects to the apex host.
+
+## Development workflow for maintainers and agents
+
+`AGENTS.md` and `docs/README.md` document the same workflow:
+
+- Use `make docs-serve` for content, navigation, and visual work.
+- Use `make docs-screenshots` only when product UI captures need regeneration; `make frontend-screenshots` no longer exists.
+- Inspect generated screenshots before publishing a complete `docs-assets` generation.
+- Update the pinned commit and manifest in a separate reviewed change.
+- Run `make docs-build` after the final documentation edit.
+- Review substantive changes as rendered pages, not only source diffs.
+- Do not publish candidate feature documentation before its release.
+- Use `make docs-deploy` from the root only for an authorized production release or post-tag documentation follow-up.
+
+The guidance explicitly tells agents that docs builds consume screenshots but never generate them.
+
+While updating `docs/README.md`, remove its inaccurate statement that section landings use files such as `usage.md`; the current navigation has no section-landing Markdown files.
+
+## Verification
+
+Verification exercises owned behavior rather than restating configuration text:
+
+- Unit tests build a fixture site and prove that missing twins, stale `llms.txt` entries, broken links, leaked private paths, and undeclared assets fail.
+- Asset-sync tests use a local synthetic Git repository and prove exact-commit pinning, manifest completeness, file-type/size validation, and fail-closed behavior without network fallbacks.
+- Upload-boundary tests construct the actual Vercel input set and prove that required public files are present while representative Go, frontend, internal, local-environment, and screenshot-generation files are absent.
+- The strict production build validates all output pages and Markdown peers.
+- Browser verification uses the repository’s locked Playwright dependency, adds explicit Chromium and WebKit projects for the built static site, and installs those browsers only in the development/CI test lane. It loads the built landing page, guide, representative docs pages, navigation, theme, responsive layouts, screenshot lightbox, and download fallback.
+- Accessibility checks cover headings, landmarks, keyboard navigation, visible focus, image alternatives, reduced motion, and color contrast.
+
+Tests do not assert that a parser returns values copied from the same configuration, inspect source strings as a proxy for behavior, or recreate the implementation logic inside the assertion.
+
+## Non-goals
+
+- Redesigning the Docbank product UI.
+- Changing document-processing, retrieval, storage, audit, backup, or MCP contracts.
+- Adding a JavaScript framework or general-purpose website build system.
+- Publishing unreleased capability claims before the post-stack release.
+- Tracking generated screenshots on the main branch.
+- Running product builds or screenshot capture inside Vercel.
+- Merging or deploying this branch before the dependent stack through PR #239 lands and the site is reconciled against its final code.
+
+## Acceptance criteria
+
+- The three tiers build into one verified static site at the documented routes.
+- The homepage and guide express the authority lifecycle for technical operators using post-stack product behavior.
+- The visual system uses flat teal-on-ink surfaces, JetBrains Mono display text, and readable Inter body text without AI-style decoration.
+- Root `llms.txt` lists every published Markdown page, and every substantive HTML page has its documented Markdown peer.
+- Documentation builds consume one pinned, complete screenshot generation and never run screenshot capture.
+- `make docs-deploy` works from the repository root, enforces the release boundary, uploads only the narrow public input set, and promotes only after the final release check passes.
+- Strict build, behavioral boundary tests, and browser verification pass.
+- Maintainer and agent instructions describe the complete development, screenshot, review, and release workflow.
+- The branch has been updated after the dependent stack lands, and its pages, screenshots, examples, and capability claims match the resulting code before merge.
diff --git a/docs/tour.md b/docs/tour.md
index b1bef17f..1a67e7a3 100644
--- a/docs/tour.md
+++ b/docs/tour.md
@@ -16,7 +16,7 @@ node's current path, stable ID, revision, immutable version, SHA-256 identity,
tags, provenance, and permanent-audit status. The browser receives scoped,
daemon-lifetime credentials rather than the daemon's master API key.
-
+
## Organize independently of folders
@@ -24,7 +24,7 @@ Tags form a shared vocabulary with stable UUIDs and revision-protected
definitions. People can manage the catalog or a selected document's
assignments in the web application; agents use the same bounded daemon API.
-
+
## Verify permanent history
@@ -33,7 +33,7 @@ changes. Independent verification replays the history, re-hashes protected
content, and returns terminal scope heads that can be recorded outside the
vault for later comparison.
-
+
## See where physical authority lives
@@ -43,7 +43,7 @@ The web view is deliberately read-only: it separates logical authority,
physical inventory, store health, sole copies, and live-document impact without
exposing deployment paths or credentials.
-
+
## Operate from a terminal
@@ -52,7 +52,7 @@ document identity, permanent history, recoverable trash and restore, job
status, and a read-only operational screen. Storage and backup results load
independently so one unavailable repository does not hide live-vault status.
-
+
Continue with the [Quickstart](quickstart.md), or choose a task from
[Capabilities](capabilities.md).
diff --git a/docs/usage/audited-history.md b/docs/usage/audited-history.md
index 968dfef9..f12686c4 100644
--- a/docs/usage/audited-history.md
+++ b/docs/usage/audited-history.md
@@ -29,7 +29,7 @@ including records outside the selected directory. Those unrelated documents
do not become audit members and their content versions are not protected by
the scope, but the enrollment-time metadata remains part of the evidence.
-
+
## Review before enabling
diff --git a/docs/usage/organizing.md b/docs/usage/organizing.md
index aa729ad5..04dce106 100644
--- a/docs/usage/organizing.md
+++ b/docs/usage/organizing.md
@@ -80,7 +80,7 @@ Tags organize documents independently of their current paths. Each tag has a
stable UUID; its name can change without breaking assignments or agent-held
references.
-
+
```bash
docbank tag create taxes
diff --git a/docs/usage/storage.md b/docs/usage/storage.md
index 441e56ed..376f0018 100644
--- a/docs/usage/storage.md
+++ b/docs/usage/storage.md
@@ -16,7 +16,7 @@ records which verified stores may satisfy each retained SHA-256 identity, but
it does not mirror arbitrary filesystem changes, manage bucket lifecycle
rules, or replace a complete [backup](backup.md).
-
+
The web application and TUI expose this inventory read-only. Registration,
placement, repair, takeover, evacuation, and removal remain explicit CLI or
diff --git a/docs/usage/tui.md b/docs/usage/tui.md
index 9c83a58e..cc67bde1 100644
--- a/docs/usage/tui.md
+++ b/docs/usage/tui.md
@@ -18,7 +18,7 @@ outlive the background daemon's idle window, so each bounded interaction
rediscovers or restarts a compatible daemon before issuing its request; leaving
the terminal open does not pin an otherwise idle process.
-
+
The main view is a full-width document table. At ordinary terminal widths it
shows each document's name, type, size, and UTC modification time; search results
diff --git a/docs/usage/web.md b/docs/usage/web.md
index 34d1600c..066a95b1 100644
--- a/docs/usage/web.md
+++ b/docs/usage/web.md
@@ -41,7 +41,7 @@ The browser is another client of the authenticated HTTP API. It does not open
SQLite or the blob store, and it has no private route that the CLI or an agent
cannot use. The daemon remains loopback-only.
-
+
*The root browser keeps the document table primary while the authority card
shows the selected file's stable identity and verified content hash.*
@@ -271,7 +271,7 @@ workflows are bound to the tag's stable UUID so a later rename does not
silently change which definition was selected. Changing the selector reruns
the current browse or search.
-
+
*Search results display complete virtual paths and keep the same authority
inspection available from ordinary folder browsing.*
diff --git a/docs/vercel.json b/docs/vercel.json
deleted file mode 100644
index 9959e60c..00000000
--- a/docs/vercel.json
+++ /dev/null
@@ -1,3 +0,0 @@
-{
- "framework": null
-}
diff --git a/docs/zensical-docs.sh b/docs/zensical-docs.sh
index 92bed6f8..a0e7f569 100755
--- a/docs/zensical-docs.sh
+++ b/docs/zensical-docs.sh
@@ -18,7 +18,11 @@ if ! command -v uv >/dev/null 2>&1; then
fi
uv_run=(uv run --project "$docs_root" --frozen --no-dev)
-repo_root="$(git -C "$docs_root" rev-parse --show-toplevel)"
+if [[ -n "${DOCBANK_REPO_ROOT:-}" ]]; then
+ repo_root="$(cd "$DOCBANK_REPO_ROOT" && pwd)"
+else
+ repo_root="$(git -C "$docs_root" rev-parse --show-toplevel)"
+fi
if [[ "/$site_dir/" == *"/../"* ]]; then
printf 'refusing docs site directory containing parent traversal: %s\n' "$site_dir" >&2
exit 2
@@ -159,7 +163,7 @@ case "$command_name" in
mkdir -p "$(dirname "$markdown_dest")"
cp "$markdown_source" "$markdown_dest"
done < <(find "$tmp_docs" -type f -name '*.md' -print0)
- "${uv_run[@]}" python "$docs_root/scripts/check_built_site.py" "$tmp_site" "$tmp_docs"
+ "${uv_run[@]}" python "$docs_root/scripts/check_built_site.py" "$tmp_site" "$tmp_docs" /docs
printf '%s\n' "$site_marker_contents" > "$tmp_site/$site_marker"
mkdir -p "$(dirname "$site_path")"
if [[ -e "$site_path" || -L "$site_path" ]]; then
diff --git a/docs/zensical.toml b/docs/zensical.toml
index e745fdc5..07bb6e4d 100644
--- a/docs/zensical.toml
+++ b/docs/zensical.toml
@@ -1,6 +1,6 @@
[project]
site_name = "docbank"
-site_url = "https://www.docbank.ai/"
+site_url = "https://docbank.ai/docs/"
site_description = "Docbank is a self-sovereign document system for people and agents, with indexed retrieval, stable identity, verifiable content, incremental recovery, and audited history."
site_author = "Kenn Software LLC"
copyright = 'Copyright © 2026 Kenn Software LLC .'
@@ -70,6 +70,7 @@ homepage = "/"
[project.theme]
variant = "modern"
custom_dir = "overrides"
+font = false
features = [
"navigation.instant",
"navigation.instant.progress",
diff --git a/frontend/docs-site/playwright.config.ts b/frontend/docs-site/playwright.config.ts
new file mode 100644
index 00000000..4b60e340
--- /dev/null
+++ b/frontend/docs-site/playwright.config.ts
@@ -0,0 +1,50 @@
+import { defineConfig, devices } from "@playwright/test";
+import path from "node:path";
+import { fileURLToPath } from "node:url";
+
+const here = path.dirname(fileURLToPath(import.meta.url));
+const repositoryRoot = path.resolve(here, "..", "..");
+const port = 41738;
+
+export default defineConfig({
+ testDir: ".",
+ testMatch: /site\.spec\.ts/,
+ fullyParallel: false,
+ workers: 1,
+ timeout: 60_000,
+ expect: {
+ timeout: 10_000,
+ },
+ outputDir: path.join(repositoryRoot, ".superpowers", "docs-site-playwright"),
+ reporter: "line",
+ use: {
+ baseURL: `http://127.0.0.1:${port}`,
+ trace: "retain-on-failure",
+ },
+ webServer: {
+ command: "node scripts/docs/serve.mjs",
+ cwd: repositoryRoot,
+ env: {
+ DOCBANK_DOCS_PORT: String(port),
+ },
+ url: `http://127.0.0.1:${port}`,
+ reuseExistingServer: false,
+ timeout: 120_000,
+ },
+ projects: [
+ {
+ name: "chromium",
+ use: {
+ ...devices["Desktop Chrome"],
+ colorScheme: "dark",
+ },
+ },
+ {
+ name: "webkit",
+ use: {
+ ...devices["Desktop Safari"],
+ colorScheme: "dark",
+ },
+ },
+ ],
+});
diff --git a/frontend/docs-site/site.spec.ts b/frontend/docs-site/site.spec.ts
new file mode 100644
index 00000000..00fbe6f3
--- /dev/null
+++ b/frontend/docs-site/site.spec.ts
@@ -0,0 +1,176 @@
+import { expect, test, type Locator, type Page } from "@playwright/test";
+
+
+const routes = ["/", "/guide/", "/docs/", "/docs/usage/searching/"];
+
+
+function channel(value: number): number {
+ const normalized = value / 255;
+ return normalized <= 0.04045
+ ? normalized / 12.92
+ : ((normalized + 0.055) / 1.055) ** 2.4;
+}
+
+
+function luminance([red, green, blue]: [number, number, number]): number {
+ return 0.2126 * channel(red) + 0.7152 * channel(green) + 0.0722 * channel(blue);
+}
+
+
+function ratio(foreground: [number, number, number], background: [number, number, number]): number {
+ const lighter = Math.max(luminance(foreground), luminance(background));
+ const darker = Math.min(luminance(foreground), luminance(background));
+ return (lighter + 0.05) / (darker + 0.05);
+}
+
+
+function parseOpaqueColor(value: string): [number, number, number] {
+ const match = value.match(/^rgba?\(\s*(\d+(?:\.\d+)?)\D+(\d+(?:\.\d+)?)\D+(\d+(?:\.\d+)?)(?:\D+([\d.]+))?\s*\)$/);
+ if (!match) throw new Error(`unsupported computed color: ${value}`);
+ if (match[4] !== undefined && Number(match[4]) !== 1) {
+ throw new Error(`contrast sample is not fully opaque: ${value}`);
+ }
+ return [Number(match[1]), Number(match[2]), Number(match[3])];
+}
+
+
+async function computedColors(locator: Locator): Promise<{ foreground: string; background: string }> {
+ return locator.evaluate((element) => {
+ const foreground = getComputedStyle(element).color;
+ let current: Element | null = element;
+ while (current) {
+ const background = getComputedStyle(current).backgroundColor;
+ const alpha = background.match(/[\d.]+\)$/)?.[0].slice(0, -1);
+ if (!background.startsWith("rgba") || alpha === "1") return { foreground, background };
+ current = current.parentElement;
+ }
+ throw new Error("no opaque background found");
+ });
+}
+
+
+async function expectContrast(locator: Locator, minimum: number): Promise {
+ const colors = await computedColors(locator);
+ expect(ratio(parseOpaqueColor(colors.foreground), parseOpaqueColor(colors.background))).toBeGreaterThanOrEqual(minimum);
+}
+
+
+test("publishes each tier with semantic page landmarks", async ({ page }) => {
+ for (const route of routes) {
+ await page.goto(route);
+ await expect(page.locator("main")).toBeVisible();
+ await expect(page.getByRole("heading", { level: 1 })).toHaveCount(1);
+ await expect(page.getByRole("navigation").first()).toBeVisible();
+ }
+});
+
+
+test("supports keyboard navigation and returns focus after the image dialog", async ({ browserName, page }) => {
+ await page.goto("/");
+ const skipLink = page.getByRole("link", { name: "Skip to content" });
+ await skipLink.focus();
+ await expect(skipLink).toBeFocused();
+ if (browserName !== "webkit") {
+ await page.keyboard.press("Tab");
+ await expect(page.locator(".wordmark")).toBeFocused();
+ await page.keyboard.press("Tab");
+ await expect(
+ page.getByRole("navigation", { name: "Primary navigation" }).getByRole("link", { name: "Guide", exact: true }),
+ ).toBeFocused();
+ }
+
+ const trigger = page.getByRole("link", { name: /synthetic technical document collection/i });
+ await trigger.click();
+ await expect(page.getByRole("dialog")).toBeVisible();
+ await expect(page.getByRole("button", { name: "Close image" })).toBeFocused();
+ await page.keyboard.press("Escape");
+ await expect(page.getByRole("dialog")).not.toBeVisible();
+ await expect(trigger).toBeFocused();
+});
+
+
+test("shows the documented install command with a Windows alternative", async ({ page }) => {
+ await page.goto("/");
+ await expect(page.locator("[data-install-command] code")).toHaveText("curl -fsSL https://docbank.ai/install.sh | sh");
+ await expect(page.getByRole("link", { name: "Windows install" })).toBeVisible();
+});
+
+
+test("renders repository facts in the header when GitHub responds", async ({ page }) => {
+ await page.route("https://api.github.com/repos/kenn-io/docbank", (route) =>
+ route.fulfill({ json: { stargazers_count: 1280, forks_count: 34 } }),
+ );
+ await page.route("https://api.github.com/repos/kenn-io/docbank/releases/latest", (route) =>
+ route.fulfill({ json: { tag_name: "v0.4.0" } }),
+ );
+ await page.goto("/");
+ await expect(page.locator('[data-fact="stars"]')).toHaveText(/1\.3k/);
+ await expect(page.locator('[data-fact="forks"]')).toHaveText(/34/);
+ await expect(page.locator('[data-fact="version"]')).toHaveText(/v0\.4\.0/);
+});
+
+
+test("keeps a static header when the GitHub API is unavailable", async ({ page }) => {
+ await page.route("https://api.github.com/**", (route) => route.fulfill({ status: 503, body: "unavailable" }));
+ const settled = page.waitForResponse("https://api.github.com/repos/kenn-io/docbank");
+ await page.goto("/");
+ await settled;
+ await expect(page.getByRole("link", { name: /on GitHub/ })).toBeVisible();
+ await expect(page.locator("[data-facts]")).toBeHidden();
+});
+
+
+test("keeps primary navigation usable at a narrow viewport", async ({ page }) => {
+ await page.setViewportSize({ width: 390, height: 844 });
+ await page.goto("/");
+ const navigation = page.getByRole("navigation", { name: "Primary navigation" });
+ await expect(navigation.getByRole("link", { name: "Guide" })).toBeVisible();
+ await expect(navigation.getByRole("link", { name: "Docs" })).toBeVisible();
+ await expect(navigation.getByRole("link", { name: /on GitHub/ })).toBeVisible();
+ await expect(navigation.getByRole("link", { name: "Docbank Discord" })).toBeVisible();
+ expect(await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth)).toBe(true);
+});
+
+
+test("honors reduced motion", async ({ page }) => {
+ await page.emulateMedia({ reducedMotion: "reduce" });
+ await page.goto("/");
+ await expect.poll(() => page.locator("html").evaluate((element) => getComputedStyle(element).scrollBehavior)).toBe("auto");
+ const duration = await page.getByRole("link", { name: "See how it works" }).evaluate(
+ (element) => getComputedStyle(element).transitionDuration,
+ );
+ expect(Number.parseFloat(duration)).toBeLessThanOrEqual(0.00001);
+});
+
+
+test("gives every rendered image accessible text", async ({ page }) => {
+ for (const route of routes) {
+ await page.goto(route);
+ const images = page.locator("img");
+ for (let index = 0; index < await images.count(); index += 1) {
+ expect((await images.nth(index).getAttribute("alt"))?.trim(), `${route} image ${index}`).toBeTruthy();
+ }
+ }
+});
+
+
+test("keeps representative text and controls above contrast thresholds", async ({ page }) => {
+ await page.goto("/");
+ await expectContrast(page.getByRole("heading", { level: 1 }), 3);
+ await expectContrast(page.locator(".lede").first(), 4.5);
+ await expectContrast(page.getByRole("link", { name: "Guide" }).first(), 4.5);
+ await expectContrast(page.getByRole("link", { name: "See how it works" }), 4.5);
+ await expectContrast(page.locator("[data-install-command] code"), 4.5);
+
+ const copy = page.getByRole("button", { name: "Copy" });
+ await copy.focus();
+ const focus = await copy.evaluate((element) => {
+ const style = getComputedStyle(element);
+ return { outline: style.outlineColor, background: style.backgroundColor };
+ });
+ expect(ratio(parseOpaqueColor(focus.outline), parseOpaqueColor(focus.background))).toBeGreaterThanOrEqual(3);
+
+ await page.goto("/docs/");
+ await expectContrast(page.locator(".md-typeset p").first(), 4.5);
+ await expectContrast(page.locator(".md-typeset a").first(), 4.5);
+});
diff --git a/frontend/docs-site/tsconfig.json b/frontend/docs-site/tsconfig.json
new file mode 100644
index 00000000..072124cb
--- /dev/null
+++ b/frontend/docs-site/tsconfig.json
@@ -0,0 +1,15 @@
+{
+ "compilerOptions": {
+ "esModuleInterop": true,
+ "forceConsistentCasingInFileNames": true,
+ "lib": ["ES2023", "DOM"],
+ "module": "ESNext",
+ "moduleResolution": "bundler",
+ "noEmit": true,
+ "skipLibCheck": true,
+ "strict": true,
+ "target": "ES2023",
+ "types": ["node", "@playwright/test"]
+ },
+ "include": ["*.ts"]
+}
diff --git a/frontend/package.json b/frontend/package.json
index 2887235c..b620f9ec 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -11,8 +11,10 @@
"build": "vp build",
"check": "svelte-check --tsconfig ./tsconfig.json",
"check:kit-ui": "kit-ui-check src",
+ "docs-site:check": "tsc -p docs-site/tsconfig.json --noEmit",
+ "docs-site:test": "node node_modules/@playwright/test/cli.js test --config docs-site/playwright.config.ts",
"screenshots": "node screenshots/run.mjs",
- "screenshots:check": "tsc -p screenshots/tsconfig.json --noEmit",
+ "screenshots:check": "tsc -p screenshots/tsconfig.json --noEmit && node --test screenshots/publish.node-test.mjs",
"test": "vp test run"
},
"dependencies": {
diff --git a/frontend/screenshots/README.md b/frontend/screenshots/README.md
index b4d44499..a048b5b6 100644
--- a/frontend/screenshots/README.md
+++ b/frontend/screenshots/README.md
@@ -15,21 +15,30 @@ node node_modules/@playwright/test/cli.js install chromium
Then run from the repository root:
```sh
-make frontend-screenshots
+make docs-screenshots
```
The command builds the current frontend and Docbank binary, creates and seeds
an owner-private temporary vault, opens the daemon-issued browser session,
captures the requested state, stops the daemon, and removes the vault.
-Generated images are written beneath `.superpowers/screenshots/` for visual
-inspection and PR attachment; the current case captures both move-to-trash and
+Generated images are atomically published beneath `.superpowers/screenshots/`
+for visual inspection and orphan-branch publication; the current set captures
+both move-to-trash and
restore confirmations, the tag-definition catalog, and a completed tag
assignment, current vault browsing, extracted-text search, retained-version
selection, packed-storage status, and independently verified permanent-audit
-evidence. Generated images are intentionally not committed.
+evidence. Generated images are intentionally not committed to the main branch.
-Pass ordinary Playwright arguments after `--` to select a case:
+The command must produce the complete set listed in `scripts/docs-assets.txt`.
+Documentation builds consume a reviewed set and never run this harness.
+
+For focused harness development, invoke Playwright directly with a separate
+temporary `DOCBANK_SCREENSHOT_DIR`; the root Make target intentionally rejects
+partial generations:
```sh
-npm --prefix frontend run screenshots -- --grep "trash confirmation"
+cd frontend
+DOCBANK_SCREENSHOT_DIR="$(mktemp -d)" node node_modules/@playwright/test/cli.js test \
+ --config screenshots/playwright.config.ts --project chromium \
+ --grep "trash confirmation"
```
diff --git a/frontend/screenshots/publish.mjs b/frontend/screenshots/publish.mjs
new file mode 100644
index 00000000..bdb885c7
--- /dev/null
+++ b/frontend/screenshots/publish.mjs
@@ -0,0 +1,99 @@
+import {
+ access,
+ open,
+ readdir,
+ rename as renamePath,
+ rm,
+} from "node:fs/promises";
+import path from "node:path";
+
+
+const pngSignature = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
+const screenshotName = /^[A-Za-z0-9][A-Za-z0-9._-]*\.png$/;
+
+
+async function exists(target) {
+ try {
+ await access(target);
+ return true;
+ } catch (error) {
+ if (error?.code === "ENOENT") return false;
+ throw error;
+ }
+}
+
+
+function validateNames(names) {
+ if (!Array.isArray(names) || names.length === 0) {
+ throw new Error("screenshot manifest is empty");
+ }
+ const sorted = [...names].sort();
+ if (names.some((name, index) => name !== sorted[index])) {
+ throw new Error("screenshot manifest is not sorted");
+ }
+ if (new Set(names).size !== names.length) {
+ throw new Error("screenshot manifest contains duplicate names");
+ }
+ for (const name of names) {
+ if (!screenshotName.test(name) || path.basename(name) !== name) {
+ throw new Error(`invalid screenshot name: ${name}`);
+ }
+ }
+}
+
+
+async function validateStaging(staging, names) {
+ const entries = await readdir(staging, { withFileTypes: true });
+ const files = entries.filter((entry) => entry.isFile()).map((entry) => entry.name).sort();
+ const unexpectedEntry = entries.find((entry) => !entry.isFile());
+ if (unexpectedEntry) {
+ throw new Error(`unexpected screenshot entry: ${unexpectedEntry.name}`);
+ }
+ for (const name of names) {
+ if (!files.includes(name)) throw new Error(`missing screenshot: ${name}`);
+ }
+ for (const name of files) {
+ if (!names.includes(name)) throw new Error(`unexpected screenshot: ${name}`);
+ }
+ for (const name of names) {
+ const file = await open(path.join(staging, name), "r");
+ try {
+ const header = Buffer.alloc(pngSignature.length);
+ const { bytesRead } = await file.read(header, 0, header.length, 0);
+ if (bytesRead !== pngSignature.length || !header.equals(pngSignature)) {
+ throw new Error(`invalid PNG signature: ${name}`);
+ }
+ } finally {
+ await file.close();
+ }
+ }
+}
+
+
+export async function publishScreenshots({
+ output,
+ staging,
+ names,
+ rename = async (from, to, realRename) => realRename(from, to),
+}) {
+ validateNames(names);
+ if (path.dirname(output) !== path.dirname(staging)) {
+ throw new Error("screenshot staging directory must be beside output");
+ }
+ await validateStaging(staging, names);
+
+ const previous = path.join(
+ path.dirname(output),
+ `.${path.basename(output)}.previous`,
+ );
+ await rm(previous, { recursive: true, force: true });
+ const hadOutput = await exists(output);
+ if (hadOutput) await rename(output, previous, renamePath);
+ try {
+ await rename(staging, output, renamePath);
+ } catch (error) {
+ if (hadOutput) await rename(previous, output, renamePath);
+ throw error;
+ }
+ await rm(previous, { recursive: true, force: true });
+}
diff --git a/frontend/screenshots/publish.node-test.mjs b/frontend/screenshots/publish.node-test.mjs
new file mode 100644
index 00000000..338ba5a2
--- /dev/null
+++ b/frontend/screenshots/publish.node-test.mjs
@@ -0,0 +1,102 @@
+import assert from "node:assert/strict";
+import { mkdtemp, mkdir, readFile, readdir, rm, writeFile } from "node:fs/promises";
+import { tmpdir } from "node:os";
+import path from "node:path";
+import test from "node:test";
+
+import { publishScreenshots } from "./publish.mjs";
+
+// This suite uses Node's built-in test runner and is intentionally named so
+// Vitest does not discover it as part of the Svelte unit-test lane.
+
+
+const pngBytes = Buffer.from([
+ 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00,
+]);
+
+
+async function workspace(t) {
+ const root = await mkdtemp(path.join(tmpdir(), "docbank-screenshot-publish-"));
+ t.after(() => rm(root, { recursive: true, force: true }));
+ return {
+ output: path.join(root, "screenshots"),
+ staging: path.join(root, ".screenshots.next"),
+ };
+}
+
+
+test("publishes a complete staged set atomically", async (t) => {
+ const { output, staging } = await workspace(t);
+ await mkdir(output);
+ await writeFile(path.join(output, "old.png"), pngBytes);
+ await mkdir(staging);
+ await writeFile(path.join(staging, "one.png"), pngBytes);
+
+ await publishScreenshots({ output, staging, names: ["one.png"] });
+
+ assert.deepEqual(await readdir(output), ["one.png"]);
+ assert.deepEqual(await readFile(path.join(output, "one.png")), pngBytes);
+});
+
+
+test("keeps the previous set when the staged manifest is incomplete", async (t) => {
+ const { output, staging } = await workspace(t);
+ await mkdir(output);
+ await writeFile(path.join(output, "old.png"), pngBytes);
+ await mkdir(staging);
+ await writeFile(path.join(staging, "one.png"), pngBytes);
+
+ await assert.rejects(
+ publishScreenshots({
+ output,
+ staging,
+ names: ["one.png", "two.png"],
+ }),
+ /missing screenshot: two\.png/,
+ );
+
+ assert.deepEqual(await readdir(output), ["old.png"]);
+ assert.deepEqual(await readFile(path.join(output, "old.png")), pngBytes);
+});
+
+
+test("restores the previous set when final rename fails", async (t) => {
+ const { output, staging } = await workspace(t);
+ await mkdir(output);
+ await writeFile(path.join(output, "old.png"), pngBytes);
+ await mkdir(staging);
+ await writeFile(path.join(staging, "one.png"), pngBytes);
+ let renameCalls = 0;
+
+ await assert.rejects(
+ publishScreenshots({
+ output,
+ staging,
+ names: ["one.png"],
+ async rename(from, to, realRename) {
+ renameCalls += 1;
+ if (renameCalls === 2) throw new Error("synthetic rename failure");
+ await realRename(from, to);
+ },
+ }),
+ /synthetic rename failure/,
+ );
+
+ assert.deepEqual(await readdir(output), ["old.png"]);
+ assert.deepEqual(await readFile(path.join(output, "old.png")), pngBytes);
+});
+
+
+test("rejects unsafe manifest names before changing output", async (t) => {
+ const { output, staging } = await workspace(t);
+ await mkdir(output);
+ await writeFile(path.join(output, "old.png"), pngBytes);
+ await mkdir(staging);
+
+ await assert.rejects(
+ publishScreenshots({ output, staging, names: ["nested/one.png"] }),
+ /invalid screenshot name/,
+ );
+
+ assert.deepEqual(await readdir(output), ["old.png"]);
+});
diff --git a/frontend/screenshots/run.mjs b/frontend/screenshots/run.mjs
index e2f80cd9..b5618d60 100644
--- a/frontend/screenshots/run.mjs
+++ b/frontend/screenshots/run.mjs
@@ -1,10 +1,17 @@
import { spawnSync } from "node:child_process";
+import { mkdir, readFile, rm } from "node:fs/promises";
import path from "node:path";
import { fileURLToPath } from "node:url";
+import { publishScreenshots } from "./publish.mjs";
+
const here = path.dirname(fileURLToPath(import.meta.url));
const frontendRoot = path.resolve(here, "..");
const repositoryRoot = path.resolve(frontendRoot, "..");
+const workspaceRoot = path.join(repositoryRoot, ".superpowers");
+const output = path.join(workspaceRoot, "screenshots");
+const staging = path.join(workspaceRoot, ".screenshots.next");
+const manifestPath = path.join(repositoryRoot, "scripts", "docs-assets.txt");
const build = spawnSync("make", ["build"], {
cwd: repositoryRoot,
@@ -13,6 +20,9 @@ const build = spawnSync("make", ["build"], {
if (build.error) throw build.error;
if (build.status !== 0) process.exit(build.status ?? 1);
+await rm(staging, { recursive: true, force: true });
+await mkdir(staging, { recursive: true, mode: 0o700 });
+
const playwrightCLI = path.join(
frontendRoot,
"node_modules",
@@ -34,7 +44,19 @@ const result = spawnSync(
{
cwd: frontendRoot,
stdio: "inherit",
+ env: {
+ ...process.env,
+ DOCBANK_SCREENSHOT_DIR: staging,
+ },
},
);
if (result.error) throw result.error;
-process.exit(result.status ?? 1);
+if (result.status !== 0) {
+ await rm(staging, { recursive: true, force: true });
+ process.exit(result.status ?? 1);
+}
+
+const names = (await readFile(manifestPath, "utf8"))
+ .split("\n")
+ .filter((line) => line !== "");
+await publishScreenshots({ output, staging, names });
diff --git a/frontend/screenshots/web-trash.screenshot.ts b/frontend/screenshots/web-trash.screenshot.ts
index ea8a5c8e..659f6c5e 100644
--- a/frontend/screenshots/web-trash.screenshot.ts
+++ b/frontend/screenshots/web-trash.screenshot.ts
@@ -10,72 +10,25 @@ const execFileAsync = promisify(execFile);
const here = path.dirname(fileURLToPath(import.meta.url));
const repositoryRoot = path.resolve(here, "..", "..");
const binary = path.join(repositoryRoot, "docbank");
-const screenshotPath = path.join(
- repositoryRoot,
- ".superpowers",
- "screenshots",
- "web-trash-confirmation.png",
-);
-const restoreScreenshotPath = path.join(
- repositoryRoot,
- ".superpowers",
- "screenshots",
+const screenshotDirectory = process.env.DOCBANK_SCREENSHOT_DIR;
+if (!screenshotDirectory) throw new Error("DOCBANK_SCREENSHOT_DIR is required");
+const screenshotPathFor = (name: string): string =>
+ path.join(screenshotDirectory, name);
+const screenshotPath = screenshotPathFor("web-trash-confirmation.png");
+const restoreScreenshotPath = screenshotPathFor(
"web-trash-restore-confirmation.png",
);
-const tagAssignmentScreenshotPath = path.join(
- repositoryRoot,
- ".superpowers",
- "screenshots",
- "web-tag-assignment.png",
-);
-const tagCatalogScreenshotPath = path.join(
- repositoryRoot,
- ".superpowers",
- "screenshots",
- "web-tag-catalog.png",
-);
-const auditEvidenceScreenshotPath = path.join(
- repositoryRoot,
- ".superpowers",
- "screenshots",
- "web-audit-evidence.png",
-);
-const storageScreenshotPath = path.join(
- repositoryRoot,
- ".superpowers",
- "screenshots",
- "web-multi-store-storage.png",
-);
-const tuiStorageScreenshotPath = path.join(
- repositoryRoot,
- ".superpowers",
- "screenshots",
- "tui-multi-store-storage.png",
-);
-const vaultBrowserScreenshotPath = path.join(
- repositoryRoot,
- ".superpowers",
- "screenshots",
- "web-vault-browser.png",
-);
-const searchResultsScreenshotPath = path.join(
- repositoryRoot,
- ".superpowers",
- "screenshots",
- "web-search-results.png",
-);
-const retainedVersionScreenshotPath = path.join(
- repositoryRoot,
- ".superpowers",
- "screenshots",
+const tagAssignmentScreenshotPath = screenshotPathFor("web-tag-assignment.png");
+const tagCatalogScreenshotPath = screenshotPathFor("web-tag-catalog.png");
+const auditEvidenceScreenshotPath = screenshotPathFor("web-audit-evidence.png");
+const storageScreenshotPath = screenshotPathFor("web-multi-store-storage.png");
+const tuiStorageScreenshotPath = screenshotPathFor("tui-multi-store-storage.png");
+const vaultBrowserScreenshotPath = screenshotPathFor("web-vault-browser.png");
+const searchResultsScreenshotPath = screenshotPathFor("web-search-results.png");
+const retainedVersionScreenshotPath = screenshotPathFor(
"web-retained-version-download.png",
);
-const packedStorageScreenshotPath = path.join(
- repositoryRoot,
- ".superpowers",
- "screenshots",
- "web-storage-status.png",
-);
+const packedStorageScreenshotPath = screenshotPathFor("web-storage-status.png");
test.describe("Docbank web screenshots", () => {
let workspace = "";
@@ -155,11 +108,13 @@ test.describe("Docbank web screenshots", () => {
await rm(retainedVersionScreenshotPath, { force: true });
await rm(packedStorageScreenshotPath, { force: true });
const archive = path.join(workspace, "archive-store");
+ const backup = path.join(workspace, "backup-repository");
await mkdir(vault, { recursive: true, mode: 0o700 });
await mkdir(archive, { recursive: true, mode: 0o700 });
await writeFile(
path.join(vault, "config.toml"),
- `[store_bindings.archive]\nkind = "filesystem"\npath = ${JSON.stringify(archive)}\npriority = 20\n`,
+ `[backup]\nrepo = ${JSON.stringify(backup)}\n\n` +
+ `[store_bindings.archive]\nkind = "filesystem"\npath = ${JSON.stringify(archive)}\npriority = 20\n`,
{ mode: 0o600 },
);
const reports = path.join(workspace, "synthetic", "Reports");
@@ -265,6 +220,15 @@ test.describe("Docbank web screenshots", () => {
if (!extractionReady) {
throw new Error("synthetic text extraction did not complete");
}
+ await runDocbank(["backup", "init"]);
+ await runDocbank([
+ "backup",
+ "create",
+ "--tag",
+ "before-review",
+ "--jobs",
+ "1",
+ ]);
const revisedReport = path.join(
workspace,
"synthetic",
@@ -280,6 +244,14 @@ test.describe("Docbank web screenshots", () => {
revisedReport,
"/Reports/quarterly-tax-report.txt",
]);
+ await runDocbank([
+ "backup",
+ "create",
+ "--tag",
+ "reviewed",
+ "--jobs",
+ "1",
+ ]);
webURL = await runDocbank(["web", "--no-browser"]);
const browserURL = new URL(webURL);
const port = Number(browserURL.port);
@@ -561,10 +533,10 @@ test.describe("Docbank web screenshots", () => {
.poll(capture, { timeout: 15_000 })
.toContain("documents for you and your agents");
await tmux(["send-keys", "-t", session, "O"]);
- const terminal = await expect
+ await expect
.poll(capture, { timeout: 15_000 })
- .toContain("Vault operations")
- .then(capture);
+ .toContain("2 recovery point(s)");
+ const terminal = await capture();
await page.setViewportSize({ width: 1280, height: 760 });
await page.setContent(`
diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts
index 643426cb..156a00e6 100644
--- a/frontend/vite.config.ts
+++ b/frontend/vite.config.ts
@@ -22,7 +22,7 @@ export default defineConfig({
},
test: {
environment: "jsdom",
- exclude: ["node_modules/**"],
+ exclude: ["docs-site/**", "node_modules/**"],
server: {
deps: {
inline: ["svelte"],
diff --git a/prek.toml b/prek.toml
index a5054aed..70aee9fc 100644
--- a/prek.toml
+++ b/prek.toml
@@ -15,5 +15,5 @@ always_run = true
id = "forbid-superpowers-artifacts"
name = "forbid-superpowers-artifacts"
language = "fail"
-entry = ".superpowers is temporary workspace output; publish inspected screenshots to docs-assets instead"
+entry = ".superpowers is temporary workspace output; inspect make docs-screenshots output and publish the complete set to docs-assets instead"
files = "^\\.superpowers/"
diff --git a/scripts/deploy-docs.sh b/scripts/deploy-docs.sh
new file mode 100755
index 00000000..2a81b101
--- /dev/null
+++ b/scripts/deploy-docs.sh
@@ -0,0 +1,47 @@
+#!/bin/sh
+set -eu
+
+script_dir=$(CDPATH='' cd -- "$(dirname -- "$0")" && pwd)
+repo_root=$(CDPATH='' cd -- "$script_dir/.." && pwd)
+source_sha=${DOCS_SOURCE:-}
+
+if [ -z "$source_sha" ]; then
+ printf 'DOCS_SOURCE is required; use a full source commit SHA\n' >&2
+ exit 2
+fi
+if ! command -v vercel >/dev/null 2>&1; then
+ printf 'vercel CLI not found; install Vercel CLI 58.4.4 or later\n' >&2
+ exit 127
+fi
+
+cd "$repo_root"
+if [ -n "$(git status --porcelain)" ]; then
+ printf 'refusing documentation deploy from a dirty worktree\n' >&2
+ exit 1
+fi
+head_sha=$(git rev-parse HEAD)
+if [ "$head_sha" != "$source_sha" ]; then
+ printf 'DOCS_SOURCE must equal HEAD: expected %s, got %s\n' "$head_sha" "$source_sha" >&2
+ exit 1
+fi
+if [ ! -f .vercel/project.json ] && { [ -z "${VERCEL_ORG_ID:-}" ] || [ -z "${VERCEL_PROJECT_ID:-}" ]; }; then
+ printf 'documentation project is not linked; run make docs-link or provide Vercel project IDs\n' >&2
+ exit 1
+fi
+
+git fetch --quiet origin refs/heads/main:refs/remotes/origin/main --tags
+expected_tag=$(git describe --tags --abbrev=0 --match 'v[0-9]*.[0-9]*.[0-9]*' origin/main)
+./scripts/validate-docs-release.sh "$source_sha" "$expected_tag"
+
+deployment_url=$(vercel deploy --prod --skip-domain --yes)
+case "$deployment_url" in
+ https://*.vercel.app) ;;
+ *)
+ printf 'Vercel did not return a deployment URL: %s\n' "$deployment_url" >&2
+ exit 1
+ ;;
+esac
+
+vercel inspect "$deployment_url" --wait --timeout 10m
+./scripts/validate-docs-release.sh "$source_sha" "$expected_tag"
+vercel promote "$deployment_url" --yes
diff --git a/scripts/docs-assets-sync.test.sh b/scripts/docs-assets-sync.test.sh
new file mode 100755
index 00000000..87f76c23
--- /dev/null
+++ b/scripts/docs-assets-sync.test.sh
@@ -0,0 +1,131 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+sync_script="$script_dir/sync-docs-assets.sh"
+test_root="$(mktemp -d)"
+trap 'rm -rf -- "$test_root"' EXIT INT TERM
+
+export GIT_CONFIG_GLOBAL="$test_root/gitconfig"
+export GIT_CONFIG_NOSYSTEM=1
+unset GIT_DIR GIT_WORK_TREE GIT_INDEX_FILE GIT_OBJECT_DIRECTORY
+unset GIT_ALTERNATE_OBJECT_DIRECTORIES GIT_COMMON_DIR GIT_NAMESPACE
+
+manifest="$test_root/docs-assets.txt"
+ref_file="$test_root/docs-assets.ref"
+cache="$test_root/cache"
+remote="$test_root/remote.git"
+fixture="$test_root/fixture"
+if command -v sha256sum >/dev/null 2>&1; then
+ checksum() { sha256sum "$@"; }
+else
+ checksum() { shasum -a 256 "$@"; }
+fi
+
+fail() {
+ printf 'docs asset sync test failed: %s\n' "$1" >&2
+ exit 1
+}
+
+write_png() {
+ local destination="$1"
+ mkdir -p "$(dirname "$destination")"
+ printf '\211PNG\r\n\032\n' > "$destination"
+ printf 'synthetic png payload\n' >> "$destination"
+}
+
+create_remote() {
+ local mode="$1"
+ rm -rf -- "$remote" "$fixture"
+ git init --bare --quiet "$remote"
+ git init --quiet "$fixture"
+ git -C "$fixture" config user.name "Docbank Test"
+ git -C "$fixture" config user.email "docbank-test@example.invalid"
+ case "$mode" in
+ valid)
+ write_png "$fixture/one.png"
+ ;;
+ extra)
+ write_png "$fixture/one.png"
+ write_png "$fixture/extra.png"
+ ;;
+ malformed)
+ printf 'not a png\n' > "$fixture/one.png"
+ ;;
+ oversized)
+ write_png "$fixture/one.png"
+ dd if=/dev/zero bs=1048576 count=11 >> "$fixture/one.png" 2>/dev/null
+ ;;
+ nested)
+ write_png "$fixture/one.png"
+ write_png "$fixture/nested/two.png"
+ ;;
+ *)
+ fail "unknown fixture mode $mode"
+ ;;
+ esac
+ git -C "$fixture" add .
+ git -C "$fixture" commit --quiet -m "docs assets fixture"
+ fixture_commit="$(git -C "$fixture" rev-parse HEAD)"
+ git -C "$fixture" push --quiet "$remote" "HEAD:refs/heads/docs-assets-candidate"
+ printf '%s\n' "$fixture_commit" > "$ref_file"
+}
+
+run_sync() {
+ DOCBANK_DOCS_ASSETS_REMOTE="$remote" \
+ DOCBANK_DOCS_ASSETS_MANIFEST="$manifest" \
+ DOCBANK_DOCS_ASSETS_REF="$ref_file" \
+ DOCBANK_DOCS_ASSETS_CACHE="$cache" \
+ "$sync_script"
+}
+
+expect_failure() {
+ local label="$1"
+ if run_sync >"$test_root/stdout" 2>"$test_root/stderr"; then
+ fail "$label unexpectedly succeeded"
+ fi
+}
+
+printf 'one.png\n' > "$manifest"
+create_remote valid
+run_sync
+destination="$cache/$fixture_commit"
+test -f "$destination/one.png" || fail "valid PNG was not cached"
+test -f "$destination/.sha256" || fail "checksum record was not cached"
+before_checksum="$(checksum "$destination/one.png")"
+
+printf 'docs-assets-candidate\n' > "$ref_file"
+expect_failure "mutable ref"
+printf '%s\n' "$fixture_commit" > "$ref_file"
+
+printf 'one.png\ntwo.png\n' > "$manifest"
+expect_failure "missing manifest member"
+test "$(checksum "$destination/one.png")" = "$before_checksum" ||
+ fail "failed sync changed the previous cache"
+
+printf 'nested/one.png\n' > "$manifest"
+expect_failure "nested manifest name"
+
+printf 'one.png\none.png\n' > "$manifest"
+expect_failure "duplicate manifest name"
+
+printf 'one.png\n\n' > "$manifest"
+expect_failure "blank manifest entry"
+
+printf 'one.jpg\n' > "$manifest"
+expect_failure "unsupported manifest extension"
+
+printf 'one.png\n' > "$manifest"
+create_remote extra
+expect_failure "unmanifested PNG"
+
+create_remote malformed
+expect_failure "invalid PNG signature"
+
+create_remote oversized
+expect_failure "oversized PNG"
+
+create_remote nested
+expect_failure "nested tree entry"
+
+printf 'docs asset sync tests passed\n'
diff --git a/scripts/docs-assets.ref b/scripts/docs-assets.ref
new file mode 100644
index 00000000..5ae01b3d
--- /dev/null
+++ b/scripts/docs-assets.ref
@@ -0,0 +1 @@
+6491f8221930159600e542f7c0955aed2d1b55b9
diff --git a/scripts/docs-assets.txt b/scripts/docs-assets.txt
new file mode 100644
index 00000000..42c23100
--- /dev/null
+++ b/scripts/docs-assets.txt
@@ -0,0 +1,11 @@
+tui-multi-store-storage.png
+web-audit-evidence.png
+web-multi-store-storage.png
+web-retained-version-download.png
+web-search-results.png
+web-storage-status.png
+web-tag-assignment.png
+web-tag-catalog.png
+web-trash-confirmation.png
+web-trash-restore-confirmation.png
+web-vault-browser.png
diff --git a/scripts/docs/assert-vercel-dry-run.mjs b/scripts/docs/assert-vercel-dry-run.mjs
new file mode 100644
index 00000000..665cb33e
--- /dev/null
+++ b/scripts/docs/assert-vercel-dry-run.mjs
@@ -0,0 +1,141 @@
+import { readFile } from "node:fs/promises";
+import path from "node:path";
+import { pathToFileURL } from "node:url";
+
+
+const requiredUploads = [
+ "vercel.json",
+ "website/index.html",
+ "docs/zensical.toml",
+ "docs/uv.lock",
+ "scripts/vercel-install-docs.sh",
+ "scripts/vercel-build-docs.sh",
+ "scripts/sync-docs-assets.sh",
+ "scripts/docs-assets.ref",
+ "scripts/docs-assets.txt",
+ "scripts/docs/build.mjs",
+ "scripts/docs/verify-site.mjs",
+ "scripts/install.sh",
+ "scripts/install.ps1",
+];
+
+const maximumUploadBytes = 10 * 1024 * 1024;
+
+const allowedUploads = new Set([
+ ...requiredUploads,
+ "LICENSES/Inter-OFL-1.1.txt",
+ "LICENSES/JetBrains-Mono-OFL-1.1.txt",
+ "docs/llms.txt",
+ "docs/overrides/main.html",
+ "docs/pyproject.toml",
+ "docs/scripts/check_built_site.py",
+ "docs/scripts/check_markdown_sources.py",
+ "docs/stylesheets/extra.css",
+ "docs/zensical-docs.sh",
+ "website/favicon.svg",
+ "website/fonts/Inter-Medium.woff2",
+ "website/fonts/Inter-Regular.woff2",
+ "website/fonts/Inter-SemiBold.woff2",
+ "website/fonts/JetBrainsMono-Bold.woff2",
+ "website/fonts/JetBrainsMono-Regular.woff2",
+ "website/fonts/JetBrainsMono-SemiBold.woff2",
+ "website/guide.md",
+ "website/guide/index.html",
+ "website/index.html",
+ "website/index.md",
+ "website/scripts/site.js",
+ "website/styles/site.css",
+]);
+
+const allowedUploadPatterns = [
+ /^docs\/(?!README\.md$)[^/]+\.md$/,
+ /^docs\/(?:agents|architecture|usage)\/[^/]+\.md$/,
+ /^website\/assets\/[a-z0-9][a-z0-9-]*\.svg$/,
+];
+
+
+function normalizeEntry(entry) {
+ const reported = typeof entry === "string" ? entry : entry?.path;
+ if (typeof reported !== "string" || reported.length === 0) {
+ throw new Error("Vercel dry-run entry is missing a path field");
+ }
+ let normalized = reported.replaceAll("\\", "/");
+ while (normalized.startsWith("./")) normalized = normalized.slice(2);
+ if (normalized.startsWith("/") || normalized.split("/").includes("..")) {
+ throw new Error(`Vercel dry-run entry is not repository-relative: ${reported}`);
+ }
+ return normalized;
+}
+
+
+function uploadSize(entry) {
+ if (typeof entry === "string") return 0;
+ if (!Number.isSafeInteger(entry?.size) || entry.size < 0) {
+ throw new Error(`Vercel dry-run entry has an invalid size: ${entry?.path ?? ""}`);
+ }
+ return entry.size;
+}
+
+
+function isAllowed(upload) {
+ return allowedUploads.has(upload)
+ || allowedUploadPatterns.some((pattern) => pattern.test(upload));
+}
+
+
+export function assertUploadBoundary(entries) {
+ if (!Array.isArray(entries)) throw new Error("Vercel dry-run report must be a JSON array");
+ const normalized = entries.map((entry) => ({
+ path: normalizeEntry(entry),
+ size: uploadSize(entry),
+ }));
+ const uploads = new Set(normalized.map((entry) => entry.path));
+ for (const required of requiredUploads) {
+ if (!uploads.has(required)) throw new Error(`missing required upload: ${required}`);
+ }
+ for (const upload of uploads) {
+ if (!isAllowed(upload)) throw new Error(`forbidden upload: ${upload}`);
+ }
+ const totalBytes = normalized.reduce((total, entry) => total + entry.size, 0);
+ if (totalBytes > maximumUploadBytes) {
+ throw new Error(`Vercel upload exceeds 10 MiB limit: ${totalBytes} bytes`);
+ }
+ return [...uploads].sort();
+}
+
+
+export async function readDryRunReport(reportPath) {
+ let report;
+ try {
+ report = JSON.parse(await readFile(reportPath, "utf8"));
+ } catch (error) {
+ throw new Error(`invalid Vercel dry-run JSON: ${error.message}`, { cause: error });
+ }
+ const entries = Array.isArray(report) ? report : report?.files;
+ if (!Array.isArray(entries)) {
+ throw new Error("Vercel dry-run report must be a JSON array or contain a files array");
+ }
+ entries.forEach((entry) => {
+ normalizeEntry(entry);
+ uploadSize(entry);
+ });
+ return entries;
+}
+
+
+const invokedPath = process.argv[1] ? pathToFileURL(path.resolve(process.argv[1])).href : "";
+if (import.meta.url === invokedPath) {
+ const reportPath = process.argv[2];
+ if (!reportPath || process.argv.length !== 3) {
+ process.stderr.write("usage: node scripts/docs/assert-vercel-dry-run.mjs \n");
+ process.exitCode = 2;
+ } else {
+ try {
+ const uploads = assertUploadBoundary(await readDryRunReport(reportPath));
+ process.stdout.write(`validated ${uploads.length} Vercel upload files\n`);
+ } catch (error) {
+ process.stderr.write(`${error.message}\n`);
+ process.exitCode = 1;
+ }
+ }
+}
diff --git a/scripts/docs/assert-vercel-dry-run.test.mjs b/scripts/docs/assert-vercel-dry-run.test.mjs
new file mode 100644
index 00000000..fa035442
--- /dev/null
+++ b/scripts/docs/assert-vercel-dry-run.test.mjs
@@ -0,0 +1,97 @@
+import assert from "node:assert/strict";
+import { mkdtemp, rm, writeFile } from "node:fs/promises";
+import { tmpdir } from "node:os";
+import path from "node:path";
+import test from "node:test";
+
+import { assertUploadBoundary, readDryRunReport } from "./assert-vercel-dry-run.mjs";
+
+
+const required = [
+ "vercel.json",
+ "website/index.html",
+ "docs/zensical.toml",
+ "docs/uv.lock",
+ "scripts/vercel-install-docs.sh",
+ "scripts/vercel-build-docs.sh",
+ "scripts/sync-docs-assets.sh",
+ "scripts/docs-assets.ref",
+ "scripts/docs-assets.txt",
+ "scripts/docs/build.mjs",
+ "scripts/docs/verify-site.mjs",
+ "scripts/install.sh",
+ "scripts/install.ps1",
+];
+
+
+test("accepts the complete static documentation build input", () => {
+ assert.doesNotThrow(() => assertUploadBoundary(required));
+});
+
+
+test("normalizes CLI path objects", () => {
+ const report = required.map((filePath, index) => ({
+ path: index % 2 === 0 ? `./${filePath}` : filePath.replaceAll("/", "\\"),
+ size: 10,
+ }));
+ assert.doesNotThrow(() => assertUploadBoundary(report));
+});
+
+
+test("reads the Vercel CLI report envelope", async (t) => {
+ const directory = await mkdtemp(path.join(tmpdir(), "docbank-vercel-report-"));
+ t.after(() => rm(directory, { recursive: true, force: true }));
+ const report = path.join(directory, "report.json");
+ const files = required.map((entry) => ({ path: entry, size: 10 }));
+ await writeFile(report, JSON.stringify({ fileCount: required.length, files }));
+ assert.deepEqual(await readDryRunReport(report), files);
+});
+
+
+test("rejects a missing build input", () => {
+ assert.throws(
+ () => assertUploadBoundary(required.filter((filePath) => filePath !== "vercel.json")),
+ /missing required upload: vercel\.json/,
+ );
+});
+
+
+for (const forbidden of [
+ "payload-1gb.bin",
+ "go.mod",
+ "deploy/private-data",
+ "cmd/docbank/main.go",
+ "internal/store/store.go",
+ "frontend/package.json",
+ ".superpowers/plan.md",
+ "docs/superpowers/specs/plan.md",
+ ".git/config",
+ ".env.local",
+ "docs/.env",
+ "AGENTS.md",
+ "docs/README.md",
+]) {
+ test(`rejects forbidden upload ${forbidden}`, () => {
+ assert.throws(() => assertUploadBoundary([...required, forbidden]), /forbidden upload/);
+ });
+}
+
+
+test("rejects an oversized upload within an approved source path", () => {
+ const report = required.map((filePath) => ({ path: filePath, size: 10 }));
+ report.push({ path: "docs/architecture/oversized.md", size: 10 * 1024 * 1024 });
+ assert.throws(() => assertUploadBoundary(report), /upload exceeds 10 MiB limit/);
+});
+
+
+test("rejects malformed reports instead of treating them as empty", async (t) => {
+ const directory = await mkdtemp(path.join(tmpdir(), "docbank-vercel-report-"));
+ t.after(() => rm(directory, { recursive: true, force: true }));
+ const report = path.join(directory, "report.json");
+ await writeFile(report, '{"path":"website/index.html"}\n');
+ await assert.rejects(() => readDryRunReport(report), /files array/);
+ await writeFile(report, '{"files":[{"size":10}]}\n');
+ await assert.rejects(() => readDryRunReport(report), /path field/);
+ await writeFile(report, '{"files":[{"path":"vercel.json"}]}\n');
+ await assert.rejects(() => readDryRunReport(report), /invalid size/);
+});
diff --git a/scripts/docs/build.mjs b/scripts/docs/build.mjs
new file mode 100644
index 00000000..3ccc5e4d
--- /dev/null
+++ b/scripts/docs/build.mjs
@@ -0,0 +1,203 @@
+import { execFile } from "node:child_process";
+import {
+ copyFile,
+ lstat,
+ mkdir,
+ readFile,
+ readdir,
+ rename,
+ rm,
+ writeFile,
+} from "node:fs/promises";
+import path from "node:path";
+import { fileURLToPath, pathToFileURL } from "node:url";
+import { promisify } from "node:util";
+
+import { verifySite } from "./verify-site.mjs";
+
+
+const execFileAsync = promisify(execFile);
+const outputMarker = ".docbank-docs-output";
+const outputMarkerContents = "generated by docbank docs build\n";
+const websiteFiles = ["favicon.svg", "guide.md", "index.html", "index.md"];
+const websiteDirectories = ["assets", "fonts", "guide", "scripts", "styles"];
+
+
+async function exists(target) {
+ try {
+ await lstat(target);
+ return true;
+ } catch (error) {
+ if (error?.code === "ENOENT") return false;
+ throw error;
+ }
+}
+
+
+async function copyPublicPath(source, destination, relative) {
+ const metadata = await lstat(source);
+ if (metadata.isSymbolicLink()) {
+ throw new Error(`website publishing boundary contains symlink: ${relative}`);
+ }
+ if (relative.split("/").some((part) => part.startsWith("."))) {
+ throw new Error(`website publishing boundary contains dot path: ${relative}`);
+ }
+ if (metadata.isDirectory()) {
+ await mkdir(destination, { recursive: true });
+ for (const entry of await readdir(source)) {
+ await copyPublicPath(
+ path.join(source, entry),
+ path.join(destination, entry),
+ `${relative}/${entry}`,
+ );
+ }
+ return;
+ }
+ if (!metadata.isFile()) {
+ throw new Error(`unsupported website source: ${relative}`);
+ }
+ await mkdir(path.dirname(destination), { recursive: true });
+ await copyFile(source, destination);
+}
+
+
+async function copyWebsite(repoRoot, staging) {
+ const website = path.join(repoRoot, "website");
+ for (const relative of websiteFiles) {
+ const source = path.join(website, relative);
+ if (!(await exists(source))) throw new Error(`missing website source: ${relative}`);
+ await copyPublicPath(source, path.join(staging, relative), relative);
+ }
+ for (const relative of websiteDirectories) {
+ const source = path.join(website, relative);
+ if (await exists(source)) {
+ await copyPublicPath(source, path.join(staging, relative), relative);
+ }
+ }
+}
+
+
+async function defaultBuildDocs({ repoRoot, output }) {
+ await execFileAsync(path.join(repoRoot, "docs", "zensical-docs.sh"), ["build"], {
+ cwd: path.join(repoRoot, "docs"),
+ env: {
+ ...process.env,
+ DOCBANK_DOCS_SITE_DIR: output,
+ },
+ maxBuffer: 10 * 1024 * 1024,
+ });
+}
+
+
+async function defaultSyncAssets({ repoRoot }) {
+ await execFileAsync(path.join(repoRoot, "scripts", "sync-docs-assets.sh"), [], {
+ cwd: repoRoot,
+ maxBuffer: 10 * 1024 * 1024,
+ });
+}
+
+
+async function readManifest(repoRoot) {
+ const contents = await readFile(path.join(repoRoot, "scripts", "docs-assets.txt"), "utf8");
+ const names = contents.split("\n").filter((line) => line !== "");
+ if (names.length === 0) throw new Error("docs asset manifest is empty");
+ return names;
+}
+
+
+async function copyGeneratedAssets(repoRoot, staging, names) {
+ const ref = (await readFile(path.join(repoRoot, "scripts", "docs-assets.ref"), "utf8")).trim();
+ const source = path.join(repoRoot, ".cache", "docs-assets", ref);
+ const destination = path.join(staging, "assets", "generated");
+ await mkdir(destination, { recursive: true });
+ for (const name of names) {
+ await copyFile(path.join(source, name), path.join(destination, name));
+ }
+}
+
+
+async function replaceOutput({ output, staging }) {
+ const previous = path.join(path.dirname(output), `.${path.basename(output)}.previous`);
+ await rm(previous, { recursive: true, force: true });
+ const hadOutput = await exists(output);
+ if (hadOutput) await rename(output, previous);
+ try {
+ await rename(staging, output);
+ } catch (error) {
+ if (hadOutput) await rename(previous, output);
+ throw error;
+ }
+ await rm(previous, { recursive: true, force: true });
+}
+
+
+export async function buildSite({
+ repoRoot,
+ output = path.join(repoRoot, "site"),
+ buildDocs = defaultBuildDocs,
+ syncAssets = defaultSyncAssets,
+}) {
+ const resolvedOutput = path.resolve(output);
+ if (await exists(resolvedOutput)) {
+ const marker = path.join(resolvedOutput, outputMarker);
+ if (!(await exists(marker)) || (await readFile(marker, "utf8")) !== outputMarkerContents) {
+ throw new Error(`refusing to replace unmarked site output: ${resolvedOutput}`);
+ }
+ }
+
+ await mkdir(path.dirname(resolvedOutput), { recursive: true });
+ const staging = path.join(
+ path.dirname(resolvedOutput),
+ `.${path.basename(resolvedOutput)}.next.${process.pid}`,
+ );
+ await rm(staging, { recursive: true, force: true });
+ await mkdir(staging);
+ try {
+ await copyWebsite(repoRoot, staging);
+ await copyFile(path.join(repoRoot, "scripts", "install.sh"), path.join(staging, "install.sh"));
+ await copyFile(path.join(repoRoot, "scripts", "install.ps1"), path.join(staging, "install.ps1"));
+ await syncAssets({ repoRoot });
+ const assets = await readManifest(repoRoot);
+ await copyGeneratedAssets(repoRoot, staging, assets);
+
+ const docsOutput = path.join(staging, "docs");
+ await buildDocs({ repoRoot, output: docsOutput });
+ await rm(path.join(docsOutput, "llms.txt"), { force: true });
+ await rm(path.join(docsOutput, "install.sh"), { force: true });
+ await rm(path.join(docsOutput, "install.ps1"), { force: true });
+ await rm(path.join(docsOutput, outputMarker), { force: true });
+ await copyFile(path.join(repoRoot, "docs", "llms.txt"), path.join(staging, "llms.txt"));
+
+ await verifySite({
+ site: staging,
+ sources: {
+ website: path.join(repoRoot, "website"),
+ docs: path.join(repoRoot, "docs"),
+ assets,
+ },
+ });
+ await writeFile(path.join(staging, outputMarker), outputMarkerContents);
+ await replaceOutput({ output: resolvedOutput, staging });
+ return resolvedOutput;
+ } catch (error) {
+ await rm(staging, { recursive: true, force: true });
+ throw error;
+ }
+}
+
+
+function outputArgument(argv) {
+ const index = argv.indexOf("--output");
+ if (index === -1) return undefined;
+ if (!argv[index + 1]) throw new Error("--output requires a directory");
+ return argv[index + 1];
+}
+
+
+const invokedPath = process.argv[1] ? pathToFileURL(path.resolve(process.argv[1])).href : "";
+if (import.meta.url === invokedPath) {
+ const here = path.dirname(fileURLToPath(import.meta.url));
+ const repoRoot = path.resolve(here, "..", "..");
+ const output = await buildSite({ repoRoot, output: outputArgument(process.argv.slice(2)) });
+ process.stdout.write(`built documentation site at ${output}\n`);
+}
diff --git a/scripts/docs/build.test.mjs b/scripts/docs/build.test.mjs
new file mode 100644
index 00000000..da8e534e
--- /dev/null
+++ b/scripts/docs/build.test.mjs
@@ -0,0 +1,127 @@
+import assert from "node:assert/strict";
+import { access, mkdtemp, mkdir, readFile, rm, writeFile } from "node:fs/promises";
+import { tmpdir } from "node:os";
+import path from "node:path";
+import test from "node:test";
+
+import { buildSite } from "./build.mjs";
+
+
+async function write(root, relative, contents) {
+ const destination = path.join(root, relative);
+ await mkdir(path.dirname(destination), { recursive: true });
+ await writeFile(destination, contents);
+}
+
+
+function html(canonical, link = "") {
+ return `Docbank
+
+
+
+
+
+
+Docbank ${link} `;
+}
+
+
+async function repositoryFixture(t) {
+ const repoRoot = await mkdtemp(path.join(tmpdir(), "docbank-site-build-"));
+ t.after(() => rm(repoRoot, { recursive: true, force: true }));
+ await write(repoRoot, "website/index.html", html("https://docbank.ai/", 'Guide '));
+ await write(repoRoot, "website/index.md", "# Product\n");
+ await write(repoRoot, "website/guide/index.html", html("https://docbank.ai/guide/", 'Docs '));
+ await write(repoRoot, "website/guide.md", "# Guide\n");
+ await write(repoRoot, "website/styles/site.css", "body { color: white; }\n");
+ await write(repoRoot, "website/scripts/site.js", "export {};\n");
+ await write(repoRoot, "website/favicon.svg", ' \n');
+ await write(repoRoot, "scripts/install.sh", "#!/bin/sh\n");
+ await write(repoRoot, "scripts/install.ps1", "# installer\n");
+ await write(repoRoot, "scripts/docs-assets.txt", "capture.png\n");
+ await write(repoRoot, "scripts/docs-assets.ref", `${"a".repeat(40)}\n`);
+ await write(repoRoot, "docs/index.md", "# Docs\n");
+ await write(repoRoot, "docs/setup.md", "# Setup\n");
+ await write(
+ repoRoot,
+ "docs/llms.txt",
+ [
+ "# Docbank",
+ "- [Product](https://docbank.ai/index.md)",
+ "- [Guide](https://docbank.ai/guide.md)",
+ "- [Docs](https://docbank.ai/docs/index.md)",
+ "- [Setup](https://docbank.ai/docs/setup.md)",
+ "",
+ ].join("\n"),
+ );
+ return repoRoot;
+}
+
+
+async function syncAssetsFixture({ repoRoot }) {
+ await write(repoRoot, `.cache/docs-assets/${"a".repeat(40)}/capture.png`, "png fixture");
+}
+
+
+async function buildDocsFixture({ repoRoot, output }) {
+ await write(output, "index.html", html("https://docbank.ai/docs/", 'Setup '));
+ await write(output, "setup/index.html", html("https://docbank.ai/docs/setup/", 'Docs '));
+ await write(output, "index.md", await readFile(path.join(repoRoot, "docs/index.md")));
+ await write(output, "setup.md", await readFile(path.join(repoRoot, "docs/setup.md")));
+ await write(output, "llms.txt", "redundant docs index\n");
+ await write(output, "install.sh", "redundant installer\n");
+}
+
+
+test("assembles all tiers and public assets into one site root", async (t) => {
+ const repoRoot = await repositoryFixture(t);
+ const output = path.join(repoRoot, "site");
+
+ await buildSite({ repoRoot, output, buildDocs: buildDocsFixture, syncAssets: syncAssetsFixture });
+
+ for (const relative of [
+ "index.html",
+ "index.md",
+ "guide/index.html",
+ "guide.md",
+ "docs/index.html",
+ "docs/index.md",
+ "docs/setup/index.html",
+ "docs/setup.md",
+ "assets/generated/capture.png",
+ "install.sh",
+ "install.ps1",
+ "llms.txt",
+ ]) {
+ await access(path.join(output, relative));
+ }
+ await assert.rejects(access(path.join(output, "docs/llms.txt")));
+ await assert.rejects(access(path.join(output, "docs/install.sh")));
+});
+
+
+test("fails before assembling when the pinned assets are not synced", async (t) => {
+ const repoRoot = await repositoryFixture(t);
+ const output = path.join(repoRoot, "site");
+
+ await assert.rejects(
+ () => buildSite({ repoRoot, output, buildDocs: buildDocsFixture, syncAssets: async () => {} }),
+ { code: "ENOENT" },
+ );
+ await assert.rejects(access(output));
+});
+
+
+test("keeps the previous marked output when verification fails", async (t) => {
+ const repoRoot = await repositoryFixture(t);
+ const output = path.join(repoRoot, "site");
+ await write(output, ".docbank-docs-output", "generated by docbank docs build\n");
+ await write(output, "sentinel.txt", "previous output\n");
+ await write(repoRoot, "website/index.html", html("https://docbank.ai/", 'Missing '));
+
+ await assert.rejects(
+ () => buildSite({ repoRoot, output, buildDocs: buildDocsFixture, syncAssets: syncAssetsFixture }),
+ /broken local URL/,
+ );
+ assert.equal(await readFile(path.join(output, "sentinel.txt"), "utf8"), "previous output\n");
+});
diff --git a/scripts/docs/serve.mjs b/scripts/docs/serve.mjs
new file mode 100644
index 00000000..eaac8638
--- /dev/null
+++ b/scripts/docs/serve.mjs
@@ -0,0 +1,252 @@
+import { createReadStream, watch as watchPath } from "node:fs";
+import { lstat } from "node:fs/promises";
+import http from "node:http";
+import path from "node:path";
+import { fileURLToPath, pathToFileURL } from "node:url";
+
+import { buildSite } from "./build.mjs";
+
+
+const contentTypes = new Map([
+ [".css", "text/css; charset=utf-8"],
+ [".html", "text/html; charset=utf-8"],
+ [".js", "text/javascript; charset=utf-8"],
+ [".json", "application/json; charset=utf-8"],
+ [".md", "text/markdown; charset=utf-8"],
+ [".png", "image/png"],
+ [".ps1", "text/plain; charset=utf-8"],
+ [".sh", "text/x-shellscript; charset=utf-8"],
+ [".svg", "image/svg+xml"],
+ [".txt", "text/plain; charset=utf-8"],
+ [".woff2", "font/woff2"],
+ [".xml", "application/xml; charset=utf-8"],
+]);
+
+
+export function createRebuildQueue({ build, debounceMs = 100, onError = console.error }) {
+ let timer;
+ let running = false;
+ let pending = false;
+ let closed = false;
+ const waiters = new Set();
+
+ function notify() {
+ for (const waiter of waiters) waiter();
+ }
+
+ function schedule(delay = debounceMs) {
+ if (timer) clearTimeout(timer);
+ timer = setTimeout(run, delay);
+ }
+
+ async function run() {
+ timer = undefined;
+ if (closed) {
+ notify();
+ return;
+ }
+ running = true;
+ notify();
+ try {
+ await build();
+ } catch (error) {
+ onError(error);
+ } finally {
+ running = false;
+ if (pending && !closed) {
+ pending = false;
+ schedule(0);
+ }
+ notify();
+ }
+ }
+
+ return {
+ request() {
+ if (closed) return;
+ if (running) {
+ pending = true;
+ return;
+ }
+ schedule();
+ },
+
+ idle({ waitForRunning = true } = {}) {
+ return new Promise((resolve) => {
+ const check = () => {
+ const settled = waitForRunning
+ ? !timer && !running && !pending
+ : running || (!timer && !pending);
+ if (!settled) return;
+ waiters.delete(check);
+ resolve();
+ };
+ waiters.add(check);
+ check();
+ });
+ },
+
+ close() {
+ closed = true;
+ pending = false;
+ if (timer) clearTimeout(timer);
+ timer = undefined;
+ notify();
+ },
+ };
+}
+
+
+function resolveRequest(siteRoot, rawUrl) {
+ const rawPath = rawUrl.split(/[?#]/, 1)[0];
+ let decoded;
+ try {
+ decoded = decodeURIComponent(rawPath);
+ } catch {
+ return undefined;
+ }
+ if (!decoded.startsWith("/") || decoded.includes("\0") || decoded.includes("\\")) {
+ return undefined;
+ }
+ if (decoded.split("/").includes("..")) return undefined;
+
+ const route = decoded.endsWith("/") ? `${decoded}index.html` : decoded;
+ const target = path.resolve(siteRoot, `.${route}`);
+ const prefix = `${path.resolve(siteRoot)}${path.sep}`;
+ if (!target.startsWith(prefix)) return undefined;
+ return target;
+}
+
+
+async function serveFile(siteRoot, request, response) {
+ if (request.method !== "GET" && request.method !== "HEAD") {
+ response.writeHead(405, { Allow: "GET, HEAD" });
+ response.end();
+ return;
+ }
+
+ const target = resolveRequest(siteRoot, request.url ?? "/");
+ if (!target) {
+ response.writeHead(404);
+ response.end("Not found\n");
+ return;
+ }
+
+ let metadata;
+ try {
+ metadata = await lstat(target);
+ } catch (error) {
+ if (error?.code === "ENOENT" || error?.code === "ENOTDIR") {
+ response.writeHead(404);
+ response.end("Not found\n");
+ return;
+ }
+ throw error;
+ }
+ if (!metadata.isFile() || metadata.isSymbolicLink()) {
+ response.writeHead(404);
+ response.end("Not found\n");
+ return;
+ }
+
+ response.writeHead(200, {
+ "Cache-Control": "no-store",
+ "Content-Length": metadata.size,
+ "Content-Type": contentTypes.get(path.extname(target)) ?? "application/octet-stream",
+ });
+ if (request.method === "HEAD") {
+ response.end();
+ return;
+ }
+ createReadStream(target).pipe(response);
+}
+
+
+function listen(server, host, port) {
+ return new Promise((resolve, reject) => {
+ server.once("error", reject);
+ server.listen(port, host, () => {
+ server.off("error", reject);
+ resolve();
+ });
+ });
+}
+
+
+export async function startDocsServer({
+ repoRoot,
+ host = "127.0.0.1",
+ port = 8000,
+ build = ({ output }) => buildSite({ repoRoot, output }),
+ watch = true,
+ watchFactory = watchPath,
+ logger = console,
+}) {
+ const siteRoot = path.join(repoRoot, "site");
+ await build({ output: siteRoot });
+
+ const queue = createRebuildQueue({
+ build: async () => {
+ try {
+ await build({ output: siteRoot });
+ logger.log("documentation rebuilt");
+ } catch (error) {
+ logger.error("documentation rebuild failed; serving the previous build");
+ throw error;
+ }
+ },
+ onError: (error) => logger.error(error),
+ });
+
+ const watchers = [];
+ if (watch) {
+ for (const [source, recursive] of [
+ [path.join(repoRoot, "website"), true],
+ [path.join(repoRoot, "docs"), true],
+ [path.join(repoRoot, "scripts", "docs-assets.ref"), false],
+ [path.join(repoRoot, "scripts", "docs-assets.txt"), false],
+ ]) {
+ const watcher = watchFactory(source, { recursive }, () => queue.request());
+ watcher.on?.("error", (error) => logger.error(error));
+ watchers.push(watcher);
+ }
+ }
+
+ const server = http.createServer((request, response) => {
+ serveFile(siteRoot, request, response).catch((error) => {
+ logger.error(error);
+ if (!response.headersSent) response.writeHead(500);
+ response.end("Internal server error\n");
+ });
+ });
+ await listen(server, host, port);
+ const address = server.address();
+ if (!address || typeof address === "string") throw new Error("documentation server has no TCP address");
+ const url = `http://${host}:${address.port}/`;
+
+ return {
+ url,
+ port: address.port,
+ close() {
+ queue.close();
+ for (const watcher of watchers) watcher.close();
+ return new Promise((resolve, reject) => {
+ server.close((error) => error ? reject(error) : resolve());
+ });
+ },
+ };
+}
+
+
+const invokedPath = process.argv[1] ? pathToFileURL(path.resolve(process.argv[1])).href : "";
+if (import.meta.url === invokedPath) {
+ const here = path.dirname(fileURLToPath(import.meta.url));
+ const repoRoot = path.resolve(here, "..", "..");
+ const configuredPort = process.env.DOCBANK_DOCS_PORT;
+ const port = configuredPort === undefined ? 8000 : Number(configuredPort);
+ if (!Number.isInteger(port) || port < 0 || port > 65_535) {
+ throw new Error("DOCBANK_DOCS_PORT must be an integer from 0 through 65535");
+ }
+ const server = await startDocsServer({ repoRoot, port });
+ process.stdout.write(`serving documentation at ${server.url}\n`);
+}
diff --git a/scripts/docs/serve.test.mjs b/scripts/docs/serve.test.mjs
new file mode 100644
index 00000000..f07bd591
--- /dev/null
+++ b/scripts/docs/serve.test.mjs
@@ -0,0 +1,149 @@
+import assert from "node:assert/strict";
+import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
+import http from "node:http";
+import { tmpdir } from "node:os";
+import path from "node:path";
+import test from "node:test";
+
+import { createRebuildQueue, startDocsServer } from "./serve.mjs";
+
+
+async function write(root, relative, contents) {
+ const destination = path.join(root, relative);
+ await mkdir(path.dirname(destination), { recursive: true });
+ await writeFile(destination, contents);
+}
+
+
+async function fixture(t) {
+ const repoRoot = await mkdtemp(path.join(tmpdir(), "docbank-docs-server-"));
+ t.after(() => rm(repoRoot, { recursive: true, force: true }));
+ for (const [relative, contents] of Object.entries({
+ "site/index.html": "Product ",
+ "site/guide/index.html": "Guide ",
+ "site/guide.md": "# Guide\n",
+ "site/docs/index.html": "Docs ",
+ "site/docs/setup/index.html": "Setup ",
+ "site/docs/setup.md": "# Setup\n",
+ "site/styles/site.css": "body { color: white; }\n",
+ "site/fonts/Inter-Regular.woff2": "font fixture",
+ "site/assets/generated/capture.png": "png fixture",
+ })) {
+ await write(repoRoot, relative, contents);
+ }
+ return repoRoot;
+}
+
+
+function rawRequest(port, requestPath) {
+ return new Promise((resolve, reject) => {
+ const request = http.get({ host: "127.0.0.1", port, path: requestPath }, (response) => {
+ response.resume();
+ response.once("end", () => resolve(response.statusCode));
+ });
+ request.once("error", reject);
+ });
+}
+
+
+test("serves every public tier and asset type from one origin", async (t) => {
+ const repoRoot = await fixture(t);
+ const server = await startDocsServer({
+ repoRoot,
+ host: "127.0.0.1",
+ port: 0,
+ build: async () => {},
+ watch: false,
+ });
+ t.after(() => server.close());
+
+ for (const [route, contentType, body] of [
+ ["/", "text/html; charset=utf-8", "Product"],
+ ["/guide/", "text/html; charset=utf-8", "Guide"],
+ ["/guide.md", "text/markdown; charset=utf-8", "# Guide"],
+ ["/docs/", "text/html; charset=utf-8", "Docs"],
+ ["/docs/setup/", "text/html; charset=utf-8", "Setup"],
+ ["/docs/setup.md", "text/markdown; charset=utf-8", "# Setup"],
+ ["/styles/site.css", "text/css; charset=utf-8", "body"],
+ ["/fonts/Inter-Regular.woff2", "font/woff2", "font fixture"],
+ ["/assets/generated/capture.png", "image/png", "png fixture"],
+ ]) {
+ const response = await fetch(new URL(route, server.url));
+ assert.equal(response.status, 200, route);
+ assert.equal(response.headers.get("content-type"), contentType, route);
+ assert.match(await response.text(), new RegExp(body), route);
+ }
+});
+
+
+test("rejects traversal outside the generated site", async (t) => {
+ const repoRoot = await fixture(t);
+ await write(repoRoot, "AGENTS.md", "private instructions\n");
+ const server = await startDocsServer({
+ repoRoot,
+ host: "127.0.0.1",
+ port: 0,
+ build: async () => {},
+ watch: false,
+ });
+ t.after(() => server.close());
+
+ assert.equal(await rawRequest(server.port, "/../AGENTS.md"), 404);
+ assert.equal(await rawRequest(server.port, "/%2e%2e/AGENTS.md"), 404);
+ assert.equal(await rawRequest(server.port, "/%00"), 404);
+});
+
+
+test("coalesces file events into one queued rebuild", async () => {
+ let calls = 0;
+ let finishFirst;
+ const firstBuild = new Promise((resolve) => {
+ finishFirst = resolve;
+ });
+ const errors = [];
+ const queue = createRebuildQueue({
+ debounceMs: 5,
+ build: async () => {
+ calls += 1;
+ if (calls === 1) await firstBuild;
+ },
+ onError: (error) => errors.push(error),
+ });
+
+ queue.request();
+ await queue.idle({ waitForRunning: false });
+ assert.equal(calls, 1);
+
+ queue.request();
+ queue.request();
+ queue.request();
+ finishFirst();
+ await queue.idle();
+
+ assert.equal(calls, 2);
+ assert.deepEqual(errors, []);
+ queue.close();
+});
+
+
+test("keeps the queue usable after a failed rebuild", async () => {
+ let calls = 0;
+ const errors = [];
+ const queue = createRebuildQueue({
+ debounceMs: 1,
+ build: async () => {
+ calls += 1;
+ if (calls === 1) throw new Error("fixture build failed");
+ },
+ onError: (error) => errors.push(error.message),
+ });
+
+ queue.request();
+ await queue.idle();
+ queue.request();
+ await queue.idle();
+
+ assert.equal(calls, 2);
+ assert.deepEqual(errors, ["fixture build failed"]);
+ queue.close();
+});
diff --git a/scripts/docs/verify-site.mjs b/scripts/docs/verify-site.mjs
new file mode 100644
index 00000000..4fc68d51
--- /dev/null
+++ b/scripts/docs/verify-site.mjs
@@ -0,0 +1,278 @@
+import { lstat, readFile, readdir } from "node:fs/promises";
+import path from "node:path";
+
+
+const canonicalOrigin = "https://docbank.ai";
+const forbiddenParts = new Set([
+ ".git",
+ ".superpowers",
+ "internal",
+ "overrides",
+ "reports",
+ "superpowers",
+]);
+const requiredMetadata = [
+ "description",
+ "og:type",
+ "og:title",
+ "og:description",
+ "og:url",
+ "og:site_name",
+ "twitter:card",
+ "twitter:title",
+ "twitter:description",
+];
+
+
+async function filesUnder(root) {
+ const files = [];
+ async function visit(directory, prefix = "") {
+ for (const entry of await readdir(directory, { withFileTypes: true })) {
+ const relative = prefix ? `${prefix}/${entry.name}` : entry.name;
+ const absolute = path.join(directory, entry.name);
+ const metadata = await lstat(absolute);
+ if (metadata.isSymbolicLink()) {
+ throw new Error(`publishing boundary contains symlink: ${relative}`);
+ }
+ if (metadata.isDirectory()) await visit(absolute, relative);
+ else if (metadata.isFile()) files.push(relative);
+ }
+ }
+ await visit(root);
+ return files.sort();
+}
+
+
+function attributes(raw) {
+ const result = new Map();
+ const expression = /([A-Za-z_:][A-Za-z0-9_.:-]*)\s*=\s*(?:"([^"]*)"|'([^']*)')/g;
+ for (const match of raw.matchAll(expression)) {
+ result.set(match[1].toLowerCase(), match[2] ?? match[3] ?? "");
+ }
+ return result;
+}
+
+
+function parsePage(contents) {
+ const metadata = new Map();
+ const urls = [];
+ const images = [];
+ const anchors = new Set();
+ let canonical = "";
+ for (const match of contents.matchAll(/<([A-Za-z0-9]+)\b([^>]*)>/g)) {
+ const tag = match[1].toLowerCase();
+ const attrs = attributes(match[2]);
+ if (attrs.get("id")) anchors.add(attrs.get("id"));
+ if (tag === "a" && attrs.get("name")) anchors.add(attrs.get("name"));
+ if (tag === "link" && attrs.get("rel") === "canonical") {
+ canonical = attrs.get("href") ?? "";
+ }
+ if (tag === "meta" && attrs.get("content")) {
+ const key = attrs.get("property") ?? attrs.get("name");
+ if (key) metadata.set(key, attrs.get("content"));
+ }
+ for (const key of ["href", "src"]) {
+ if (attrs.get(key)) urls.push(attrs.get(key));
+ }
+ if (tag === "img" && attrs.get("src")) images.push(attrs.get("src"));
+ }
+ return {
+ anchors,
+ canonical,
+ images,
+ metadata,
+ title: /]*)?>\s*[^<\s][^<]*<\/title>/i.test(contents),
+ urls,
+ };
+}
+
+
+function routeForHtml(relative) {
+ if (relative === "index.html") return "/";
+ return `/${relative.replace(/index\.html$/, "")}`;
+}
+
+
+function markdownPeer(relative) {
+ if (relative === "index.html") return "index.md";
+ if (relative === "guide/index.html") return "guide.md";
+ if (relative === "docs/index.html") return "docs/index.md";
+ if (!relative.endsWith("/index.html")) return null;
+ return relative.replace(/\/index\.html$/, ".md");
+}
+
+
+function htmlForMarkdown(relative) {
+ if (relative === "index.md") return "index.html";
+ if (relative === "guide.md") return "guide/index.html";
+ if (relative === "docs/index.md") return "docs/index.html";
+ return relative.replace(/\.md$/, "/index.html");
+}
+
+
+function targetForUrl(site, page, raw) {
+ if (/^(?:mailto|tel|data|javascript):/i.test(raw)) return null;
+ const base = `${canonicalOrigin}${routeForHtml(page)}`;
+ let resolved;
+ try {
+ resolved = new URL(raw, base);
+ } catch {
+ return { error: "invalid" };
+ }
+ if (resolved.origin !== canonicalOrigin) return null;
+ let relative = decodeURIComponent(resolved.pathname).replace(/^\//, "");
+ if (relative === "" || relative.endsWith("/")) relative += "index.html";
+ else if (path.posix.extname(relative) === "") relative += "/index.html";
+ return {
+ absolute: path.join(site, ...relative.split("/")),
+ fragment: decodeURIComponent(resolved.hash.replace(/^#/, "")),
+ relative,
+ };
+}
+
+
+async function compareSource(site, relative, source, errors) {
+ try {
+ const [published, original] = await Promise.all([
+ readFile(path.join(site, ...relative.split("/"))),
+ readFile(source),
+ ]);
+ if (!published.equals(original)) {
+ errors.push(`${relative}: published Markdown differs from source`);
+ }
+ } catch (error) {
+ if (error?.code === "ENOENT") {
+ errors.push(`${relative}: Markdown source is missing`);
+ return;
+ }
+ throw error;
+ }
+}
+
+
+export async function verifySite({ site, sources }) {
+ const errors = [];
+ const files = await filesUnder(site);
+ const fileSet = new Set(files);
+ for (const relative of files) {
+ const parts = relative.split("/");
+ if (parts.some((part) => forbiddenParts.has(part))) {
+ errors.push(`publishing boundary leaked ${relative}`);
+ }
+ }
+
+ const htmlFiles = files.filter((relative) => relative.endsWith(".html"));
+ if (htmlFiles.length === 0) errors.push("no HTML pages were built");
+ const parsedPages = new Map();
+ for (const relative of htmlFiles) {
+ const contents = await readFile(path.join(site, ...relative.split("/")), "utf8");
+ const parsed = parsePage(contents);
+ parsedPages.set(relative, parsed);
+ if (!parsed.title) errors.push(`${relative}: missing title`);
+ for (const key of requiredMetadata) {
+ if (!parsed.metadata.get(key)) errors.push(`${relative}: missing ${key} metadata`);
+ }
+ if (!relative.endsWith("404.html")) {
+ const expectedCanonical = `${canonicalOrigin}${routeForHtml(relative)}`;
+ if (parsed.canonical !== expectedCanonical) {
+ errors.push(`${relative}: canonical is ${parsed.canonical || "missing"}; expected ${expectedCanonical}`);
+ }
+ if (parsed.metadata.get("og:url") !== expectedCanonical) {
+ errors.push(`${relative}: og:url does not match its route`);
+ }
+ }
+ if (/fonts\.(?:googleapis|gstatic)\.com/i.test(contents)) {
+ errors.push(`${relative}: remote font URL`);
+ }
+ }
+
+ for (const relative of htmlFiles) {
+ const parsed = parsedPages.get(relative);
+ for (const raw of parsed.images) {
+ if (targetForUrl(site, relative, raw) === null) {
+ errors.push(`${relative}: image is not served from the site: ${raw}`);
+ }
+ }
+ for (const raw of parsed.urls) {
+ const target = targetForUrl(site, relative, raw);
+ if (target === null) continue;
+ if (target.error || !fileSet.has(target.relative)) {
+ errors.push(`${relative}: broken local URL ${raw}`);
+ continue;
+ }
+ if (
+ target.fragment &&
+ target.fragment !== "__skip" &&
+ target.relative.endsWith(".html")
+ ) {
+ const destination = parsedPages.get(target.relative);
+ if (destination && !destination.anchors.has(target.fragment)) {
+ errors.push(`${relative}: broken local fragment ${raw}`);
+ }
+ }
+ }
+ }
+
+ const markdownFiles = files.filter((relative) => relative.endsWith(".md"));
+ for (const relative of htmlFiles) {
+ if (relative.endsWith("404.html")) continue;
+ const peer = markdownPeer(relative);
+ if (peer === null) {
+ errors.push(`${relative}: unsupported substantive HTML route`);
+ continue;
+ }
+ if (!fileSet.has(peer)) errors.push(`${relative}: missing Markdown peer ${peer}`);
+ }
+ for (const relative of markdownFiles) {
+ const rendered = htmlForMarkdown(relative);
+ if (!fileSet.has(rendered)) errors.push(`${relative}: missing HTML route ${rendered}`);
+ if (relative === "index.md" || relative === "guide.md") {
+ await compareSource(site, relative, path.join(sources.website, relative), errors);
+ } else if (relative.startsWith("docs/")) {
+ const sourceRelative = relative.slice("docs/".length);
+ await compareSource(site, relative, path.join(sources.docs, sourceRelative), errors);
+ }
+ }
+
+ if (!fileSet.has("llms.txt")) {
+ errors.push("llms.txt: missing from site root");
+ } else {
+ const llms = await readFile(path.join(site, "llms.txt"), "utf8");
+ const indexed = new Set(
+ [...llms.matchAll(/https:\/\/docbank\.ai(\/[^)\s]+\.md)/g)].map(
+ (match) => match[1],
+ ),
+ );
+ for (const relative of markdownFiles) {
+ const route = `/${relative}`;
+ if (!indexed.has(route)) errors.push(`llms.txt: missing ${route}`);
+ }
+ for (const route of indexed) {
+ if (!fileSet.has(route.slice(1))) errors.push(`llms.txt: missing page ${route}`);
+ }
+ }
+
+ const generatedPrefix = "assets/generated/";
+ const generated = files
+ .filter((relative) => relative.startsWith(generatedPrefix))
+ .map((relative) => relative.slice(generatedPrefix.length));
+ const declared = [...sources.assets].sort();
+ for (const name of generated) {
+ if (!declared.includes(name)) errors.push(`undeclared generated asset: ${name}`);
+ }
+ for (const name of declared) {
+ if (!generated.includes(name)) errors.push(`missing generated asset: ${name}`);
+ }
+
+ for (const relative of files.filter((name) => name.endsWith(".css"))) {
+ const contents = await readFile(path.join(site, ...relative.split("/")), "utf8");
+ if (/fonts\.(?:googleapis|gstatic)\.com/i.test(contents)) {
+ errors.push(`${relative}: remote font URL`);
+ }
+ }
+
+ if (errors.length > 0) {
+ throw new Error(`built site validation failed:\n ${errors.join("\n ")}`);
+ }
+ return { htmlPages: htmlFiles.length, markdownPages: markdownFiles.length };
+}
diff --git a/scripts/docs/verify-site.test.mjs b/scripts/docs/verify-site.test.mjs
new file mode 100644
index 00000000..ff81f9fa
--- /dev/null
+++ b/scripts/docs/verify-site.test.mjs
@@ -0,0 +1,209 @@
+import assert from "node:assert/strict";
+import { mkdtemp, mkdir, rm, writeFile } from "node:fs/promises";
+import { tmpdir } from "node:os";
+import path from "node:path";
+import test from "node:test";
+
+import { verifySite } from "./verify-site.mjs";
+
+
+async function write(root, relative, contents) {
+ const destination = path.join(root, relative);
+ await mkdir(path.dirname(destination), { recursive: true });
+ await writeFile(destination, contents);
+}
+
+
+function page({ canonical, body = "", extraHead = "" }) {
+ return `
+
+Fixture page
+
+
+
+
+
+
+
+
+
+
+${extraHead} Fixture page ${body} `;
+}
+
+
+async function fixture(t) {
+ const root = await mkdtemp(path.join(tmpdir(), "docbank-site-verify-"));
+ t.after(() => rm(root, { recursive: true, force: true }));
+ const site = path.join(root, "site");
+ const website = path.join(root, "website");
+ const docs = path.join(root, "docs");
+ await write(
+ site,
+ "index.html",
+ page({
+ canonical: "https://docbank.ai/",
+ body: 'Guide ',
+ }),
+ );
+ await write(
+ site,
+ "guide/index.html",
+ page({ canonical: "https://docbank.ai/guide/", body: 'Docs ' }),
+ );
+ await write(
+ site,
+ "docs/index.html",
+ page({ canonical: "https://docbank.ai/docs/", body: 'Setup ' }),
+ );
+ await write(
+ site,
+ "docs/setup/index.html",
+ page({ canonical: "https://docbank.ai/docs/setup/", body: 'Overview ' }),
+ );
+ const markdown = {
+ "index.md": "# Product\n",
+ "guide.md": "# Guide\n",
+ "docs/index.md": "# Operating docs\n",
+ "docs/setup.md": "# Setup\n",
+ };
+ for (const [relative, contents] of Object.entries(markdown)) {
+ await write(site, relative, contents);
+ }
+ await write(website, "index.md", markdown["index.md"]);
+ await write(website, "guide.md", markdown["guide.md"]);
+ await write(docs, "index.md", markdown["docs/index.md"]);
+ await write(docs, "setup.md", markdown["docs/setup.md"]);
+ await write(site, "assets/generated/capture.png", "png fixture");
+ await write(
+ site,
+ "llms.txt",
+ [
+ "# Docbank",
+ "- [Product](https://docbank.ai/index.md)",
+ "- [Guide](https://docbank.ai/guide.md)",
+ "- [Docs](https://docbank.ai/docs/index.md)",
+ "- [Setup](https://docbank.ai/docs/setup.md)",
+ "",
+ ].join("\n"),
+ );
+ return {
+ site,
+ sources: {
+ website,
+ docs,
+ assets: ["capture.png"],
+ },
+ };
+}
+
+
+test("accepts complete routes, Markdown peers, links, and assets", async (t) => {
+ const input = await fixture(t);
+ await verifySite(input);
+});
+
+
+test("accepts the generated 404 page without a canonical peer", async (t) => {
+ const input = await fixture(t);
+ const notFound = page({
+ canonical: "https://docbank.ai/docs/",
+ body: 'Skip ',
+ }).replace(' ', "");
+ await write(input.site, "docs/404.html", notFound);
+
+ await verifySite(input);
+});
+
+
+test("rejects a substantive HTML file that is not a directory route", async (t) => {
+ const input = await fixture(t);
+ await write(
+ input.site,
+ "docs/orphan.html",
+ page({ canonical: "https://docbank.ai/docs/orphan.html" }),
+ );
+
+ await assert.rejects(
+ () => verifySite(input),
+ /docs\/orphan\.html: unsupported substantive HTML route/,
+ );
+});
+
+
+test("rejects an HTML route without its Markdown peer", async (t) => {
+ const input = await fixture(t);
+ await rm(path.join(input.site, "guide.md"));
+ await assert.rejects(() => verifySite(input), /guide\/index\.html: missing Markdown peer guide\.md/);
+});
+
+
+test("rejects a stale llms index", async (t) => {
+ const input = await fixture(t);
+ await write(
+ input.site,
+ "llms.txt",
+ "- [Product](https://docbank.ai/index.md)\n- [Guide](https://docbank.ai/guide.md)\n- [Docs](https://docbank.ai/docs/index.md)\n",
+ );
+ await assert.rejects(() => verifySite(input), /llms\.txt: missing \/docs\/setup\.md/);
+});
+
+
+test("rejects a broken local link", async (t) => {
+ const input = await fixture(t);
+ await write(
+ input.site,
+ "index.html",
+ page({ canonical: "https://docbank.ai/", body: 'Missing ' }),
+ );
+ await assert.rejects(() => verifySite(input), /index\.html: broken local URL \/missing\//);
+});
+
+
+test("rejects an image that is not served from the site", async (t) => {
+ const { site, sources } = await fixture(t);
+ await write(
+ site,
+ "docs/setup/index.html",
+ page({
+ canonical: "https://docbank.ai/docs/setup/",
+ body: ' ',
+ }),
+ );
+ await assert.rejects(() => verifySite({ site, sources }), /image is not served from the site/);
+});
+
+
+test("rejects a private publishing path", async (t) => {
+ const input = await fixture(t);
+ await write(input.site, "internal/plan.md", "private\n");
+ await assert.rejects(() => verifySite(input), /publishing boundary leaked internal\/plan\.md/);
+});
+
+
+test("rejects an undeclared generated asset", async (t) => {
+ const input = await fixture(t);
+ await write(input.site, "assets/generated/extra.png", "extra\n");
+ await assert.rejects(() => verifySite(input), /undeclared generated asset: extra\.png/);
+});
+
+
+test("rejects a Markdown peer that differs from source", async (t) => {
+ const input = await fixture(t);
+ await write(input.site, "docs/setup.md", "changed\n");
+ await assert.rejects(() => verifySite(input), /docs\/setup\.md: published Markdown differs from source/);
+});
+
+
+test("rejects remote font hosts", async (t) => {
+ const input = await fixture(t);
+ await write(
+ input.site,
+ "index.html",
+ page({
+ canonical: "https://docbank.ai/",
+ extraHead: ' ',
+ }),
+ );
+ await assert.rejects(() => verifySite(input), /index\.html: remote font URL/);
+});
diff --git a/scripts/sync-docs-assets.sh b/scripts/sync-docs-assets.sh
new file mode 100755
index 00000000..b4e492c0
--- /dev/null
+++ b/scripts/sync-docs-assets.sh
@@ -0,0 +1,149 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+repo_root="$(cd "$script_dir/.." && pwd)"
+remote="${DOCBANK_DOCS_ASSETS_REMOTE:-https://github.com/kenn-io/docbank.git}"
+manifest="${DOCBANK_DOCS_ASSETS_MANIFEST:-$script_dir/docs-assets.txt}"
+ref_file="${DOCBANK_DOCS_ASSETS_REF:-$script_dir/docs-assets.ref}"
+cache_root="${DOCBANK_DOCS_ASSETS_CACHE:-$repo_root/.cache/docs-assets}"
+max_png_bytes=$((10 * 1024 * 1024))
+
+if command -v sha256sum >/dev/null 2>&1; then
+ checksum() { sha256sum "$@"; }
+ checksum_check() { sha256sum -c "$@"; }
+elif command -v shasum >/dev/null 2>&1; then
+ checksum() { shasum -a 256 "$@"; }
+ checksum_check() { shasum -a 256 -c "$@"; }
+else
+ printf 'SHA-256 checksum tool not found\n' >&2
+ exit 127
+fi
+
+if [[ ! -f "$manifest" ]]; then
+ printf 'docs asset manifest is missing: %s\n' "$manifest" >&2
+ exit 1
+fi
+if [[ ! -f "$ref_file" ]]; then
+ printf 'docs asset ref is missing: %s\n' "$ref_file" >&2
+ exit 1
+fi
+
+asset_ref="$(tr -d '\r\n' < "$ref_file")"
+if [[ ! "$asset_ref" =~ ^[0-9a-f]{40}$ ]]; then
+ printf 'docs asset ref must be one full lowercase commit SHA\n' >&2
+ exit 1
+fi
+if [[ ! -s "$manifest" ]] || grep -q '^$' "$manifest"; then
+ printf 'docs asset manifest must contain no blank entries\n' >&2
+ exit 1
+fi
+if ! LC_ALL=C sort -c "$manifest" 2>/dev/null; then
+ printf 'docs asset manifest must be sorted\n' >&2
+ exit 1
+fi
+if [[ -n "$(LC_ALL=C sort "$manifest" | uniq -d)" ]]; then
+ printf 'docs asset manifest contains duplicate entries\n' >&2
+ exit 1
+fi
+while IFS= read -r name; do
+ if [[ ! "$name" =~ ^[A-Za-z0-9][A-Za-z0-9._-]*\.png$ ]]; then
+ printf 'invalid docs asset manifest entry: %s\n' "$name" >&2
+ exit 1
+ fi
+done < "$manifest"
+
+destination="$cache_root/$asset_ref"
+
+cache_is_valid() {
+ [[ -d "$destination" && -f "$destination/.sha256" ]] || return 1
+ local actual_files recorded_files
+ actual_files="$(
+ find "$destination" -maxdepth 1 -type f ! -name '.sha256' -exec basename {} \; |
+ LC_ALL=C sort
+ )"
+ recorded_files="$(awk '{name=$2; sub(/^\*/, "", name); print name}' "$destination/.sha256")"
+ [[ "$actual_files" == "$(<"$manifest")" ]] || return 1
+ [[ "$recorded_files" == "$(<"$manifest")" ]] || return 1
+ (cd "$destination" && checksum_check .sha256 >/dev/null 2>&1)
+}
+
+if cache_is_valid; then
+ printf 'docs assets ready at %s\n' "$destination"
+ exit 0
+fi
+
+mkdir -p "$cache_root"
+scratch="$(mktemp -d)"
+staging="$cache_root/.${asset_ref}.next.$$"
+previous="$cache_root/.${asset_ref}.previous"
+cleanup() {
+ rm -rf -- "$scratch"
+ if [[ -n "${staging:-}" ]]; then
+ rm -rf -- "$staging"
+ fi
+}
+trap cleanup EXIT INT TERM
+
+export GIT_CONFIG_GLOBAL="$scratch/gitconfig"
+export GIT_CONFIG_NOSYSTEM=1
+unset GIT_DIR GIT_WORK_TREE GIT_INDEX_FILE GIT_OBJECT_DIRECTORY
+unset GIT_ALTERNATE_OBJECT_DIRECTORIES GIT_COMMON_DIR GIT_NAMESPACE
+
+git_repo="$scratch/repository.git"
+git init --bare --quiet "$git_repo"
+git -C "$git_repo" fetch --quiet --depth=1 "$remote" "$asset_ref"
+fetched="$(git -C "$git_repo" rev-parse 'FETCH_HEAD^{commit}')"
+if [[ "$fetched" != "$asset_ref" ]]; then
+ printf 'fetched docs asset commit differs from pinned ref\n' >&2
+ exit 1
+fi
+if [[ -n "$(git -C "$git_repo" show -s --format='%P' "$asset_ref")" ]]; then
+ printf 'docs asset commit must be an orphan commit\n' >&2
+ exit 1
+fi
+
+tree_files="$scratch/tree-files"
+git -C "$git_repo" ls-tree -r --name-only "$asset_ref" | LC_ALL=C sort > "$tree_files"
+if ! diff -u "$manifest" "$tree_files" >/dev/null; then
+ printf 'docs asset commit tree differs from manifest\n' >&2
+ diff -u "$manifest" "$tree_files" >&2 || true
+ exit 1
+fi
+if git -C "$git_repo" ls-tree -r "$asset_ref" | awk '$1 != "100644" { found=1 } END { exit !found }'; then
+ printf 'docs asset commit contains unsupported entry modes\n' >&2
+ exit 1
+fi
+
+mkdir "$staging"
+while IFS= read -r name; do
+ git -C "$git_repo" show "$asset_ref:$name" > "$staging/$name"
+ size="$(wc -c < "$staging/$name" | tr -d ' ')"
+ if (( size > max_png_bytes )); then
+ printf 'oversized docs asset: %s\n' "$name" >&2
+ exit 1
+ fi
+ signature="$(od -An -tx1 -N8 "$staging/$name" | tr -d ' \n')"
+ if [[ "$signature" != "89504e470d0a1a0a" ]]; then
+ printf 'invalid PNG signature: %s\n' "$name" >&2
+ exit 1
+ fi
+ (cd "$staging" && checksum "$name") >> "$staging/.sha256"
+done < "$manifest"
+(cd "$staging" && checksum_check .sha256 >/dev/null)
+
+rm -rf -- "$previous"
+had_destination=false
+if [[ -e "$destination" ]]; then
+ mv "$destination" "$previous"
+ had_destination=true
+fi
+if ! mv "$staging" "$destination"; then
+ if [[ "$had_destination" == true ]]; then
+ mv "$previous" "$destination"
+ fi
+ exit 1
+fi
+staging=""
+rm -rf -- "$previous"
+printf 'docs assets ready at %s\n' "$destination"
diff --git a/scripts/validate-docs-release.sh b/scripts/validate-docs-release.sh
new file mode 100755
index 00000000..5489abe4
--- /dev/null
+++ b/scripts/validate-docs-release.sh
@@ -0,0 +1,66 @@
+#!/bin/sh
+set -eu
+
+source_sha=${1:-}
+expected_tag=${2:-}
+
+fail() {
+ printf 'documentation release validation failed: %s\n' "$1" >&2
+ exit 1
+}
+
+if [ "${#source_sha}" -ne 40 ]; then
+ fail "source must be a full 40-character commit SHA"
+fi
+case "$source_sha" in
+ *[!0-9a-fA-F]*) fail "source must be a full 40-character commit SHA" ;;
+esac
+if ! printf '%s\n' "$expected_tag" | grep -Eq '^v[0-9]+\.[0-9]+\.[0-9]+$'; then
+ fail "expected release tag must use vX.Y.Z form"
+fi
+
+git fetch --quiet origin refs/heads/main:refs/remotes/origin/main --tags
+git cat-file -e "$source_sha^{commit}" 2>/dev/null || fail "source commit does not exist: $source_sha"
+git rev-parse --verify --quiet "$expected_tag^{commit}" >/dev/null || fail "release tag does not exist: $expected_tag"
+
+if ! git merge-base --is-ancestor "$source_sha" origin/main; then
+ fail "documentation source $source_sha is not on origin/main"
+fi
+if ! git merge-base --is-ancestor "$expected_tag" "$source_sha"; then
+ fail "release tag $expected_tag is not an ancestor of documentation source $source_sha"
+fi
+
+latest_tag=$(git describe --tags --abbrev=0 --match 'v[0-9]*.[0-9]*.[0-9]*' origin/main 2>/dev/null) || {
+ fail "origin/main has no release tag"
+}
+if [ "$latest_tag" != "$expected_tag" ]; then
+ fail "latest release on origin/main changed from $expected_tag to $latest_tag"
+fi
+
+git diff --name-only "$expected_tag..$source_sha" | while IFS= read -r changed_path; do
+ case "$changed_path" in
+ .github/workflows/deploy-docs.yml | \
+ .vercelignore | \
+ AGENTS.md | \
+ Makefile | \
+ README.md | \
+ scripts/deploy-docs.sh | \
+ scripts/docs-assets.ref | \
+ scripts/docs-assets.txt | \
+ scripts/sync-docs-assets.sh | \
+ scripts/validate-docs-release.sh | \
+ scripts/vercel-build-docs.sh | \
+ scripts/vercel-install-docs.sh | \
+ vercel.json | \
+ LICENSES/* | \
+ docs/* | \
+ scripts/docs/* | \
+ website/*)
+ ;;
+ *)
+ fail "release-gated documentation source contains product change: $changed_path"
+ ;;
+ esac
+done
+
+printf 'validated documentation source %s at release %s\n' "$source_sha" "$expected_tag"
diff --git a/scripts/validate-docs-release.test.sh b/scripts/validate-docs-release.test.sh
new file mode 100755
index 00000000..6e9e8f85
--- /dev/null
+++ b/scripts/validate-docs-release.test.sh
@@ -0,0 +1,91 @@
+#!/bin/sh
+set -eu
+
+repository_root=$(CDPATH='' cd -- "$(dirname -- "$0")/.." && pwd)
+validator="$repository_root/scripts/validate-docs-release.sh"
+scratch=$(mktemp -d -t docbank-release-validation.XXXXXX)
+trap 'find "$scratch" -depth -delete' EXIT HUP INT TERM
+tests=0
+
+
+new_fixture() {
+ fixture="$scratch/repo-$tests"
+ remote="$scratch/remote-$tests.git"
+ git init --quiet --bare "$remote"
+ git init --quiet -b main "$fixture"
+ git -C "$fixture" config user.name "Release Test"
+ git -C "$fixture" config user.email "release-test@example.invalid"
+ git -C "$fixture" remote add origin "$remote"
+ mkdir -p "$fixture/cmd/docbank" "$fixture/docs"
+ printf 'package main\n' > "$fixture/cmd/docbank/main.go"
+ printf '# Documentation\n' > "$fixture/docs/index.md"
+ git -C "$fixture" add .
+ git -C "$fixture" commit --quiet -m "initial release"
+ git -C "$fixture" tag v1.0.0
+ git -C "$fixture" push --quiet -u origin main --tags
+
+ printf '\nRelease notes.\n' >> "$fixture/docs/index.md"
+ git -C "$fixture" commit --quiet -am "docs: add release notes"
+ git -C "$fixture" push --quiet origin main
+ source_sha=$(git -C "$fixture" rev-parse HEAD)
+}
+
+
+expect_pass() {
+ expected_tag=$1
+ output=$(cd "$fixture" && "$validator" "$source_sha" "$expected_tag" 2>&1) || {
+ printf 'expected success, got:\n%s\n' "$output" >&2
+ exit 1
+ }
+ printf '%s\n' "$output" | grep -F "validated documentation source $source_sha at release $expected_tag" >/dev/null
+ tests=$((tests + 1))
+}
+
+
+expect_fail() {
+ expected_tag=$1
+ expected_message=$2
+ if output=$(cd "$fixture" && "$validator" "$source_sha" "$expected_tag" 2>&1); then
+ printf 'expected failure, got success:\n%s\n' "$output" >&2
+ exit 1
+ fi
+ printf '%s\n' "$output" | grep -F "$expected_message" >/dev/null || {
+ printf 'expected failure containing %s, got:\n%s\n' "$expected_message" "$output" >&2
+ exit 1
+ }
+ tests=$((tests + 1))
+}
+
+
+new_fixture
+expect_pass v1.0.0
+
+new_fixture
+git -C "$fixture" switch --quiet --orphan unrelated
+git -C "$fixture" rm --quiet -rf --ignore-unmatch .
+printf 'unrelated\n' > "$fixture/unrelated.txt"
+git -C "$fixture" add unrelated.txt
+git -C "$fixture" commit --quiet -m "unrelated release"
+git -C "$fixture" tag v9.0.0
+git -C "$fixture" switch --quiet main
+expect_fail v9.0.0 "is not an ancestor of documentation source"
+
+new_fixture
+printf '\nUnpublished.\n' >> "$fixture/docs/index.md"
+git -C "$fixture" commit --quiet -am "docs: unpublished edit"
+source_sha=$(git -C "$fixture" rev-parse HEAD)
+expect_fail v1.0.0 "is not on origin/main"
+
+new_fixture
+printf 'package main\n\nvar version = 2\n' > "$fixture/cmd/docbank/main.go"
+git -C "$fixture" commit --quiet -am "feat: change product"
+git -C "$fixture" push --quiet origin main
+source_sha=$(git -C "$fixture" rev-parse HEAD)
+expect_fail v1.0.0 "release-gated documentation source contains product change: cmd/docbank/main.go"
+
+new_fixture
+git -C "$fixture" tag v1.1.0 "$source_sha"
+git -C "$fixture" push --quiet origin v1.1.0
+expect_fail v1.0.0 "latest release on origin/main changed from v1.0.0 to v1.1.0"
+
+printf '%s release validation tests passed\n' "$tests"
diff --git a/scripts/vercel-build-docs.sh b/scripts/vercel-build-docs.sh
new file mode 100755
index 00000000..2457612f
--- /dev/null
+++ b/scripts/vercel-build-docs.sh
@@ -0,0 +1,15 @@
+#!/bin/sh
+set -eu
+
+script_dir=$(CDPATH='' cd -- "$(dirname -- "$0")" && pwd)
+repo_root=$(CDPATH='' cd -- "$script_dir/.." && pwd)
+export PATH="$repo_root/.vercel-tools/bin:$PATH"
+export DOCBANK_REPO_ROOT="$repo_root"
+
+if [ ! -x "$repo_root/.vercel-tools/bin/uv" ]; then
+ printf 'pinned uv is missing; the Vercel install step did not complete\n' >&2
+ exit 1
+fi
+
+cd "$repo_root"
+node scripts/docs/build.mjs
diff --git a/scripts/vercel-install-docs.sh b/scripts/vercel-install-docs.sh
new file mode 100755
index 00000000..69e393ad
--- /dev/null
+++ b/scripts/vercel-install-docs.sh
@@ -0,0 +1,41 @@
+#!/bin/sh
+set -eu
+
+script_dir=$(CDPATH='' cd -- "$(dirname -- "$0")" && pwd)
+repo_root=$(CDPATH='' cd -- "$script_dir/.." && pwd)
+tools_root="$repo_root/.vercel-tools"
+uv_version=0.12.7
+
+if [ "$(uname -s)" != "Linux" ]; then
+ printf 'Vercel documentation install requires Linux\n' >&2
+ exit 1
+fi
+
+case "$(uname -m)" in
+ x86_64 | amd64)
+ target=x86_64-unknown-linux-gnu
+ expected_sha=788f18abea7c5f55d6216e4f5613fd89d4d59b631efeec117b2b07fe72f1da21
+ ;;
+ aarch64 | arm64)
+ target=aarch64-unknown-linux-gnu
+ expected_sha=66393193038dd7eb108abd7a218d9cec04ac70ab98242b0720fa94de19223b7c
+ ;;
+ *)
+ printf 'unsupported Vercel build architecture: %s\n' "$(uname -m)" >&2
+ exit 1
+ ;;
+esac
+
+scratch=$(mktemp -d)
+trap 'rm -rf -- "$scratch"' EXIT HUP INT TERM
+archive="$scratch/uv.tar.gz"
+url="https://github.com/astral-sh/uv/releases/download/$uv_version/uv-$target.tar.gz"
+
+curl --fail --location --silent --show-error "$url" --output "$archive"
+printf '%s %s\n' "$expected_sha" "$archive" | sha256sum -c -
+tar -xzf "$archive" -C "$scratch"
+
+mkdir -p "$tools_root/bin"
+install -m 0755 "$scratch/uv-$target/uv" "$tools_root/bin/uv"
+install -m 0755 "$scratch/uv-$target/uvx" "$tools_root/bin/uvx"
+PATH="$tools_root/bin:$PATH" uv sync --project "$repo_root/docs" --frozen
diff --git a/vercel.json b/vercel.json
new file mode 100644
index 00000000..4e94d940
--- /dev/null
+++ b/vercel.json
@@ -0,0 +1,21 @@
+{
+ "$schema": "https://openapi.vercel.sh/vercel.json",
+ "framework": null,
+ "installCommand": "./scripts/vercel-install-docs.sh",
+ "buildCommand": "./scripts/vercel-build-docs.sh",
+ "outputDirectory": "site",
+ "trailingSlash": true,
+ "redirects": [
+ {
+ "source": "/:path*",
+ "has": [
+ {
+ "type": "host",
+ "value": "www.docbank.ai"
+ }
+ ],
+ "destination": "https://docbank.ai/:path*",
+ "permanent": true
+ }
+ ]
+}
diff --git a/website/assets/authority-ledger.svg b/website/assets/authority-ledger.svg
new file mode 100644
index 00000000..a14293c3
--- /dev/null
+++ b/website/assets/authority-ledger.svg
@@ -0,0 +1,25 @@
+
+ Authority ledger
+ Exact source bytes remain authoritative while approved processing creates version-bound derivatives.
+
+
+
+
+
+
+
+
+
+ SOURCE AUTHORITY
+ Exact bytes · stable identity · immutable version
+ 01 / AUTHORIZE
+ Operator policy
+ Provider and outputs
+ 02 / PROCESS
+ Bounded work
+ Against one version
+ 03 / RETAIN
+ Derivatives
+ Traceable to source
+
+
diff --git a/website/assets/derivative-cycle.svg b/website/assets/derivative-cycle.svg
new file mode 100644
index 00000000..4bc7ae04
--- /dev/null
+++ b/website/assets/derivative-cycle.svg
@@ -0,0 +1,47 @@
+
+ Docbank derivative cycle
+ Consent, derivation, retrieval, and purge cycle around a source that stays exact.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ THE RECORD
+ source stays exact
+
+ CONSENT
+ DERIVE
+ RETRIEVE
+ PURGE
+
+
+ durable · revocable
+ rendition · embedding
+ evidence-backed
+ originals untouched
+
+
+
diff --git a/website/assets/intelligence-pipeline.svg b/website/assets/intelligence-pipeline.svg
new file mode 100644
index 00000000..bdbc317d
--- /dev/null
+++ b/website/assets/intelligence-pipeline.svg
@@ -0,0 +1,62 @@
+
+ Docbank intelligence pipeline
+ One exact source version feeds governed rendition, chunk, embedding, and index derivatives that can be purged and rebuilt without touching the record.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ THE RECORD
+ GOVERNED DERIVATIVES
+ CONSENT
+
+ SOURCE
+ RENDITION
+ CHUNKS
+ EMBEDDINGS
+ INDEX
+
+
+ exact bytes · v3
+ ocr · markdown
+ deterministic
+ generation-bound
+ rebuildable
+
+ purge · re-derive · swap providers · rebuild — the record never moves
+
+
diff --git a/website/assets/interface-map.svg b/website/assets/interface-map.svg
new file mode 100644
index 00000000..f803eb65
--- /dev/null
+++ b/website/assets/interface-map.svg
@@ -0,0 +1,61 @@
+
+ Docbank interface map
+ CLI, web, TUI, HTTP, embedded Go, and MCP reach the same vault authority.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ DOCBANK VAULT
+ one authority
+ stable identity · exact bytes
+
+ CLI
+ WEB
+ TUI
+ HTTP
+ GO
+ MCP
+
+
+ scriptable
+ visual
+ keyboard
+ api
+ embedded
+ agents
+
+
+
diff --git a/website/assets/recovery-flow.svg b/website/assets/recovery-flow.svg
new file mode 100644
index 00000000..083e99c6
--- /dev/null
+++ b/website/assets/recovery-flow.svg
@@ -0,0 +1,25 @@
+
+ Provider-independent recovery
+ A verified snapshot restores exact content, metadata, and history without making a processing provider authoritative.
+
+
+
+
+
+
+
+
+
+
+
+ 01 / SNAPSHOT
+ Exact record
+ content · metadata
+ 02 / VERIFY
+ Before use
+ checksums · history
+ 03 / RESTORE
+ Operator control
+ no provider required
+
+
diff --git a/website/favicon.svg b/website/favicon.svg
new file mode 100644
index 00000000..7a4ff4d9
--- /dev/null
+++ b/website/favicon.svg
@@ -0,0 +1,6 @@
+
+
+
+
+
+
diff --git a/website/fonts/Inter-Medium.woff2 b/website/fonts/Inter-Medium.woff2
new file mode 100644
index 0000000000000000000000000000000000000000..fdfdcc699fc1e19eb1943c2896e8d66e17b538ff
GIT binary patch
literal 114348
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 Q|3zqFp7#i%;`h*l0SMlYXs3emQm!N_Vh)
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