Three docs still told a reader to start a server on every interface: the reference recipe for the share round-trip, the quoted scripts block, and app-wrappers.md. Agents followed those recipes faithfully, which is how twenty publicly bound http.server processes accumulated on this box. All three now bind 127.0.0.1. check:quoted-scripts caught the second one on its own. Also adds the gate-authoring constraint that just broke a build. Plugin and library source is written for Vite, not Node, and fails two ways: the @map alias is unresolvable (hit first), and import.meta.glob is undefined in 18 files including 11 of the 20 plugin index.js. A new check:facet-cascade imported the dashboard config, reached library/colors/_registry.js transitively, and threw -- while already wired into build:unlocked, so the build failed for a reason unrelated to the code being built. The counts in that section were measured, not estimated. A first draft said "every plugin index.js" and the real number is 11 of 20; it also blamed the glob when the first error is actually the alias. States the two rules that follow: build:unlocked is only for gates that run clean in plain Node with no browser and no network, and a gate gets run before it gets wired in -- a broken gate in the build chain is worse than no gate, because it goes red for the wrong reason and trains people to skip the step. Records the other box constraints that have each produced a wrong answer: Node 20 (no fs.glob), bfs find rejecting relative -newermt, no hardware WebGL, and bunx not existing.
4.4 KiB
Architecture — app wrappers
The simple overview is in
library/layouts/README.md. This is the full contract for building and wiring an app wrapper.
Each folder in library/layouts/ is an app wrapper: a host shell that composes doxa-maps plugin-designs
(the independently-built custom elements) into one application.
Terminology (deliberate — don't drift)
| Thing | What it is |
|---|---|
| app-profile | An independently-built, self-contained custom element (a map, a dashboard, a list). These are the micro-frontends. |
| app wrapper (this folder) | The host/shell that mounts plugin-designs, provides nav/chrome/routing, and bounds resources. Not itself a micro-frontend — it's the container. |
A wrapper is a host page, not a regular app-profile. Keeping it a host (rather than a bundle nested inside a bundle) avoids the relative-path/base-URL resolution problem that nesting one position-independent bundle inside another would create.
Wrappers today
app-wrapper-material/— Material-styled wrapper. Structure/behaviour modelled on the Quasar YouTube layout gallery; conventional theming + nav model (--ui-*CSS custom properties, aSidebarNavItem-shaped tree). Two-mode sidebar, self-hosted icons.pane-wrapper/— a second wrapper beside it.
The sidebar contract (nav.js)
NAV is an ordered list. Order = render order, top to bottom. Three kinds:
item— a single entry. At top level these are loose items in no group.section— an always-open labelled group with an icon + name (the YouTube "guide" look).folder— a collapsible group (open: falseby default).
Every entry carries target: 'both' | 'desktop' | 'mobile'.
navForTarget(NAV, target) filters the tree, and is used by both:
- the UI — hides non-matching entries, and
- the packager — the mobile/Capacitor build copies fewer bundles into
webDir.
That's why a Desktop only folder both disappears on phones and is excluded from the app binary.
Sidebar modes (intentionally different per platform)
| Collapsed | Expanded | |
|---|---|---|
| Desktop (≥1024px) | icon rail stays visible (labels hidden) | full width: icons + labels + section headers |
| Mobile (<1024px) | completely hidden (saves space) | overlay drawer + scrim; choosing a profile closes it |
Icons (icons.js)
icons.js ships inline SVG — no CDN, no icon font, no Google/YouTube request. This is a hard
requirement, not a preference: F-Droid builds from source and rejects proprietary blobs / non-free
CDN dependencies, and local icons also mean the wrapper works offline and inside Capacitor from
local assets. Paths follow the Lucide idiom (24×24, 2px stroke, round caps).
Local staging
cd library/layouts/app-wrapper-material
python3 -m http.server 4173 --bind 127.0.0.1
Then open it and narrow the window below 1024px (or use the browser device toolbar) to check the mobile behaviour.
The PROFILES registry (the wrapper's map catalog)
Beside nav.js, the wrapper's index.html declares PROFILES — one entry per mountable map:
const PROFILES = {
'prayer-progress': {
tag: 'doxa-map', // the element to mount (a bundle may register several)
src: './doxa-maps/doxa-simple-map/doxa-simple-map.js', // the bundle IIFE, tree-relative
views: ['map'], // which sub-views (map/list/tree) this profile offers
// + the profile-config the wrapper passes (copied from the generated instance page's BASE_CONFIG)
},
}
nav.js ids route to PROFILES keys; the wrapper lazy-loads src on first visit and mounts
tag. Gotchas: an instance page's tag may differ from the bundle name (doxa-simple-map
registers <doxa-map> too), and colorStrategy values are registry keys derived from
FILENAMES (prayer-progress.js → 'prayerProgress').
How a wrapper becomes the served SPA
bun run shell (internal/install-shell.mjs) copies library/layouts/<wrapper>/ to the root of
public/, so public/index.html is the shell and public/doxa-maps/… are the bundles it
mounts — one served tree, two roles. Copy (not symlink) keeps Railway/Capacitor/file:// happy;
--no-token omits the local-only token.local.js (mandatory for release builds). Capacitor
also requires this root index.html (cap copy hard-fails without it).