VisorVisor
Themes

Private theme gallery

Operator howto for the auth-gated /themes/private route on visor.design.

The /themes/private route renders every theme from @low-orbit-studio/visor-themes-private for live preview on the deployed docs site. The gallery is gated behind Cloudflare Access — only authorized identities can view it. Public visor.design visitors and self-hosters of the open-source repo see no private content.

How it works

  • The docs build attempts to import @low-orbit-studio/visor-themes-private. The package is listed as an optional dependency, so install succeeds even when the registry returns 401.
  • scripts/generate-private-themes.mjs runs in prebuild (and predev). When the package is present, it materializes per-theme CSS and a manifest at app/private-themes.generated.css and lib/private-themes.generated.ts. When the package is absent, both files are empty stubs.
  • The /themes/private route reads PRIVATE_THEMES. When the array is empty, the route returns 404 — that's the public-build behavior.
  • When PRIVATE_THEMES is non-empty, the page renders a switcher and a representative component preview. Cloudflare Access enforces who can reach the route in the first place.

One-time infrastructure setup

These steps must be performed manually by an operator with access to the relevant dashboards. They are not driven by application code.

1. Vercel project secret

Add GITHUB_PACKAGES_TOKEN to the visor.design Vercel project as an environment variable (Production + Preview).

Use a classic GitHub personal access token with only the read:packages scope. Create one at https://github.com/settings/tokens/new. Fine-grained PATs are not reliably accepted by the GitHub Packages npm registry — they return 403 The token provided does not match expected scopes even when the repository permissions appear correct.

The classic PAT is broader than ideal (it has read:packages access across every org and user the creator can see, not just low-orbit-studio/visor-themes-private). Mitigate by:

  • Granting only the read:packages scope — no repo, no admin, nothing else.
  • Setting an expiration (1 year is reasonable for an internal preview gallery).
  • Storing only in Vercel and Bitwarden — never commit it.

The .npmrc that consumes the token lives at the repo root, not inside packages/docs/. npm in workspace mode silently ignores .npmrc files inside workspace packages with the warning ignoring workspace config at .../packages/docs/.npmrc — only the root .npmrc is read during a workspace install.

2. Cloudflare Access policy

Put visor.design behind Cloudflare as a proxied origin (orange cloud), then create an Access application:

  1. Application type: Self-hosted
  2. Application domain: visor.design/themes/private* — the trailing wildcard covers any sub-paths.
  3. Identity providers: GitHub (preferred — restrict to the low-orbit-studio GitHub org) or one-time PIN over email.
  4. Policy: Allow → membership in the low-orbit-studio GitHub org, OR an explicit allowlist of email addresses.
  5. Session duration: 24 hours is a reasonable default.

Confirm the policy by visiting /themes/private from an unauthorized identity (incognito, no auth) — Cloudflare Access should return its login page, never the route's HTML.

3. Adding a new authorized identity

To grant a new operator access:

  1. In the Cloudflare Zero Trust dashboard, open the visor.design Access application.
  2. Edit the Allow policy and add the operator's email or GitHub identity.
  3. Save. The operator can now sign in via the configured identity provider on the next visit.

To revoke access, remove the identity from the policy. Active sessions expire at the configured session duration.

Local development

Set GITHUB_PACKAGES_TOKEN in your shell (or in an untracked .env.local) before running npm install:

export GITHUB_PACKAGES_TOKEN=ghp_xxxxxxxxxxxxxxxxxx
npm install
npm run dev -w packages/docs

/themes/private will be available at http://localhost:4050/themes/private. Without the token, the route returns 404 — same behavior as the production public-build path.

Local development with a sibling checkout

The published-package flow above is the right model for the deployed /themes/private route. For fast iteration on private themes — editing YAML, regenerating CSS, and seeing the result in the docs site without a publish-and-install round trip — use the sibling-checkout flow with visor theme sync.

One-time setup

Clone visor-themes-private as a sibling of the visor repo, or under any sibling parent — both layouts are supported. Pick whichever matches how your other repos are organized.

True-sibling layout (simplest):

cd ~/Code   # or wherever your visor checkout lives
git clone git@github.com:low-orbit-studio/visor-themes-private.git

LO-convention one-level-deeper layout (when LO repos already live under ~/Code/low-orbit/):

cd ~/Code/low-orbit
git clone git@github.com:low-orbit-studio/visor-themes-private.git

The two resulting layouts:

~/Code/
├── visor/                       # this repo
└── visor-themes-private/        # true-sibling layout
    └── themes/
        ├── strata/
        │   ├── theme.visor.yaml
        │   └── meta.json
        ├── entr/
        └── ...
~/Code/
├── visor/                       # this repo
└── low-orbit/                   # any sibling parent dir
    └── visor-themes-private/    # one-level-deeper layout (LO convention)
        └── themes/
            └── ...

Running the sync

The docs site syncs themes automatically on npm run dev -w packages/docs — the predev hook calls theme:sync:fast, which skips the package rebuild when packages/cli/dist/ and packages/theme-engine/dist/ already exist. After a fresh clone, git clean, or branch switch, the next npm run dev regenerates all theme files without manual intervention.

You only need the manual command in two cases — after editing packages/cli/ or packages/theme-engine/ source (to force a rebuild before the next sync), or for a one-off sync without starting the dev server. From inside the visor repo:

npm run theme:sync          # full rebuild + sync
npm run theme:sync:fast     # sync only, reuses existing dist

The full theme:sync builds the local theme-engine and CLI, then runs visor theme sync. The sync command discovers private themes in this order:

  1. VISOR_THEMES_PRIVATE_PATH env var — explicit override; must point at a directory containing {slug}/theme.visor.yaml entries
  2. True-sibling checkout<visor>/../visor-themes-private/themes/ (the convention default)
  3. One-level-deeper checkout<visor>/../<any-dir>/visor-themes-private/themes/ (covers the LO convention ~/Code/low-orbit/visor-themes-private/)
  4. Legacy custom-themes/ flat layout — backwards-compat only; emits a deprecation warning per file

If both true-sibling and one-level-deeper checkouts exist, true-sibling wins and a warning names the suppressed path. If multiple one-level-deeper candidates exist, the alphabetically first wins and a warning names the others. Set VISOR_THEMES_PRIVATE_PATH to override either case.

Override the convention default for non-standard layouts:

VISOR_THEMES_PRIVATE_PATH=/path/to/themes npm run theme:sync

Always use the workspace command

Run npm run theme:sync, never bare visor theme sync, from inside a Visor checkout.

The bare visor command resolves to whatever visor binary is on your PATH — typically the globally installed published package, which bundles a snapshot of @loworbitstudio/visor-theme-engine that lags HEAD. Running it against the working tree silently regresses generated stock theme CSS to whatever the published engine produced.

The sync command refuses to run when invoked through the global binary inside a Visor workspace — it prints a redirect message pointing at npm run theme:sync and exits non-zero. Override with VISOR_SKIP_WORKSPACE_GUARD=1 only if you really know what you're doing.

Adding a new private theme

  1. Add the theme directory under visor-themes-private/themes/{slug}/ (with theme.visor.yaml + meta.json)
  2. Run npm run dev -w packages/docs from the visor repo — the predev step syncs automatically and the new theme appears in the switcher
  3. When ready to ship, follow the publish flow in visor-themes-private/docs/howto/HOWTO_UPDATE_AND_PUBLISH_THEMES.md

Publishing a new private theme

Private themes live in low-orbit-studio/visor-themes-private. Follow the publish flow in that repo's docs/howto/HOWTO_UPDATE_AND_PUBLISH_THEMES.md, bump the version, and push the tag. After the package publishes, bump the dep version in packages/docs/package.json and redeploy. The generator picks up new themes on the next build with no docs-site code change.

Troubleshooting

  • /themes/private returns 404 in production. Check Vercel build logs — the generator logs [generate-private-themes] generated N theme(s) when the package was found. If it logs empty stubs written (package not installed), the GITHUB_PACKAGES_TOKEN env var is missing or invalid for the build environment.
  • Vercel build log warns npm warn config ignoring workspace config at .../packages/docs/.npmrc. The .npmrc is in the wrong place. It must live at the repo root — npm refuses to read .npmrc files inside workspace packages during a workspace install.
  • Build fails or token is rejected with 403 The token provided does not match expected scopes. Fine-grained PATs are not reliably accepted by GitHub Packages. Re-issue as a classic PAT with only the read:packages scope.
  • /themes/private is reachable without sign-in. Cloudflare Access policy is missing or misconfigured. Verify the application domain pattern includes the trailing wildcard and that the proxied (orange-cloud) DNS record is in place.
  • Build fails with 404 Not Found from npm. The token has no read access to the low-orbit-studio/visor-themes-private repo. Re-issue the token with read:packages scope as the repo's owner.