LibrePortal/docs/frontend-modularization.md
librelad 22aafe3a55 docs: frontend feature-module modularization design
Synthesized architecture for turning the no-build vanilla-JS WebUI into a
scan-and-manifest feature system mirroring the backend container scan:
self-contained features/<id>/ folders, a navigation kernel, uniform
mount/unmount lifecycle, DI service context replacing ~80 window globals,
per-feature CSS, god-file decomposition, and a strangler migration roadmap.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Signed-off-by: librelad <librelad@digitalangels.vip>
2026-05-29 22:10:36 +01:00

500 lines
56 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# LibrePortal WebUI — Feature-Module Architecture (Design Doc)
**Status:** Proposed · **Audience:** implementing engineer · **Scope:** `containers/libreportal/frontend/` (no-build vanilla-JS SPA)
---
## 1. Executive summary
We turn the frontend into a **scan-and-manifest feature system** that mirrors the backend's container scan: each page becomes a self-contained folder (`features/<id>/`) carrying its own HTML fragment, scoped CSS, controller, and a `feature.json` manifest. A single shell generator — hung off the existing `webuiLibrePortalUpdate`/`libreportal regen` pass that already emits the apps-tools artifact — walks those folders and writes one read-only `/data/webui/generated/features.json`. A small **navigation kernel** consumes that manifest to build the route table, the topbar nav, and the per-feature CSS links, replacing the four hand-maintained registries (`index.html`, `spa.js` `routes` Map, `system-loader.js` component registry, `config-manager.js` `renderConfig()` if-chain). Every feature implements **one uniform lifecycle** (`mount(ctx)` / `unmount(ctx)`) and receives shared services (taskBus, liveSystem, auth, dataLoader, router, notifications) via an injected `ctx` instead of ~80 `window.*` globals. We stay **no-build by default** (plain `<script>`/`<link>`, lazy per route), preserve the theming module and flash-free first paint untouched, and decompose the three 100KB+ god-files as their feature is migrated — strangler-style, shippable at every commit, visually verified with `lp-shot`.
This is **Approach #1 (FeatureModule + Registry + ServiceContext)** as the spine, grafted with:
- the **shell-generator discovery** + app-shipped frontends from #3 (Scan-and-Manifest),
- the **optional esbuild release chunker** from #3 (flagged as *unproven* — see §11),
- the **`disconnectedCallback`-grade teardown discipline** (auto-revoked subscriptions via `AbortController`/ctx-tracked unsubs) and **light-DOM-by-default** CSS reasoning from #2 (Custom Elements) — *without* adopting Custom Elements, because the global-cascade CSS (nebula's `[data-theme]` component overrides) would not pierce shadow roots and the migration cost is higher for no incremental gain.
**Two honest caveats up front, because they reshape the plan:**
1. **The "zero central edits to add a feature" headline is only true in the steady state, *after* the enabling infrastructure exists.** Building that infrastructure is itself a heavily central change: a new generator script, a new staleness predicate in `webui_regen.sh`, a new ordered call site in `webui_updater.sh`, and two source-array regens (`files_webui.sh` + `function_manifest.sh`). The first-mover cost is high; the per-feature payoff is real but back-loaded. We state this plainly rather than selling the headline.
2. **The CSS modularization is a separate hardening track, not a side effect of the JS work.** It is the single most regression-prone area (verified `style.css` is 82KB / 3884 LOC / 67 `!important`), and it rests on documented cross-feature borrowing that breaks naive "remove CSS on unmount" and naive `[data-feature]` prefixing. We treat it as its own track with explicit rules (§5) and gate every extraction with `lp-shot`.
---
## 2. The Feature Module contract
A feature is a folder. Two artifacts define it: a **declarative manifest** (read by the shell scan) and a **runtime registration** (the lifecycle the kernel drives). They describe the same thing so the optional bundler path works from the same source of truth.
### 2.1 Sample folder layout
```
frontend/features/backup/
feature.json # manifest — read by the generator at regen time
index.js # calls LP.features.register({...}) at eval time
backup.html # fragment (was /html/backup-content.html)
backup.css # scoped, lazy-linked on mount (was eager css/backup.css)
center.js # the BackupCenter view (decomposed god-file)
tabs/ # nested registry: dashboard|snapshots|locations|migrate|configuration
app-card.js # the per-app Backups tab provider (exported to the apps feature)
retention.js cron.js loc-schema.js backup-data.js # extracted sub-modules
```
### 2.2 `feature.json` (read by the scan)
```jsonc
{
"id": "backup",
"routes": ["/backup", "/backup/*"], // wildcard matches today's '/backup*' precedence
"nav": { "label": "Backups", "icon": "icons/backup.svg", "order": 50, "group": "main" },
"fragment": "backup.html",
"css": ["backup.css"], // injected on mount, ref-counted, removed on unmount
"cssRequires": ["config-engine"], // CSS-dependency declaration (see §5.2) — borrowed donor sheets
"scripts": ["index.js"], // load order preserved (deps before consumers)
"preload": false, // false = lazy on first navigation (cold-load win)
"requires": ["tasks", "config-engine"] // shared-service (JS) deps the kernel resolves before mount
}
```
`requires` is **JS service dependency** (the kernel awaits those services ready before `mount`). `cssRequires` is **CSS dependency** (the css-manager pins the donor feature's stylesheet so a borrowing feature renders styled even when reached without first visiting the donor route — see §5.2). They are distinct mechanisms; conflating them was a gap in the draft.
### 2.3 `index.js` (the runtime contract — the uniform lifecycle)
```js
// features/backup/index.js
LP.features.register({
id: 'backup',
routes: ['/backup', '/backup/*'],
nav: { label: 'Backups', icon: 'icons/backup.svg', order: 50 },
css: ['/features/backup/backup.css'],
// ONE boot method. Replaces today's divergence:
// new BackupPage().init() | appsManager.initialize() | configManager.renderConfig() | bare loadDashboardData()
async mount(ctx) {
// ctx = { root, params, query, services, on, sub, loadFragment, nav }
ctx.root.dataset.feature = 'backup'; // CSS scoping hook (see §5)
ctx.root.innerHTML = await ctx.loadFragment('/features/backup/backup.html');
this._view = new BackupCenter(ctx.root, ctx.services);
// Live-stream + listener teardown is FIRST-CLASS. ctx.sub / ctx.on auto-revoke on unmount.
ctx.sub(ctx.services.live.subscribe(sample => this._view.onLiveSample(sample)));
ctx.on(ctx.services.tasks.bus, 'taskCompleted', e => this._view.onTaskDone(e));
ctx.services.tasks.refresh.register({ id: 'backups', actions: ['backup','restore'],
run: () => this._view.refreshAll() }); // auto-removed on unmount
await this._view.init();
},
async unmount(ctx) {
this._view?.dispose(); // clear intervals/timers the view owns
this._view = null;
// ctx auto-revokes every ctx.sub() / ctx.on() / refresh.register() opened during this mount.
}
});
```
### 2.4 The lifecycle driver (`kernel/lifecycle.js`)
The kernel owns a per-mount **subscription ledger** (the discipline borrowed from #2's `disconnectedCallback`/`AbortController`, achieved here without Custom Elements):
```js
class MountContext {
constructor(root, route, services) {
this.root = root; this.params = route.params; this.query = route.query;
this.services = services;
this._ac = new AbortController(); // for addEventListener({signal})
this._unsubs = []; // for SSE / bus / refresh handles
}
loadFragment(url) { return this.services.data.fragment(url); } // cached fetch
nav(path) { return this.services.router.navigate(path); }
sub(unsub) { if (typeof unsub === 'function') this._unsubs.push(unsub); return unsub; }
on(target, ev, fn) { // target = window | bus | element
target.addEventListener(ev, fn, { signal: this._ac.signal });
}
_teardown() {
this._ac.abort(); // kills every addEventListener
this._unsubs.forEach(u => { try { u(); } catch {} }); // kills SSE/bus/refresh subscriptions
this._unsubs.length = 0;
}
}
```
On `navigate(path)`:
1. resolve route from the registry-built Map; parse `params`/`query` **once** here (kills the duplicated `getAppFromURL`/`appPartsFromPath` parsers in `spa.js`, `apps-manager.js`, `app-tabbed-manager.js`).
2. run the **dirty-nav guard** (preserve `window.__appConfigNavGuard` semantics — apps' config form sets it).
3. `await current.unmount(ctx)` then `ctx._teardown()` — this is the teardown the tree lacks today (the never-removed document listeners on `AdminSystem`/`SshPage`/`PeersPage`, the dead 2 s `startGlobalLiveLogUpdater` interval, the dashboard's "is `#disk-donut` still in the DOM?" poll).
4. ensure target feature's `scripts`+`css` loaded (idempotent `asset-loader.loadScripts`, the dedup loader already in `spa.js:474`), pin any `cssRequires` donor sheets, and `await` its `requires` services ready.
5. `await target.mount(new MountContext(root, route, services))`.
> **Invariant preserved:** the task SSE bus (`taskEventBus`, one `EventSource` on `/api/tasks/events`) is a **shared singleton** — `unmount` releases only *this view's* subscriptions, never `bus.stop()`. The bus's "suppress ghost completion for first-seen-already-terminal" rule and BFCache `pagehide→stop / pageshow→start` (today `spa.js:642`) move to `kernel/bootstrap.js` unchanged.
> **Invariant preserved (auth fetch ordering):** `auth-manager.interceptFetch()` replaces `window.fetch`, and today runs *after* `auth.initialize()` and *before* any feature fetch (verified `system-orchestrator.js:25-26`). The kernel bootstrap MUST install the fetch interceptor before any feature `mount()` (and before `feature-registry` fetches `features.json`). This ordering is a hard invariant, not an incidental — pinned in §4 and Phase 0.
---
## 3. Registry & discovery mechanism
### 3.1 Mirroring the backend container-scan — and where the mirror is imperfect
The backend's "snap a folder in and it works" is the shell scan in `scripts/source/loading/scan_files.sh`, followed by `webuiLibrePortalUpdate` regenerating `/data/*.json` that the SPA fetches read-only. The proven precedent is `<app>.tools.json` → aggregated apps-tools artifact → consumed by `tools-manager.js`.
**The mirror is imperfect in a way that matters, and the draft conflated it.** There are **two backend roots with two different permission models**, and the existing apps-tools generator already distinguishes them:
| Root | Owner | Read access | Used for |
|---|---|---|---|
| `install_containers_dir` (`$LP_SYSTEM_DIR/install/containers/`) | **manager** | plain `find` OK | app **install templates** — this is where `*/tools/*.tools.json` is read from (NOT the live tree) |
| `containers_dir` (`$LP_CONTAINERS_DIR`) | **container-user / root** | **NOT list-readable by manager** — requires `runFileOp` (`scan_files.sh:52-54`) | the **live** app data tree, live `app_configs` |
Consequences for our generator (these are corrections, not options):
- **Core features** (`containers/libreportal/frontend/features/`) live, on a live install, under the **container-owned `containers_dir`**. Reads there — and the write of the generated artifact — must go through **`runFileOp`**, exactly as the existing webui generator does (verified `webui_tools.sh:53` writes via `runFileOp mkdir`/write). Plain shell I/O EACCEScs on a real (rootless / three-root) install.
- **App-shipped frontends** are *not* readable with a plain `find "$CONTAINERS_DIR"/*/frontend` — that root is container-owned. The only proven precedent reads app contributions from **`install_containers_dir`** (manager-owned templates), not from the live app tree. So app-shipped feature frontends must either (a) be sourced from the manager-owned install-template root like tools are, or (b) be read via `runFileOp`. **Decision for v1: app-shipped feature frontends are read from the same manager-owned install-template root the apps-tools scan uses, via the same access path.** Reading live container-owned frontends via `runFileOp` is deferred (see Open Questions §11).
### 3.2 The new generator
Add `scripts/webui/data/generators/webui_feature_scan.sh`, invoked by the existing `webuiLibrePortalUpdate` regen pass. It mirrors the apps-tools generator's **roots and permission model**, NOT a single naive find:
```sh
# CORE features: live under the container-owned containers_dir on a real install.
# Read directory listing + each feature.json via runFileOp (NOT plain find).
runFileOp find "$LP_CONTAINERS_DIR/libreportal/frontend/features" -maxdepth 2 -name feature.json
# APP-shipped feature frontends: read from the MANAGER-OWNED install-template root,
# the same root the apps-tools scan uses — plain find OK here.
find "$install_containers_dir"/*/frontend -maxdepth 2 -name feature.json -print0 # v1 scope
# validate each (schema + duplicate-route detection); concatenate ->
# /data/webui/generated/features.json [{id,routes,nav,fragment,scripts,css,cssRequires,preload,requires}]
# WRITE the artifact via runFileOp (mkdir -p the new data/webui/generated subtree, then write).
runFileOp mkdir -p "<frontend>/data/webui/generated"
runFileOp write "<frontend>/data/webui/generated/features.json" "$json"
```
Served by the existing `express.static('/data', requireAuth + no-store)` (`utils/middleware.js`) — **no new Node route, no runtime scan, no mutating endpoint** (honors "mutations via tasks"). This is the same class of read-only generated GET as the apps artifact.
**Ownership of the new subtree:** `frontend/data/webui/generated/` is a **new subtree**. `update.sh` reconciles ownership for the whole frontend, but `data/` is **`rsync --delete --exclude 'data/'`-excluded** on deploy, so this directory is created at *regen* time and must be (a) created via `runFileOp mkdir -p` and (b) chowned to the **container user** so express.static can serve it. The generator must call the ownership helper for the freshly created `data/webui/` path — do **not** assume the frontend-wide reconcile covers it.
> **DO NOT "wire the dead env `FRONTEND_PATH`."** The draft proposed using `docker-compose.yml`'s `FRONTEND_PATH=/data/frontend` as the scan root. This is **app-breaking and is removed from the design.** Verified: `backend/utils/config.js` computes `FRONTEND_PATH = path.join(__dirname,'..','..','frontend') = /app/frontend`, and compose mounts **only** `./frontend:/app/frontend`. The env value `/data/frontend` is **mounted nowhere** (`grep '/data/frontend:'` → 0 hits). If `config.js` were changed to honor the env, `middleware.js:73/76` `express.static` would serve from an empty/absent path — **every JS/CSS/HTML asset, `index.html`, and all `/data/*.json` would 404, taking down the entire WebUI.** The relative compute is correct and load-bearing. The scan root is the source-tree `features/` path resolved server-side (per §3.1), not derived from this env.
### 3.3 Staleness predicate (required — the "self-heal" claim is false as written)
The draft claimed `feature.json` changes "inherit the `lpRegen` front door + `find -newer` self-heal." **This is false as written and must be fixed before relying on it.** Verified in `webui_regen.sh`: `lpRegenWebui` marks work stale only when `*.config` (`install_containers_dir`, maxdepth 2) or `*/tools/*.tools.json` (maxdepth 3) are newer than their artifacts. A new/changed `feature.json` matches **neither** pattern, so regen no-ops and `features.json` never refreshes on natural triggers; `libreportal regen` would silently do nothing for feature changes until `--force`.
**Fix:** add an explicit staleness predicate to `lpRegenWebui` that treats `features/**/feature.json` (and, for the app-template root, `*/frontend/**/feature.json`) newer than `data/webui/generated/features.json` as stale, mirroring the existing `find -newer` predicates. This is a required edit, listed in §9 Phase 0 and §12.
### 3.4 Source-array + sequence registration (required — runtime-fatal if skipped)
Per the "New .sh files need array regen" rule, `webui_feature_scan.sh` must be registered in **both** source arrays before it can be called on a real install:
- `scripts/source/files/generate_arrays.sh` → regenerates `files_webui.sh` (so the file is sourced),
- `scripts/source/files/generate_function_manifest.sh` → regenerates `function_manifest.sh` (so under the default lazy loader `LP_LAZY=1` the generator function autoloads on first call).
Verified both generator scripts exist and webui generators are already listed in `files_webui.sh` + `function_manifest.sh`. Omitting either means the generator function is never sourced/autoloaded and the regen call **fails at runtime** on a real install. Both regenerated files must be committed.
**Sequence wiring (a real central edit, not auto-discovered):** `webui_updater.sh` is a **fixed ordered list** of named generator calls (`webuiSystemUpdate`, `webuiCreateCategories`, `webuiGenerateAppsServicesConfig`, `webuiGenerateAppsToolsConfig`, …). `webuiFeatureScan` is **not** auto-discovered; it must be added as an explicit line in that sequence. This is part of the central enabling work the headline hides — we own it openly.
### 3.5 Generator failure mode — degrade, never abort the pass
`webuiLibrePortalUpdate` runs inside the `libreportal.service` task-processor poll **and** on deploy, regenerating many artifacts (apps.json, categories, …). A **hard failure** in the feature scan (e.g. a malformed `feature.json`, a duplicate route) must **not abort the whole pass** and starve those other artifacts. Therefore:
- **Duplicate-route / schema errors at scan time → skip the offending feature, emit a warning into the artifact and the regen log, continue.** Do not `exit 1` the pass.
- **Runtime last-wins** remains a console warning in the kernel as a backstop.
This is a correction to the draft's "build error" framing, which would have made a bad feature able to block the entire regen.
### 3.6 Runtime registration
At cold-load, `kernel/feature-registry.js` does `fetch('/data/webui/generated/features.json')` (same pattern as `DataLoader.loadApps`) and builds:
- the routes Map (replaces `spa.js setupRoutes`),
- the topbar nav, sorted by `nav.order` (replaces hand-authored `topbar.html` + the **two** highlight maps in `spa.js updateNavigation` and `topbar.js getCurrentPage`),
- the CSS link set (lazy).
For each entry it registers a **lazy stub**: the first navigation to a feature's route injects its `scripts[]` + `css[]` (and pins `cssRequires` donors), the `index.js` calls `LP.features.register(...)` with the live module, then `mount()` runs.
### 3.7 Build / codegen — **none by default; esbuild optional (release-only, UNPROVEN)**
Default path is **codegen-only**: the generator emits plain JSON; the kernel lazy-loads individual `<script>`/`<link>` exactly as `spa.js`/`system-loader.js` already do. This preserves the no-build model that is a hard constraint (`.dockerignore` excludes frontend, `Dockerfile` copies only backend, compose bind-mounts `./frontend`, `*.html` served no-cache → edit-and-it's-live; the deploy hook chain copies files, it does not run a bundler).
**Optional and explicitly unproven** (see §11): an esbuild step gated behind `CFG_INSTALL_MODE=release`, run inside `make_release.sh`, reading the *same manifests* and emitting per-route chunks + `chunks.json`. The kernel loads chunks if `chunks.json` is present, else falls back to per-file. esbuild is **rejected as a default** because it would break the bind-mount deploy. It is **not committed for v1** — adding a node toolchain and a JS build artifact to the checksum/minisign release-tarball flow is non-trivial and has not been verified to have a home in `make_release.sh`. Treat as a research spike, not a deliverable.
---
## 4. Shared services (kill the `window.*` IPC bus)
Today ~80 mutable globals are the integration bus (verified ref counts under `js/`: `notificationSystem` 20, `tasksManager` 17, `configManager` 11, `spaClean` 8, `appTabbedManager` 8, `taskEventBus` 7 — higher across the whole tree). Replace with a small DI container built once at boot and injected via `ctx.services`:
```js
ctx.services = {
tasks: { // the ONLY mutation path — funnels to libreportal CLI
route(action, params), // was tasksManager.router.routeAction (mutations-via-tasks preserved)
client, // REST CRUD over /api/tasks (was TaskManager)
bus, // single SSE EventSource -> CustomEvents (was taskEventBus)
refresh // register({actions,run}) (was taskRefresh-coordinator)
},
live, // LiveSystem SSE: subscribe()->unsub, pause()/resume()
auth, // auth-manager: logout, status, interceptFetch (installed at boot, fetch-replace ordering pinned)
data, // DataLoader: loadApps/loadCategories/fetchJson + cached fragment()
notify, // NotificationSystem toast engine (app-specific nav handler moves OUT to apps feature)
theme, // ThemeRegistry (unchanged module)
modal, // eo-modal toolkit
config, // the extracted config-form engine (shared by admin + apps + backup)
router // navigate(), setNavGuard(), appPath/adminPath helpers
};
```
The three task kernel files (`task-event-bus.js`, `task-manager.js`, `task-refresh-coordinator.js`) are **already DOM-free single-purpose singletons** — they move to `shared/services/` near-verbatim. This also collapses the **two-boot-path** blocker: `task-refresh-coordinator.js` (today an eager `<script>` at `index.html:97`) becomes a registered service, so the tasks folder can finally snap out without orphaning a `<script>` 404.
### Boot ordering invariants (pinned)
The DI container is **created once, at the very top of `kernel/bootstrap.js`, before anything else can construct a manager**, and the back-compat shim aliases to **the same singletons** (next section). The non-negotiable order is:
1. install `auth` + `auth.interceptFetch()` (replace `window.fetch`) — **before any fetch**, including the registry fetch.
2. construct the DI singletons (`tasks.bus` opens the one `EventSource` early so boot upserts aren't missed; `tasks.client`, `tasks.refresh`, `live`, `data`, `notify`, `theme`, `modal`, `config`, `router`).
3. install the `window.*` shim aliasing to those exact singletons.
4. run `feature-registry` fetch + build.
5. dispatch the first route.
`task-refresh-coordinator`-as-a-service (step 2) MUST exist before any eager `dashboard.js`/`update-notifier` registration fires (these reference `window.taskRefresh`); during the migration window the shim guarantees they see the same `tasks.refresh`. This load-order sensitivity is called out as a migration risk in §10 and sequenced in PR 3.
### Migration of globals (back-compat shim)
To stay incremental, boot installs a **deprecation shim** so un-migrated files keep working, **aliasing to the same singletons the kernel uses** (this is what prevents the two-`AppsManager`/two-`TasksManager` class of divergence bug — verified live at `app-tabbed-manager.js:37-38` `new TasksManager()+new AppsManager()` and `apps-manager.js:3373` fallback `new TasksManager()`):
```js
// shim deleted file-by-file in the final phase, when grep shows zero non-test consumers
window.tasksManager = { router: { routeAction: ctx.services.tasks.route }, taskManager: ctx.services.tasks.client, ... };
window.taskEventBus = ctx.services.tasks.bus;
window.notificationSystem = ctx.services.notify;
window.navigateToRoute = ctx.services.router.navigate;
// ...etc for the consumers enumerated in the map
```
A migrated feature using `ctx.services.tasks.bus` and an un-migrated file using `window.taskEventBus` observe the **same** bus — they interoperate. **Critical invariant:** the shim is created in step 3 above, *before* `SystemLoader`-built globals or any eager script can `new` up its own manager; if the shim were created after a `SystemLoader` bundle constructed a second instance, we would re-introduce the exact two-instance divergence bug. The shim must alias, never instantiate fresh.
---
## 5. CSS & theming per module
Two layers, matching the existing token/presentation split, preserving the look exactly. **This section is a hardening track in its own right** — the most regression-prone part of the project (`style.css` verified 82KB / 3884 LOC / 67 `!important`). Gate every change with `lp-shot`.
### 5.1 Token layer — left intact, only improved
- `themes/<name>/theme.css` under `[data-theme]` stays the self-registering folder-drop module (`theme-registry.js` + `GET /api/themes/list`). It is **already the model**; do not touch it. The inline first-paint `document.write` bootstrap in `index.html` stays verbatim (flash-free), and theme switching stays a zero-network `data-theme` flip.
- **Add one central default layer** `shared/css/tokens.css :root{}` declaring every palette token, **hoisting the `--page-*` identity hues** currently stranded in `css/admin.css :root` (verified: `--page-updates/-verify/-backups/-ssh/-system` at admin.css:812), and **defining `--font-mono`** (verified consumed but undefined at `ssh.css:87`, `backup.css:794`). Themes then *override* rather than fully redefine — killing the add-token-to-4-files burden and the undefined-token drift. nebula's `[data-theme=nebula]` component overrides stay as-is; `.admin-action-btn`'s `rgba(var(--page-rgb, var(--accent-rgb)), …)` continues to work unchanged.
### 5.2 Presentation layer — per-feature, scoped, lazy — with explicit borrowing rules
The draft's two CSS assumptions were wrong; here are the corrected rules.
**Cross-feature borrowing is real and must be modeled (corrected).** Verified: `ssh.css` header explicitly reuses `backup.css`'s `.backup-ssh-key-card` **and** `config.css`'s `.config-category`. Collision counts: `.btn-secondary` in 6 files, `.modal` in 4, `.config-category` in 4, `.task-item` in 6. If a feature's `<link>` is removed on unmount, a feature that **borrowed** its classes renders unstyled whenever reached without first visiting the donor route. Two mechanisms close this:
1. **Promote genuinely shared rules into the always-present base/UI layer.** Truly shared rules (reset, scrollbars, base buttons incl. `.btn-secondary`, `.tab-navigation`, `.modal` base, `.notification`, `.task-item`, `.config-category`, aurora) move from `style.css` into `shared/css/base.css` + per-component `shared/ui/<component>.css`, which are **eager and never removed**. A class borrowed across features is, by definition, shared and belongs here. This eliminates most borrowing.
2. **For anything still borrowed feature-to-feature, declare `cssRequires` in `feature.json`** (e.g. ssh borrowing a backup-specific card → `"cssRequires": ["backup"]`). The css-manager **pins** the donor sheet (ref-count never drops to zero while a borrower is mounted) so the borrowing feature renders styled regardless of navigation order. This is the CSS-side analogue of `requires`, and it is a **new required mechanism** the draft lacked.
**Ref-counted injection (unchanged intent, with the borrowing fix above):** each feature's CSS is injected as a `<link>` **on mount, removed on unmount**, ref-counted — the `theme-registry.linkThemeCss()` dedup pattern generalized into `kernel/css-manager.js`. Removing `features/backup/` removes `backup.css`'s 35KB from the page lifecycle entirely (today a permanent eager `<link>`) — *except* sheets pinned by an active borrower's `cssRequires`.
**Scoping — corrected specificity claim.** The draft asserted `[data-feature="x"]` prefixing is "additive at the same specificity rank." **That is false:** adding an attribute selector raises specificity by one attribute-selector level (0,0,1,0 added), which can flip cascade order against existing rules and against the `!important` wars. So:
- We **do not** mechanically prefix existing rules. Scoping is applied **only to genuinely new per-feature rules**, and where a prefixed rule must override an unprefixed one, parity is verified by `lp-shot`, not assumed.
- Where prefixing would change the winner, prefer **moving the rule to base** or **using a single, documented specificity bump** rather than relying on attribute-selector accident.
- `mount()` still sets `ctx.root.dataset.feature = id` as a *namespacing hint* for new rules and for debugging, not as a correctness-by-specificity guarantee.
**Eager set shrinks** from 26 stylesheet links to: `tokens.css` + `base.css` + the relevant `shared/ui/*.css` + the theme bootstrap + the pre-feature essentials (`loading-screen.css`, `login.css`, `aurora-background.css`). Note the eager set is **only meaningfully reduced once `style.css` is split (Phase 7)** — the cold-load CSS win is back-loaded to the last, riskiest phase (see §8 and §11).
> **`lp-shot` gate (per CLAUDE.md):** before merging each feature's CSS extraction, `lp-shot` the affected route across all built-in themes (`nebula`/`dark-blue`/`light`) and Read the PNG to confirm pixel parity. Extract `base.css`/`shared/ui` first (Phase 1) so the visual baseline is locked before any JS moves.
---
## 6. Routing — per-module ownership
`spa.js`'s `routes` Map + `handleX()` methods (verified lines 6683, incl. legacy `/config`,`/peers`,`/ssh` redirects) are replaced by registry-built routing in `kernel/router.js`:
- Each feature declares `routes[]`; the registry builds the Map from `features.json`. The longest-prefix rule that makes `/apps*` precede `/app*` today is encoded as **route specificity = path length**. *This is a plausible-but-unverified equivalence* (see §11) — Phase 0 includes a route-resolution test that diffs the length-sort against the current `spa.js` behavior on the known edge cases (`/apps*` vs `/app*`, `/admin/system/storage` vs `/admin/system`) before the kernel router becomes authoritative.
- **Duplicate-route detection** at scan time (degrade-and-warn, never abort — see §3.5) and runtime (last-wins warning) ends the two-router race; the vestigial `js/utils/router.js` is deleted and its neon loading bar extracted to `shared/ui/progress.js` (the only thing `app-manager.js`/`config-router.js` consumed from it).
- **Sub-routes** (`/app/<name>/<tab>`, `/admin/system/storage`) dispatch via the feature's **nested registry** (tabs/admin-pages), so deep navigation unmounts only the inner tab, not the whole feature.
- **Legacy URLs:** keep the redirect manifests as first-class entries — a tiny `redirects` array in the kernel maps `/config*→/admin`, `/peers*→/admin/tools/peers`, `/ssh*→/admin/tools/ssh-access`. The backend `app.get('*')` catch-all (`routes/routes.js`) still returns the shell, so a stale bookmark is a clean **client-side** not-found rendered by the kernel's fallback route.
- **Single history owner.** `setup-completion-watcher.js` monkey-patches `history.pushState/replaceState` globally as an eager self-invoking IIFE at `index.html:103` (verified). The absorption into `kernel/router.js` must be **sequenced in Phase 0** — if both the watcher's patch and the kernel's history ownership coexist mid-migration, double `pushState` results. Phase 0 removes the IIFE the same commit the kernel takes ownership; there is exactly one history owner per phase.
---
## 7. God-file decomposition
### `apps-manager.js` (4154 LOC / 176KB) → `features/apps/`
- `apps-grid.js` (grid + sidebar + search), `app-detail-shell.js` (header + install/uninstall/update via `ctx.services.tasks.route`).
- `config-form-engine.js` (CFG_* field generation, gating, `showWhen`, advanced toggles, password/domain modes) **built on** `shared/config-engine`.
- `dirty-nav-guard.js` (the `__appConfigNavGuard`, now wired through `lifecycle.unmount`), `install-dispatch.js` (gluetun/mullvad/welcome/uninstall modal pack).
- `service-links.js` (`ServiceButtons` + `expandServiceLinks`) **moved to `shared/ui/`** — dashboard.js depends on it, so this severs the hidden cross-feature dependency.
- `port-codec.js` — the single pipe-delimited `CFG_*_PORT_N` parser (kills the 3 duplicate parsers in port-manager/routing-manager/ServiceButtons).
- **Nested tab registry** `features/apps/tabs/{config,services,tools,routing,backups,tasks}/`: each exports `{id,label,icon,isApplicable(appData),mount,unmount}`. `services/tools/routing-manager.js` are already this shape — promote them. The hardcoded tab list (today triplicated across `apps-unified-layout.html` buttons, `app-tabbed-manager` `switchTab`/`disableTabs` arrays, and `apps-manager` `renderAppDetail`) collapses into iteration.
### `backup-page.js` (2553 LOC / 129KB) → `features/backup/`
- One module per tab (`tabs/{dashboard,snapshots,locations,migrate,configuration}.js`), `modals.js`, `retention.js`, `cron.js` (the bespoke parser), `loc-schema.js` (`BACKUP_LOC_*` maps), `backup-data.js` (7-endpoint fetch client).
- `app-card.js` (`BackupAppCard`) becomes the feature's **exported tab provider** mounted by `features/apps/tabs/backups/` — backup *owns* its per-app surface (ends `app-tabbed-manager` reaching for a global class).
- Delete dead `openLocationModal_unused` + `#backup-location-modal` siblings; dedupe `escape`/`formatRelative`/`fetchJson`/`_fmt*` into `backup-data.js`.
### `tasks-manager.js` (2664 LOC / 109KB) → `shared/services/` + `features/tasks/`
- Kernel files → `shared/services/{task-client,task-bus,task-refresh}.js` (already clean).
- `features/tasks/{list,filters,log-stream,modals}.js`. **Use only `/api/tasks` REST**; delete the parallel `/read-file?path=tasks/queue.json` file-scan path, the **three duplicate `init()`** defs, the dead 2 s `startGlobalLiveLogUpdater` loop, and the 3 construction sites. The ~30 `window.*` onclick globals become delegated listeners bound in `mount()` (via `ctx.on`) and auto-removed on unmount. The `__tasksManagerBusBound`/`__taskMetaLinksBound` guards become unnecessary.
### `config-shared.js` (1558 LOC / 62KB) → `shared/config-engine/`
- Split the field factory (`generateField` + range/crontab/password builders + category grouping) from the ~6 near-duplicate `toggleX` section functions and the global helpers (`handleInstallModeChange`/`handleToggleChange`). It is consumed by **admin + apps + backup**, so it becomes a shared library, not admin-scoped. Keep `window.ConfigShared` as a re-export shim until all three consumers migrate. Drop the second factory `config-renderer.js` and the dead `config-router.js`.
- **Encode the implicit init-order coupling as `requires`.** Verified load-order fragility: `toggle-manager.js` depends on `config-shared.js` having loaded first; `ConfigManager` constructs 7 sub-managers. The `shared/config-engine` module must expose a single ready promise so `requires: ["config-engine"]` truly means "fully built," not "constructor returned" — otherwise `mount()` runs against a half-built service (see §11).
### `system-loader.js` (1343 LOC / 47KB) → `kernel/`
- The hardcoded `initializeComponentRegistry()` dies — features self-register from `features.json`. The weighted health-check engine + config-file validation + icon preloading split into `kernel/health.js` (a thin pre-render gate). **Preserve `config-validator.js`'s behavior:** it HEAD-checks `configs.json` and builds an **error overlay** if missing — this is the only guard against a missing `configs.json`. `kernel/health.js` keeps the `config-files` critical check *and* the validator's overlay; icon preloading moves to a non-blocking idle task. Do not silently drop the overlay when "reducing health to one critical check."
- The dynamic script loader is the existing `asset-loader.loadScripts`. The boot sequence merges into `kernel/bootstrap.js` (was `system-orchestrator.js`).
### Setup wizard / first-run flow (was unaddressed — now specified)
`setup-detector.js`, `setup-wizard.js` (~878 LOC), `setup-completion-watcher.js`, `/api/setup/*` do **not** fit the normal route model: the wizard **gates the whole app pre-render** and has a `sessionStorage` handoff to `/tasks`. Treatment:
- The wizard is a **pre-kernel gate**, not a feature: `kernel/bootstrap.js` runs `setup-detector` *before* `feature-registry` fetch; if first-run, it mounts the wizard and short-circuits routing.
- The `sessionStorage` handoff to `/tasks` must survive the rewrite — `features/tasks` reads the same key; covered by a dedicated `lp-shot` of the first-run → tasks transition in Phase 4.
- `setup-completion-watcher.js`'s history monkey-patch is absorbed into `kernel/router.js` in Phase 0 (see §6); its completion-detection role becomes a `tasks.bus` subscription in the wizard gate.
---
## 8. Performance budget
**Before (every route, regardless of landing):** ~26 eager stylesheets (~16k LOC CSS incl. `backup.css` 35KB, `admin.css` 42KB) + ~23 eager scripts; a fixed `setTimeout(2500ms)` in `handleNormalLoading` (verified `system-orchestrator.js:145`); `spaClean.waitForTopbar()` 2 s poll (verified `spa.js:27/38`); 5001000 ms health-check sleeps.
**After:**
- Shell loads only `kernel/bootstrap.js` + `tokens.css` + `base.css` + `shared/ui/*` + theme bootstrap + pre-feature essentials. Feature `scripts[]`+`css[]` load lazily on first navigation. Landing on `/dashboard` pulls dashboard CSS/JS only.
- Remove the baked-in latency: the kernel **awaits explicit service-ready promises** instead of `setTimeout(2500)`/`waitForTopbar` polls. **But not before the consumers are migrated** — see the migration risk below.
- Task SSE bus opens once early (preserved) so boot upserts aren't missed.
- `preload: true` in a manifest opts a hot feature back into eager load if measurement shows a regression.
**Stays eager:** theme palette (`document.write`, first-paint critical), `tokens.css`/`base.css`/`shared/ui`, auth gate, the task SSE bus.
**Performance risks — measured, not asserted:**
- **The cold-load CSS win is back-loaded to Phase 7.** The "26 → ~5 eager stylesheets" reduction depends on splitting `style.css` (82KB / 3884 LOC), which is the last and riskiest phase. Until then the eager set is barely reduced — do not advertise the cold-load win before Phase 7 lands.
- **Warm-navigation latency regression.** Per-route lazy `<link>` with `await link.onload` before `mount()` adds a serialized round-trip to **every** first-visit-per-route navigation. On a single-origin server with ~60 s asset cache, CSS that was previously already in memory now blocks paint. HTTP/2 multiplexing helps concurrency, not the added `onload` gate. This trades cold-load for warm-nav latency; **measure per-navigation latency, not just cold-load.** Mitigation: prefetch-on-hover (must be *specified and implemented*, not hand-waved) and `preload:true` for hot features.
- **Per-tab request multiplication.** Granular per-tab JS+CSS (apps/backup tabs) multiplies request count. Express static has no server push. **Measure per-tab open latency** explicitly.
- **Compression presence is a precondition for any wire-size claim.** `compression` is loaded defensively (`try/require`, may be absent until image rebuild, per `middleware.js` comment). If split CSS deploys before the image with `compression` is rebuilt, many smaller **uncompressed** sheets could net **more** wire bytes than today's gzipped monoliths. **Confirm `compression` is active before claiming any wire-size win.**
- **Measure on the served app** (no synthetic bench) before/after each phase; `lp-shot` for visual parity. The cold-load speedup from removing the sleeps is a **hypothesis until measured** (see §11).
---
## 9. Phased strangler migration roadmap
Every phase is behavior-identical or feature-scoped, shippable at each commit, `lp-shot`-verified.
| Phase | What | Verify |
|---|---|---|
| **0** | Kernel scaffold dormant: extract `kernel/{router,feature-registry,lifecycle,asset-loader,css-manager,bootstrap,health}.js` from `spa.js`/`system-loader.js`/`system-orchestrator.js`; **absorb the history monkey-patch and remove the `setup-completion-watcher` IIFE the same commit (single history owner)**; pin auth-fetch-interceptor ordering; delete `utils/router.js` (extract neon bar). Kernel reads a **hand-committed** `features.json` describing current pages; old hardcoded Maps remain as fallback **but the kernel router is authoritative — fallback is fetch-failure only (see §10 dual-table risk)**. Add the route-resolution test (length-sort vs `spa.js` on `/apps*`/`/app*`, `/admin/system/storage`). | `lp-shot` every route — pixel-identical; route-resolution test green |
| **1** | Extract `shared/css/base.css` + `shared/ui/*` + `tokens.css` (hoist `--page-*`, add `--font-mono`; pull all *borrowed* classes — `.btn-secondary`/`.modal`/`.config-category`/`.task-item` — into base/ui). Per-feature sheets stay eager for now. | `lp-shot` every route × every built-in theme — byte-identical look |
| **2** | DI services + back-compat shim (aliasing, not instantiating). Collapse eager `task-refresh-coordinator` `<script>` into a service, created before eager dashboard/update-notifier registrations. **Keep the 2500ms/waitForTopbar latency guards.** | `lp-shot` /tasks, /backup, /dashboard mid-task |
| **3** | Migrate smallest/cleanest leaves first: **ssh**, **peers** (already `new(rootId)+init()`), prove `mount/unmount` fixes the never-removed document listeners. Add `cssRequires` for ssh's borrowed backup/config classes (or confirm they're in base from Phase 1). Drop the ad-hoc `peers-content.html` fetch in `config-manager`. | `lp-shot` /admin/tools/ssh-access, /admin/tools/peers — incl. reaching ssh without visiting backup first |
| **4** | **tasks** decomposition (delete file-scan path, triple `init()`, dead 2 s loop). Wire the setup-wizard `sessionStorage``/tasks` handoff through the new feature. | `lp-shot` /tasks + per-app Tasks tab + first-run→tasks transition |
| **5** | **backup** decomposition; `BackupAppCard` becomes the exported per-app tab provider; delete dead `#backup-location-modal`. | `lp-shot` /backup (5 tabs) + app Backups tab |
| **6** | **apps**: extract `shared/config-engine` (with a real ready promise; encode toggle-manager/config-shared load order as `requires`), split `apps-manager.js`, move `service-links` to `shared/ui` (unblocks dashboard), build the nested tab registry. **Confirm nothing live reads `html/app-content.html`/`html/apps-content.html` (and that `BackupAppCard`'s `#backup-app-card-*` DOM is not sourced from them) before deleting** the orphan fragments + dead `app-manager.js` (which references `ConfigShared` — confirm the extraction doesn't keep it alive). **admin** config-form + system pages. | `lp-shot` /apps, /app/<name> every tab, /admin/* |
| **7** | Flip `index.html` to kernel-only; build+wire the **scan generator** (`runFileOp` roots/write per §3.2), the **staleness predicate** (§3.3), the **source-array regens + sequence call site** (§3.4), the **degrade-don't-abort** failure mode (§3.5); delete hardcoded fallback Maps + `window.*` shim file-by-file (grep-zero gated); split remaining `style.css` per-feature; **only now remove the 2500ms/waitForTopbar guards** (all consumers migrated); optional esbuild spike (not shipped). | Full `lp-shot` sweep + cold-load **and warm-nav** timing vs baseline; `libreportal regen` actually refreshes on a `feature.json` touch; snap-out leaves no 404 |
### Concrete first 3 commits
**PR 1 — "kernel scaffold (dormant), zero behavior change":**
- Add `frontend/kernel/*.js`. `feature-registry.js` reads a checked-in `frontend/features/manifest.dev.json` listing the current pages with their existing file paths. `router.js` builds the routes Map from it; `spa.js`'s Map is fallback **only on fetch failure**, and the kernel router is the single authoritative owner (no two live tables — see §10).
- Absorb the history monkey-patch; remove the `setup-completion-watcher` IIFE the same commit; pin auth-fetch ordering.
- `spa.js` `handleX()` bodies delegate to `kernel.mount(id)`; no folders moved.
- **Verify:** `lp-shot /dashboard /tmp/p1-dash.png`, `/apps`, `/admin/system`, `/backup`, `/tasks` → Read each PNG, diff against a pre-change capture; run the route-resolution test. Commit on `claude/1` (auto-deploys).
**PR 2 — "extract base.css + shared/ui + central token layer":**
- Create `shared/css/base.css` (reset/scrollbars/buttons/tabs/notifications/aurora) + `shared/ui/*.css` (the borrowed components: `.btn-secondary`/`.modal`/`.config-category`/`.task-item`) + `shared/css/tokens.css` (`:root` defaults incl. hoisted `--page-*` and new `--font-mono`). Remove those rules from `style.css`/`admin.css`. Add links to `index.html` head.
- **Verify:** `lp-shot` every route × each built-in theme (`nebula`/`dark-blue`/`light`) — confirm glass look, page-identity hues, ssh/backup monospace fields unchanged.
**PR 3 — "DI services + back-compat shim":**
- Move `task-event-bus.js`/`task-manager.js`/`task-refresh-coordinator.js``shared/services/`. Build `ctx.services`. Install the `window.*` alias shim (aliasing the exact singletons, before any eager manager construction). Convert the eager `task-refresh-coordinator` `<script>` (index.html:97) into a registered service created before eager dashboard/update-notifier registrations.
- **Keep** the 2500ms/waitForTopbar guards (removed only in Phase 7).
- **Verify:** trigger an install task; confirm the toast, the /tasks live log, and the dashboard refresh-on-completion all still fire. `lp-shot /tasks` mid-task.
---
## 10. Snap-in / snap-out
> **Honest scope note:** the steps below are clean **only after the Phase 7 infrastructure exists** (generator + `runFileOp` roots + staleness predicate + sequence call site + source-array regens + degrade-don't-abort). Until then, snap-in/out is *not* a regen-only operation. The headline applies to the steady state.
### (a) Add a brand-new "Logs" feature — zero central edits (steady state)
```
frontend/features/logs/
feature.json { "id":"logs", "routes":["/admin/tools/logs"],
"nav":{"label":"Logs","icon":"icons/logs.svg","group":"tools","order":30},
"fragment":"logs.html","scripts":["index.js"],"css":["logs.css"],
"requires":["tasks"] }
index.js logs.html logs.css
```
Run `libreportal regen`. Because of the **staleness predicate (§3.3)**, the new `feature.json` is now seen as stale, the generator (running with the correct `runFileOp` roots, §3.2) validates and appends a `logs` entry to `/data/webui/generated/features.json`. Next load: the routes Map gains `/admin/tools/logs`, the topbar grows a "Logs" item under Tools (no `topbar.html` edit), and navigating there lazy-loads `logs.css`+`index.js` and calls `mount()`. **No edits to** `index.html`, `spa.js`, `system-loader.js`, `config-manager.js`, or `topbar.html`.
**App-shipped variant — scoped for v1.** An app may ship this from its **manager-owned install-template frontend** (the same root the apps-tools scan reads), which the generator can read with a plain `find`. Shipping it from the **live container-owned** `containers/<app>/frontend/` requires reading via `runFileOp` and is **deferred** (§11) — do not assume the live-tree variant works in v1.
### (b) Remove the existing "peers" feature
```
rm -rf frontend/features/peers/
libreportal regen
```
The generator no longer emits a `peers` entry; the route, nav item, `peers.css` link, and controller disappear. The legacy `/peers*→/admin/tools/peers` redirect is removed by deleting its line in the kernel's `redirects` array.
**Deploy-chain ordering caveat (corrected — was claimed "clean"):** `update.sh` uses `rsync -aH --delete --exclude 'data/'`. Deleting `features/peers/` in the repo removes the **source** on deploy, but `features.json` lives under the **excluded `data/` tree** and is only refreshed by regen. So **between deploy and the next regen, the manifest still lists `peers`** → the kernel tries to lazy-load now-deleted scripts → **404, not a clean disappearance.** Two mitigations, pick one and document it as the snap-out procedure:
1. **Regen-after-deploy ordering:** the deploy hook triggers `lpRegenWebui` (now staleness-aware) *after* rsync, so the manifest is rewritten before the next page load; or
2. **Kernel tolerance:** the kernel skips (and warns about) a manifest entry whose `scripts[]` 404, falling back to the not-found route instead of a hard error.
We adopt **both** (defense in depth). A stale bookmark to `/admin/tools/peers` still hits the `app.get('*')` catch-all → shell → kernel's clean client-side not-found.
---
## 11. Open questions / risks
Grouped by how blocking they are. Items marked **(must-resolve)** gate the phase noted; the rest are watch-items to measure or decide during implementation.
### Backend / deploy-chain (must-resolve before Phase 7)
- **Generator permission model is two-rooted, not one** *(must-resolve)*. Core `features/` is container-owned (read + write via `runFileOp`); app-shipped frontends in v1 come from the manager-owned install-template root (plain find). A single naive `find "$CONTAINERS_DIR"/*/frontend` EACCEScs on a real install. Resolved in §3.2 — verify the exact `runFileOp` invocations against `webui_tools.sh` before coding.
- **Staleness predicate is required** *(must-resolve)*. `lpRegenWebui` does not fire on `feature.json` changes today; without the new predicate (§3.3) the entire snap-in/out premise silently no-ops until `--force`.
- **Source-array regen + sequence call site are required** *(must-resolve)*. Without regenerating `files_webui.sh` + `function_manifest.sh` and adding the `webuiFeatureScan` line to `webui_updater.sh`, the generator fails at runtime under `LP_LAZY=1`.
- **New `data/webui/generated/` ownership is unverified** *(must-resolve)*. The frontend-wide ownership reconcile in `update.sh` may not cover a regen-time-created, `rsync`-excluded subtree. Confirm the ownership helper is invoked for the freshly created `data/webui/` path, or the container user cannot serve `features.json`.
- **`FRONTEND_PATH` env is a trap, permanently out of scope.** Documented in §3.2; re-stated here so no future phase re-introduces it. Honoring that env 404s the whole WebUI.
- **Snap-out is not deploy-atomic.** Mitigated by regen-after-deploy + kernel 404-tolerance (§10b); confirm the deploy hook actually triggers `lpRegenWebui` post-rsync on this install.
### CSS hardening (must-resolve per feature it touches)
- **Cross-feature borrowing breaks naive CSS removal** *(must-resolve)*. ssh borrows backup/config classes; `.btn-secondary`/`.modal`/`.config-category`/`.task-item` are multi-file. Resolved by promoting shared rules to base/ui (Phase 1) + the new `cssRequires` pinning mechanism (§5.2). Any feature extraction must classify each rule as shared-vs-owned-vs-borrowed first.
- **`[data-feature]` prefixing is NOT specificity-neutral.** It raises specificity and can flip the `!important`-laden cascade. Applied only to new rules, with `lp-shot` parity checks, never as a blanket mechanical prefix (§5.2).
- **`link.onload`/cached-stylesheet/ref-counted-removal semantics in geckodriver-Firefox are unverified.** FOUC-free `await link.onload` is assumed; validate on the `lp-shot` driver before relying on it for paint timing.
### Migration sequencing (watch-items, enforced by the phase table)
- **Dual route tables in Phase 0.** Kernel router must be the single authoritative owner with the old Map as *fetch-failure-only* fallback, and exactly one `popstate`/history owner per phase — otherwise we recreate the dual-router race we're killing.
- **Don't remove the 2500ms/`waitForTopbar` guards until the last consumer migrates.** They paper over the component-init-before-handler race; un-migrated handlers `typeof`-check `SystemLoader`-built managers during Phases 26. Removal is Phase 7 only.
- **Shim must alias, not instantiate.** The `window.*` shim must point at the same singletons the kernel uses, installed before any eager manager construction, or we re-introduce the two-`AppsManager`/two-`TasksManager` divergence bug.
- **`task-refresh-coordinator`-as-service ordering.** It must exist before eager `dashboard.js`/`update-notifier` registrations fire (PR 3 sequencing).
- **`config-engine` ready promise.** `requires:["config-engine"]` must mean "fully built" (toggle-manager/config-shared load order encoded), not "constructor returned," or `mount()` runs against a half-built service.
### Subsystems to keep whole (watch-items)
- **Setup wizard / first-run gate** is pre-kernel, not a feature; its `sessionStorage``/tasks` handoff must survive (§7, Phase 4 verify).
- **`config-validator.js` error overlay** is the only guard against a missing `configs.json`; `kernel/health.js` must retain it, not just "one critical check" (§7).
- **Auth fetch-interceptor ordering** is a hard boot invariant (§4), not an incidental.
- **Orphan fragments** `html/app-content.html`/`html/apps-content.html`: confirm zero live readers (and that `BackupAppCard`'s `#backup-app-card-*` DOM isn't sourced from them) before deleting in Phase 6; confirm the `config-engine` extraction doesn't keep dead `app-manager.js` (which references `ConfigShared`) alive.
### Performance (watch-items — measure, don't assert)
- **Cold-load CSS win is back-loaded to Phase 7** (depends on the `style.css` split). Don't advertise it earlier.
- **Warm-navigation latency may regress** from per-route `await link.onload`; measure per-navigation latency, specify prefetch-on-hover concretely, use `preload:true` for hot features.
- **Per-tab request multiplication** (apps/backup tabs): measure per-tab open latency; Express static has no push.
- **Wire-size win is conditional on `compression` being active** (loaded defensively, may be absent until image rebuild). Confirm before claiming fewer bytes; uncompressed split sheets could exceed today's gzipped monoliths.
- **Removing the fixed sleeps yields a real speedup** only as a hypothesis until measured on the served app (the doc's own discipline). Treat the cold-load number as TBD, not a result.
### Explicitly unproven, deferred from v1
- **esbuild release chunker.** No verified home for a node toolchain or a JS build artifact in `make_release.sh`/the checksum+minisign tarball flow. v1 is codegen-only; esbuild is a research spike, not a deliverable (§3.7).
- **App-shipped frontends from the live container-owned tree.** v1 reads app feature frontends from the manager-owned install-template root (proven precedent). Reading the live `containers/<app>/frontend/` via `runFileOp`, or the LibrePortal-Infra overlay path, is deferred and unproven (§3.1, §10a).
- **`route specificity = path length` reproducing today's longest-prefix precedence.** Plausible but not diffed against `spa.js`; the Phase 0 route-resolution test must pass before the kernel router becomes authoritative (§6).
---
### Key files this doc touches (absolute paths)
- New kernel: `/home/user/.claude-work-1/containers/libreportal/frontend/kernel/{router,feature-registry,lifecycle,asset-loader,css-manager,bootstrap,health}.js`
- New shared layer: `/home/user/.claude-work-1/containers/libreportal/frontend/shared/{services,ui,config-engine,css}/`
- Feature folders: `/home/user/.claude-work-1/containers/libreportal/frontend/features/<id>/`
- Generator: `/home/user/.claude-work-1/scripts/webui/data/generators/webui_feature_scan.sh` (uses `runFileOp` roots/write; reads core `features/` from the container-owned tree and app frontends from the manager-owned install-template root)
- Regen wiring (all REQUIRED central edits): staleness predicate in `/home/user/.claude-work-1/scripts/.../webui_regen.sh`; ordered call site in `/home/user/.claude-work-1/scripts/.../webui_updater.sh`; source-array regens `/home/user/.claude-work-1/scripts/source/files/{generate_arrays.sh→files_webui.sh, generate_function_manifest.sh→function_manifest.sh}`
- Generated artifact (read-only GET, written via `runFileOp`): `/data/webui/generated/features.json`
- Decompose: `apps-manager.js`, `backup-page.js`, `tasks-manager.js`, `config-shared.js`, `system-loader.js` (all under `…/frontend/js/components|system/`)
- First-run gate: `setup-detector.js`, `setup-wizard.js`, `setup-completion-watcher.js` (pre-kernel; history patch absorbed Phase 0)
- Untouched: `…/frontend/themes/*`, `theme-registry.js`, the `index.html` first-paint theme bootstrap
- **DO NOT TOUCH:** `/home/user/.claude-work-1/containers/libreportal/backend/utils/config.js` `FRONTEND_PATH` relative compute (honoring the compose env 404s the whole WebUI); note `…/backend/utils/middleware.js` serves both `/data` and the static frontend from that path.