The theme is codeTheme from @hanzo/ui/core: Dracula on dark, GitHub Light on light, the one every Hanzo surface draws. lib/shiki.ts and each app's source.config.ts import it rather than naming a theme, so vesper and github-dark are gone. @hanzo/ui moves to 8.27.83, the first release that carries it. A block's code area paints the theme's own ground and ink from the pre shiki wrote, #282a36 in the dark, rather than the site's panel. The docs home drew its hero and its eight domain snippets as plain text, and so did the models page's fallback request. The hero highlights its three doors in a server part (components/hero.tsx); the snippets and the fallback are Example props in the MDX. No highlighter reaches the browser.
26 KiB
Hanzo Docs Framework
Fork of Hanzo Docs with all packages renamed to @hanzo/docs-* namespace.
How this ships
Runbook: RELEASE.md. One lane:
push -> github.com/hanzo-docs/docs origin; GitHub Actions is OFF here
-> git.hanzo.ai/hanzoai/docs a pull mirror; sync + dispatch (RELEASE.md)
.github/workflows/deploy.yml the forge reads this directory natively
-> pnpm build --filter=docs NEXT_EXPORT=1 -> apps/docs/out
-> hanzoai/ci .github/actions/site Sites project `docs`
-> s3://hanzo-sites/hanzo/docs what docs.hanzo.ai serves
The host is a route, not a workload: Host(docs.hanzo.ai) -> Middleware
docs-static -> that S3 prefix, declared in hanzoai/universe
charts/app/values/hanzo/static-sites.yaml. Nothing is pinned and nothing rolls;
the publish IS the deploy.
The other workflows in .github/workflows/ run on the same forge: lint.yml and
test.yml on pull requests, release.yml on push (the npm packages),
sync-zen-pricing.yml daily, deploy-cloud.yml for apps/cloud (Sites project
hanzo-cloud), and eight deploy-*-docs.yml that still wrangler pages deploy
their app. Each wrangler one is the ONLY deploy of its host, so it stays until
that host has a Sites project: give the app output: 'export', publish a slug,
move the DNS, delete the workflow.
The image lane is cold. ghcr.io/hanzoai/docs, the root Dockerfile,
hanzo.yml's images: and the docs app in universe served this host before
the Sites plane did. The pod still runs and no router names it. Building or
pinning it publishes nothing.
The export gate is in scripts/check-export.sh, which the deploy runs on
apps/docs/out and the Dockerfile runs in its build stage. This site fails by
exporting nothing — a valid layer, a valid image, a 404 — and apps/<app>/ export.require names the sections that must not silently vanish (docs/studio/
is a submodule; a checkout that does not recurse drops it while page count and
nav stay green).
Canonical model — the one way to do docs
Full ADR: apps/docs/content/docs/contributing/docs-architecture.mdx (rendered
at docs.hanzo.ai/docs/contributing/docs-architecture). Summary:
- docs.hanzo.ai is the hub. ONE Fumadocs build (
apps/docs) → ONE Sites projectdocs. The federated per-section deploy is retired (stale origins 530'd). Do not reintroduce per-section builds. - IA is two levels of separator, and one word each. The ROOT sidebar
(
content/docs/meta.json) is nine sections a developer walks in order — Get started · Concepts · Capabilities · Reference · Build with · Surfaces · Programs · Integrate · Open source. Inside Capabilities (content/docs/openapi/meta.json, generated) is the second level: the nine DOMAINS — Identity & Trust · Intelligence · Data · Streams · Observability · Commerce · Platform · Applications · Chain — fromapps/docs/openapi-specs/ capabilities.yaml, which is tracked HERE and is the one editorial decision left in this repo. Both levels are icon-bearing separators (---[Icon]Name---, resolved bylucideIconsPlugin), NOT folder moves — files stay flat so URLs/openapi back-links don't break. Counts are never written twice. How many capabilities there are is a fact about cloud's document, printed by the generator on/docs/openapi; authored prose links to it rather than restating a number that goes stale the next time cloud ships.check-capabilitiesis what holds the taxonomy, the document and the pages to one set. No stutter. A folder's ownindex.mdxis already its landing page — the tree builder resolves it before it readspages. Namingindexinpagesdemotes the page to a child of the folder carrying the folder's own name, so the sidebar readsAgents > Agents; 191 folders named it and 188 of them carried the same title, so that is how many rows said it twice. Same rule one level down: a folder wrapping exactly ONE page is a folder that should not exist, which is why a single-tool MCP product is published as a page and not a directory. API reference and SDKs are separated surfaces (/docs/openapi,/docs/sdks) reached via the top nav (lib/nav.ts), deliberately NOT in the root sidebar descent. The landing (app/(landing)/page.tsx— its own route group, because it carries the docs chrome fromcomponents/shell.tsxwhile(home)keeps the marketing chrome for /blog) + docs home (content/docs/index.mdx) lead with the decentralized spine ("the AI cloud you can run yourself");content/docs/network.mdx= self-host/hanzo.network,content/docs/architecture/philosophy.mdx= the engineering pedagogy. Landing breadth stats trace to real sources: the domains fromcapabilities.yamland the capability count from cloud's document, 157 models (ai/conf/models.yaml), 706 connectors (cloud/clients/automations/catalog/ catalog.json), 6 SDK langs (openapi/CLAUDE.md). Count from data, never invent. - One canonical home per doc. Content enters the single build via exactly one
of three orthogonal mechanisms, chosen by kind:
- Authored (first-party prose) → MDX under
content/docs/<section>/. Small/core sections live in-repo; team-owned sections live in ahanzo-docs/<team>content repo mounted as a git submodule atcontent/docs/<team>/. Exemplar:hanzo-docs/studio-docs→content/docs/studio/. - Generated (never hand-written,
.gitignored) → API reference fromhanzoai/cloud'sopenapi.yamlat the releaseopenapi-specs/.spec-lockpins, viascripts/sync-openapi.sh+scripts/gen-openapi-pages.ts(source-derived: an endpoint's sentence is written next to its handler and travels here unaltered); SDK reference from the ZAP SDK generator intocontent/docs/sdks/<lang>/. Every reader of the document goes throughscripts/openapi-doc.ts, which owns the path — no script names the file. A generated MDX fragment is the third shape here, for a fact an authored page needs mid-sentence:scripts/gen-key-types.tswritesgenerated/key-types.mdxandcontent/docs/api-keys.mdxpulls it in with<include>. It sits outsidecontent/because source.config globs**/*.mdxthere and would give a fragment its own URL — and the path is relative to the INCLUDING file, so fromcontent/docs/it is../../generated/. One directory short renders the page body empty with the frontmatter and chrome intact, which looks like a working page. - Ported (upstream OSS, mirrored with attribution, tracked) →
content/docs/projects/<upstream>/. Port, don't re-author. Carry upstream LICENSE + NOTICE. GPL stays GPL. This is a committed snapshot, not build output:scripts/sync-project-docs.tsrefreshes it on demand (pnpm --filter docs sync:projects) and you read the diff before it ships, the same contractopenapi-specs/has. It used to be BOTH — gitignored and re-mirrored by every build, so a build rewrote 965 committed files, put 298 repos' docs on disk that production never had, and died on 54 imports (@site/...) no alias resolves. The builder alone skipped the sync, which meant the command a person runs and the command that ships the site compiled different sites.
- Authored (first-party prose) → MDX under
- Repos:
hanzo-docs/docs= framework + hub (canonical;hanzoai/docsredirects here).hanzo-docs/<team>= authored content only, NO framework.hanzoai/cloudopenapi.yaml= the API source of truth;hanzoai/openapi'shanzo.yamlwas a projection of it on its own clock and this repo no longer reads it. All docs live in thehanzo-docsorg: the hub athanzo-docs/docs, each team's content athanzo-docs/<team>. - Standalone vs hub: default is a hub section. Standalone deploy only if ALL of: ≈150+ pages or fast OSS-upstream churn, independent versioning, direct audience. Standalone runs its own copy of this framework; the hub links out, never copies.
- Serving: the Sites plane —
s3://hanzo-sites/hanzo/docs, served by the ingress staticFiles middleware onHost(docs.hanzo.ai). The sibling hosts below are still CF Pageshanzo-docs(token from KMS, never hard-coded). No nginx/caddy, and no pod for a static export.
Known dedup debt (rollout, not done): the apps/*-docs legacy apps
(base-docs, bootnode-docs, bot-docs, cloud, dev-docs, dns-docs, flow, gui-docs,
insights-docs, platform, pulsar-docs, spec, tasks-docs, team, visor, zen-docs,
zt-docs) predate the unified content/docs/ model — migrate their content into
content/docs/<section>/ (or a hanzo-docs/<team> submodule) then archive the
app.
Funnel
- No page sends a visitor to hanzo.id. Sign-in is
https://hanzo.ai/login(components/account.tsx;/loginis a rule inpublic/_redirects). Sign-out clears the session in this browser and navigates nowhere. - The conversion CTA reads "Try Hanzo" and opens
https://hanzo.ai/login; payment comes after sign-in (components/landing.tsx). It sends no event of its own: it is not a plan choice, and the page view and click cover it. - Tracking is
@hanzo/docs-analytics: page views (first load and each route change) toapi.hanzo.ai/v1/event, and<Hanzo>(@hanzo/ui) autocaptures$clickonto the same client. The key is the one@hanzo/event's keyring resolves from the page's host:docs.hanzo.aiis the hanzo org's,localhostresolves none. An event this site names itself comes from the @hanzo/events catalog. The cold image lane'sPUBLISHABLE_KEYbuild arg is no longer read by any app.
Build guards, and why prose needs them
scripts/pre-build.ts ends with checks that fail the build. The first two exist for
the same reason: a generated page cannot be wrong about the API, an authored one
can, and a reader cannot tell which kind of page they are on.
check-endpoints.ts— no page may name a/v1/…route onapi.hanzo.aithat the document does not serve.check-keys.ts— no page may spell an API key a way cloud does not mint, andapi-keys.mdxmust teach every class the document carries. Both directions, so renaming, adding or removing a key class in cloud stops the build here instead of publishing a stale page.check-zen.ts— a Zen page (models/index.mdx,models/zen*) names no upstream base model outside adata-upstreamelement. Two places carry one: the Architecture value (the loader string from the weights' own config) and the License & attribution section. The names live inapps/docs/zen.upstreamand only there; title and description are read too, since they become the meta and OG tags.
The key classes come from Document.keys (openapi-doc.ts), read out of the
/v1/keys prose — cloud's own Go doc comments, lifted by zipdoc. secretKey(doc)
is the one accessor generators use for "the key a server presents"; three of them
had it as a literal, which is how three GENERATED pages came to teach hk-.
What was wrong: docs.hanzo.ai documented three key types — hk- "API Key",
sk- "Secret Key", hz- "Widget Key". Cloud mints two (cloud.APIKeyPrefixes:
pk-, sk-) and refuses anything else, so a reader's first call failed asking
for a key nobody can issue. hk-/hz- were on 149 files. Note the shape of the
mistake: sk- was present and described wrongly (as an org-level provider
credential; it resolves to the USER), so a find-and-replace would have left the
page confidently wrong. Cloud's apps/platform/secretshape.go still lists hk-
in its secret-DETECTION table — harmless, it only scans, but it is where the
invention came from.
Branch Convention
main— Production branch. A push here builds the export and publishes it to the Sites plane, so landing onmainIS publishing (RELEASE.md). All Hanzo work lands here.dev— Tracks upstreamHanzo Docs/dev. Used for upstream sync merges only.upstreamremote — points tohanzoai/docs
Architecture
pnpm workspace monorepo with turbo. Two apps, 24 packages, 24 examples.
~/work/hanzo/docs/
├── apps/
│ ├── docs/ # Main docs site (hanzoai.github.io/docs)
│ └── zap-docs/ # Zap protocol docs
├── packages/
│ ├── core/ # @hanzo/docs-core - source loading, search, i18n
│ ├── mdx/ # @hanzo/docs-mdx - MDX processing, collections
│ ├── base-ui/ # @hanzo/docs-base-ui - headless UI (@base-ui/react)
│ ├── radix-ui/ # @hanzo/docs-ui - full UI with Radix primitives
│ ├── openapi/ # @hanzo/docs-openapi - OpenAPI docs generation
│ ├── typescript/ # @hanzo/docs-typescript - auto type tables
│ ├── twoslash/ # @hanzo/docs-twoslash - TypeScript code hints
│ ├── cli/ # @hanzo/docs-cli - scaffolding & customization
│ ├── story/ # @hanzo/docs-story - component stories
│ ├── tailwind/ # @hanzo/docs-tailwind - Tailwind CSS utils
│ ├── press/ # @hanzo/docs-press - minimal setup
│ ├── hanzo-docs/ # @hanzo/docs - unified wrapper re-exporting all
│ ├── content-collections/ # @hanzo/docs-content-collections
│ ├── mdx-remote/ # @hanzo/docs-mdx-remote - remote MDX
│ ├── obsidian/ # @hanzo/docs-obsidian - Obsidian vault adapter
│ ├── python/ # @hanzo/docs-python - Python docgen
│ ├── doc-gen/ # @hanzo/docs-docgen - doc generation
│ ├── create-app/ # @hanzo/docs-create-app - project scaffolding
│ ├── create-app-versions/ # version tracking for create-app
│ ├── shared/ # shared utilities
│ ├── stf/ # @hanzo/docs-stf (upstream dependency)
│ ├── mdx-runtime/ # @hanzo/mdx-runtime
│ ├── eslint-config-custom/ # shared ESLint config
│ └── tsconfig/ # shared TypeScript config
└── examples/ # 24 example apps (Next.js, Astro, React Router, etc.)
Package Naming Convention
One brand: every workspace package publishes under @hanzo/docs-*. The
rename rule (applied when merging from the upstream fork) drops the upstream
prefix and re-scopes to @hanzo/docs-:
<basename>(unscoped upstream) →@hanzo/docs-<basename>@<scope>/<basename>(scoped upstream) →@hanzo/docs-<basename>
Canonical workspace packages and their paths:
| Hanzo name | Path |
|---|---|
@hanzo/docs-core |
packages/core |
@hanzo/docs-mdx |
packages/mdx |
@hanzo/docs-ui |
packages/radix-ui |
@hanzo/docs-base-ui |
packages/base-ui |
@hanzo/docs-openapi |
packages/openapi |
@hanzo/docs-preview |
packages/preview |
@hanzo/docs-typescript |
packages/typescript |
@hanzo/docs-twoslash |
packages/twoslash |
@hanzo/docs-cli |
packages/cli |
@hanzo/docs-story |
packages/story |
@hanzo/docs-tailwind |
packages/tailwind |
@hanzo/docs-language |
packages/language |
@hanzo/docs-local-md |
packages/local-md |
@hanzo/docs-sanity |
packages/sanity |
@hanzo/docs-vite |
packages/vite |
@hanzo/docs-basehub |
packages/basehub |
@hanzo/docs-mdx-remote |
packages/mdx-remote |
@hanzo/docs-stf |
packages/stf |
@hanzo/create-docs |
packages/create-app |
@hanzo/docs-create-versions |
packages/create-app-versions |
Important: packages/radix-ui publishes as @hanzo/docs-ui (Radix
variant). The base-ui variant (@hanzo/docs-base-ui) uses @base-ui/react
instead of Radix.
External deps kept verbatim (real upstream npm packages, NOT renamed):
fuma-cli, fuma-content, @fumari/json-schema-ts, and the third-party
search adapters (typesense / trieve) documented under apps/docs.
Upstream Sync
Remote upstream points to the upstream fork. Local dev tracks upstream/dev.
To merge upstream changes:
git checkout dev && git pull upstream dev
git checkout -b merge-upstream-YYYY-MM-DD main
git merge dev
# Resolve conflicts, then re-apply the package rename (table above)
# Merge into main when ready
After merge, re-apply the rename with the canonical script
(scripts/rebrand-packages.mjs — masks the external KEEP-list, then maps
each upstream name to its @hanzo/docs-* form).
Key Patterns
Source Config (source.config.ts)
import { defineConfig, defineDocs } from '@hanzo/docs-mdx/config';
Source Loader (lib/source.ts)
import { docs } from '@/.source';
import { loader } from '@hanzo/docs-core/source';
export const source = loader({
baseUrl: '/docs',
source: docs.toHanzoDocsSource(), // API name kept from upstream
});
The chrome is @hanzo/gui (apps/docs)
Every docs page sits in components/shell.tsx: the rail (rail.tsx — brand,
search, tree.tsx, account and theme at the foot), and a column holding the bar
(bar.tsx) and the page (page.tsx — breadcrumb, title, description, the
"Use agent" menu agent.tsx, body, previous/next; toc.tsx beside it). All of
it is gui stacks and text on theme tokens; no class names.
- Widths are media props, never JS.
$max-md,$xlcompile to CSS media rules, so the markup a phone hydrates is the markup the export wrote. Rail 264px, bar 56px, article 880px with the outline 240px beside it from 1280px; below 768px the rail is a Sheet opened from the bar. - First paint is styled with no script. gui emits each component's atomic
rules as
<style precedence>during SSR, React hoists them, and<html>shipst_dark. Check it: render any page with JavaScript off. - One theme authority:
t_dark/t_lighton<html>. gui'sNextThemeProvider(@hanzogui/next-theme) writes it before paint and<Hanzo theme>follows it (app/provider.tsx). The only CSS that reads it iscolor-schemeinapp/global.css, which is what picks the code palette. - Muted text is
lib/ink.ts. gui's grey ramp is not symmetric (light step 10 is #333), so muted names step 10 dark / 9 light. Chrome text measures >= 7.5:1 in both themes. - gui v5 is shorthand-only.
bg p px items justify rounded minW maxH t zexist;w/hdo NOT (usewidth/height) — an unknown prop is dropped silently and the box takes its content's size. - One title per page. The header prints the frontmatter
title;lib/remark-title.tsdrops a body's leading# heading. - Page bodies are gui too. MDX hands each markdown tag to
components/mdx.tsx; blocks are spaced byBody's gap, never their own margins, so a list inside a callout keeps its own rhythm. Running text setswhiteSpace: normal(gui text keeps whitespace, markdown hard-wraps). Tables keep the browser's table layout throughstyle.display. Code tokens carrycolor: light-dark(…)(lib/shiki.ts), socolor-schemepicks the palette and no stylesheet knows the theme. Steps are theSteps/Stepparts (lib/remark-parts.tsrenames remark-steps' marked divs). - One code theme:
codeThemefrom@hanzo/ui/core(Dracula dark, GitHub Light light), the same on hanzo.ai, platform and ui.hanzo.ai.lib/shiki.tspasses it to rehype-code andExample; every app'ssource.config.tsimports it, never a theme literal. A block's code area paints the theme's own ground and ink from thepreshiki wrote (#282a36dark,#ffflight). A client part never runs shiki: it takes the code already highlighted —snippetandrequestare<Example lang code />props in the MDX, and the hero iscomponents/hero.tsx, a server part that highlights its three doors. - The tree narrows by path, not address.
lib/tree.tsxresolves an operation page (not in the tree) to its product page, so the rail opens the reference it belongs to.
Build
pnpm install # Install deps
pnpm build # Build all packages + apps
pnpm dev # Dev server
# Individual packages
pnpm build --filter @hanzo/docs-core
pnpm build --filter @hanzo/docs-ui
Build tool: tsdown (all packages except hanzo-docs wrapper which uses tsup).
Compatibility
- Next.js 15-16+ with App Router
- React 19+
apps/docsrenders no Tailwind; the framework packages still ship it ("Moving onto @hanzo/gui")- pnpm 10+
Moving onto @hanzo/gui — the plan
House rule: @hanzo/gui is the only UI framework (it renders on web, iOS and
Android; Tailwind classes and Radix/Base UI are DOM-only). docs.hanzo.ai is being
moved onto it in four phases, each shipped to main when verified live.
Inventory of what docs.hanzo.ai renders (Tailwind's own scanner, counting only
tokens Tailwind would emit CSS for, in class contexts; content/docs/projects/**
excluded — it is upstream's):
| Area | Tailwind tokens / files | Radix files | Base UI files |
|---|---|---|---|
apps/docs app+components+lib, before phase 1 |
2,189 / 52 | 2 | 0 |
apps/docs after phase 1 |
1,906 / 37 | 2 | 0 |
apps/docs after phase 3 |
0 / 0 | 0 | 0 |
packages/base-ui (@hanzo/docs-base-ui) |
2,774 / 76 | 0 | 10 |
packages/radix-ui (@hanzo/docs-ui, via openapi/twoslash/typescript) |
2,568 / 71 | 9 | 0 |
packages/openapi |
794 / 23 | 3 | 0 |
packages/story / twoslash |
212 / 4, 3 / 1 | 1, 1 | 0 |
authored MDX (content/docs, mostly code samples) |
63 / 56 | 0 | 0 |
Plus the CSS: app/global.css imports tailwindcss, the base-ui, openapi,
story and twoslash presets, and tailwindcss-animate.
What replaces what. Layout, rail, bar, outline and page header: our own gui
stacks (done). Drawer: @hanzo/ui Sheet. Menus: @hanzo/ui DropdownMenu.
Folds: plain state + gui stacks. Icons: @hanzogui/lucide-icons-2. Theme:
@hanzogui/next-theme. Tree/TOC logic stays in @hanzo/docs-core (headless).
| Phase | Scope | Replaces | Status |
|---|---|---|---|
| 1 | Chrome on every page: rail, tree, bar, outline, page header, search trigger, account, theme | base-ui DocsLayout/DocsPage/sidebar/toc, next-themes, the corner dock |
shipped (f11eb49dc2) |
| 2 | Page bodies: markdown's elements (components/mdx/prose.tsx), the parts content names — Callout, Cards, Tabs, Steps, Accordion, Files, code blocks and code tabs (blocks.tsx, tabs.tsx, code.tsx) — and Example; content imports none of them; ported and studio pages reach them through the parts aliases in next.config |
Tailwind typography, base-ui MDX components, shiki.css, the HoverCard link preview (a title now) |
shipped |
| 3 | App pages and app parts: landing (on the theme now, no forced dark), hero, product sections, provider strip, catalogs, search (search.tsx + finder.tsx, ⌘K), feedback (a real docs.feedback event, not a console stub), 404, blog (in the shell, on the page frame), sign-in screens |
base-ui search dialog, RootProvider, HomeLayout, the registry build and the dead preview / AI-search / API-page modules |
shipped |
| 4 | The build: app/global.css is plain CSS (faces, ground, color-scheme, the shell footer); no @import 'tailwindcss', no presets, no postcss.config.mjs; apps/docs no longer depends on tailwindcss, @tailwindcss/postcss, tailwindcss-animate, tailwind-merge, class-variance-authority, any @radix-ui/*, @hanzo/docs-base-ui, @hanzo/docs-ui, lucide-react, or the build plugins that emitted their parts (twoslash, auto type table, ts2js) |
— | shipped |
No framework package was deleted, because none is dead. packages/base-ui,
radix-ui, tailwind, story, twoslash, typescript, openapi and the
packages/gui stub each still have consumers in apps/*, examples/* or the
@hanzo/docs wrapper. They leave when those apps move onto the gui parts in
apps/docs/components/ — lift those into a package first, so every docs app
renders one set.
Known gap in @hanzo/ui (8.27.18): its .d.ts augments @hanzogui/web
without depending on it, so in this workspace the augmentation lands on the
hoisted 8.1.1 copy while @hanzo/gui 8.3.5 uses 8.3.5 — every gui shorthand
fails typecheck here (runtime is correct; ignoreBuildErrors is on). The fix is
@hanzo/ui declaring @hanzogui/web/core as dependencies of the same train.
Out of scope when counting: content/docs/projects/** is a snapshot of
upstream repos. The Java/Spring IAM guide uses Bootstrap in a Thymeleaf template
and the IAM login-customization guide uses its own <style> blocks — not Tailwind.
@zenlm/ui ships classes that style nothing (packages/zenlm-ui: Tailwind
utilities, no CSS, no Tailwind dep); apps/zen-docs pins the published tarball.
Converting it to gui style props fixes it by construction.
Baseline when touching this repo
pnpm types:check is not green and never was: 50 of 72 tasks fail at rest.
npx vitest run is 13 failed / 203 passed. Judge a change by the delta against
those sets, not by a clean run you will never get. And docs#types:check failing
tells you nothing about whether a page renders — MDX compile errors do not surface
in tsc. zen5.mdx typechecked clean while the loader skipped the entire page
(<50ms in a table parses as a JSX tag; fence such values in backticks). Run the
app and look at it.
remark-math is on, so two $ in one paragraph or list item typeset the text
between them as math: migrate/stripe.mdx's "floor $1 and ceiling $5,000"
renders as KaTeX on the live site. Write each amount as \$0.021 where a
paragraph holds two; a table cell, a code span and frontmatter are each their
own context and need no escape.
Landing Apps (Moved Out)
Landing page apps were moved to their ecosystem repos:
- Hanzo apps →
~/work/hanzo/apps/ - Lux apps →
~/work/lux/apps/ - Zoo apps →
~/work/zoo/apps/
apps/zap-docs and apps/liquid are gone; their deploy workflows were deleted
because they built directories that do not exist. See apps/ for what is here.