From 952dd03cc28361c3e8ca26d46a8c376c68b80363 Mon Sep 17 00:00:00 2001 From: lilleman Date: Tue, 16 Jun 2026 15:52:03 +0200 Subject: [PATCH] =?UTF-8?q?Add=20config/menu.ts=20central=20override=20+?= =?UTF-8?q?=20branding=20(todo=20=C2=A72);=20loadMenuConfig=20validates+me?= =?UTF-8?q?rges,=20override=20applied=20to=20nav,=20branding=20into=20shel?= =?UTF-8?q?l?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 14 +++--- config/menu.ts | 23 +++++++++ docs/plugin-contract.md | 9 ++-- src/app.ts | 7 ++- src/dashboard.test.ts | 12 +++++ src/dashboard.ts | 13 ++--- src/menu-config.test.ts | 39 +++++++++++++++ src/menu-config.ts | 105 ++++++++++++++++++++++++++++++++++++++++ src/server.ts | 4 +- todo.md | 2 +- tsconfig.json | 2 +- 11 files changed, 209 insertions(+), 21 deletions(-) create mode 100644 config/menu.ts create mode 100644 src/menu-config.test.ts create mode 100644 src/menu-config.ts diff --git a/README.md b/README.md index bdaf258..1c1db36 100644 --- a/README.md +++ b/README.md @@ -258,21 +258,22 @@ and reproducible; mount a volume only to add plugins to an already-built image. > at `/public//` (`src/static.ts`). The mount mechanics above are how the files get into the > container either way. -## The menu system _(planned)_ +## The menu system The menu is **driven entirely by config** and assembled from two sources: 1. **Plugin fragments** — each plugin contributes its own `nav` (above). -2. **A central override** — `config/menu.ts` — where the operator reorders, - renames, groups, or hides items, and sets branding (app name, logo, default - theme). The override always wins. +2. **A central override** — `config/menu.ts` (loaded by `src/menu-config.ts`, validated at boot) + — where the operator reorders, renames, groups, or hides items (by node `id`), and sets + branding (app name, logo, default theme). The override always wins, applied before the + per-user filter. A clean clone needs no `config/menu.ts`; defaults apply. Every nav item may carry a `permission`; the rendered tree is **filtered per user** by reading the roles in the session JWT (no per-request authz call — see [Auth, sessions & permissions](#auth-sessions--permissions-planned)), so the menu only ever shows what that person can reach. The markup is the recursive, zero-JS nav tree from the design foundation (header/leaf × clickable/static, counts, -arbitrary depth). +arbitrary depth). _(Branding values are wired into the app shell — logo + default theme — next.)_ ## Building blocks _(partly designed, planned to extract)_ @@ -438,9 +439,10 @@ src/plugin.ts Plugin contract: manifest types, definePlugin(), version + src/discovery.ts discoverPlugins(): scan plugins/, import + validate each plugin.ts default export, fail loud at boot (§2) src/router.ts matchRoute()/allowedMethods()/isAuthorized(): map method+path → plugin route, params, permission gate (§2) src/view-resolver.ts renderPluginView(): render plugins//views/.ejs; plugin views can include() core partials (§2) +src/menu-config.ts loadMenuConfig()/defineMenu(): read config/menu.ts (central override + branding), validated at boot (§2) views/ Core EJS templates (index = the app-shell People dashboard, 403/404/500, partials/ incl. app shell, nav tree, filter bar, data table, pagination, form field, auth card, menu/popover, theme switch, icon sprite) public/ Static assets under /public/ (css/styles.css + auth.css, favicon, robots.txt) -config/menu.ts Central menu override + branding (planned) +config/menu.ts Central menu override + branding (optional; defaults apply if absent) plugins/ Drop-in plugin folders (scanned at /app/plugins; bind-mount or bake in) (planned) docs/ Reference docs (plugin-contract.md — the authoritative plugin API) e2e/ Playwright visual + functional E2E (Dockerfile.e2e + compose.e2e.yml run it) diff --git a/config/menu.ts b/config/menu.ts new file mode 100644 index 0000000..09c0351 --- /dev/null +++ b/config/menu.ts @@ -0,0 +1,23 @@ +// Central menu override + branding (todo §2). Brand the app and reorder/rename/group/hide nav +// nodes (by their `id`) across all plugins — the override always wins, applied before the +// per-user permission filter. Every field is optional; delete one to fall back to the default. +// See src/menu-config.ts (types), src/nav.ts (NavOverride), docs/plugin-contract.md. + +import { defineMenu } from "../src/menu-config.ts"; + +export default defineMenu({ + branding: { + name: "Plainpages", // app name shown in the sidebar + sub: "Console", // optional subtitle under the name + // logo: "/public/logo.svg", // optional logo asset (rendered in the shell — next §2 item) + // theme: "auto", // default color theme: auto | light | dark + }, + + // Operator override (rename → group → order → hide), keyed by node id. + override: { + // rename: { people: "Staff" }, // node id → new label + // groups: [{ id: "admin", label: "Admin", children: ["users", "roles"] }], + // order: ["people", "reports"], // top-level order by id + // hide: ["teams"], // remove nodes (any depth) + }, +}); diff --git a/docs/plugin-contract.md b/docs/plugin-contract.md index 62d44a0..aa6a100 100644 --- a/docs/plugin-contract.md +++ b/docs/plugin-contract.md @@ -18,9 +18,10 @@ time, not in production. > (`src/discovery.ts`), the **router** (`src/router.ts` — method+path match, `:name` params, > permission gate, `RouteResult` → response), and the **per-plugin view resolver** > (`src/view-resolver.ts` — a `view` result renders `plugins//views/`, with the core partials -> reachable via `include()`), and **per-plugin static serving** (`/public//` → the plugin's -> `public/`, `routePublic` in `src/static.ts`) are wired. The central menu override + branding -> (`config/menu.ts`) is the next §2 item. +> reachable via `include()`), **per-plugin static serving** (`/public//` → the plugin's +> `public/`, `routePublic` in `src/static.ts`), and the **central menu override + branding** +> (`config/menu.ts`, loaded by `src/menu-config.ts`) are wired. Rendering branding (logo, default +> theme) into the app shell is the next §2 item. ## Anatomy of a plugin @@ -43,7 +44,7 @@ convention) its nav/permission tokens. Installing a plugin is "drop the folder, restart." Removing one is "delete the folder, restart." Nothing else references it; the operator stays in control through the central menu override -(`config/menu.ts`, §2). +(`config/menu.ts`). ## The manifest diff --git a/src/app.ts b/src/app.ts index e7e8665..451002b 100644 --- a/src/app.ts +++ b/src/app.ts @@ -5,6 +5,7 @@ import * as ejs from "ejs"; import { buildContext } from "./context.ts"; import { buildDashboardModel } from "./dashboard.ts"; import { PLUGINS_DIR } from "./discovery.ts"; +import { DEFAULT_MENU, type MenuConfig } from "./menu-config.ts"; import type { Plugin, RouteResult } from "./plugin.ts"; import { allowedMethods, isAuthorized, matchRoute } from "./router.ts"; import { routePublic, serveStatic } from "./static.ts"; @@ -16,6 +17,7 @@ export interface AppOptions { // Cache compiled templates; caller decides (server passes config.cacheTemplates). // Off by default so edits show live; the app itself never inspects the environment. cache?: boolean; + menu?: MenuConfig; // central override + branding (config/menu.ts); defaults to DEFAULT_MENU plugins?: Plugin[]; // discovered manifests to mount (router); empty until §2 discovery runs pluginsDir?: string; // where plugin views/static live; defaults to the scanned plugins/ publicDir?: string; @@ -24,6 +26,7 @@ export interface AppOptions { export function createApp(options: AppOptions = {}): Server { const cache = options.cache ?? false; + const menu = options.menu ?? DEFAULT_MENU; const plugins = options.plugins ?? []; const pluginIds = new Set(plugins.map((p) => p.id)); const pluginsDir = options.pluginsDir ?? PLUGINS_DIR; @@ -69,8 +72,8 @@ export function createApp(options: AppOptions = {}): Server { } if (pathname === "/" && (method === "GET" || method === "HEAD")) { - // Mock data + no roles until auth (§4) lands. - sendHtml(res, 200, await render("index", { model: buildDashboardModel(url) })); + // Mock data + no roles until auth (§4) lands; branding/override come from config/menu.ts. + sendHtml(res, 200, await render("index", { model: buildDashboardModel(url, [], menu) })); return; } diff --git a/src/dashboard.test.ts b/src/dashboard.test.ts index 2b3195b..2c3c14c 100644 --- a/src/dashboard.test.ts +++ b/src/dashboard.test.ts @@ -57,6 +57,18 @@ test("dashboard sorts by a column, reflects direction, and the header toggles", assert.equal(col0(bad).sort, undefined); }); +test("dashboard applies the central menu config: branding + nav override (rename/hide)", () => { + const m = buildDashboardModel(new URL("http://x/"), [], { + branding: { name: "Acme Ops", sub: "Admin" }, + override: { hide: ["teams"], rename: { people: "Staff" } }, + }); + + assert.deepEqual(m.shell.brand, { name: "Acme Ops", sub: "Admin" }); + const labels = m.nav.map((n) => n.label); + assert.ok(labels.includes("Staff")); // "People" renamed + assert.ok(!labels.includes("Teams")); // "Teams" hidden +}); + test("dashboard paginates: page 2 slices the next rows and preserves state in links", () => { const p2 = buildDashboardModel(new URL("http://x/?sort=-name&page=2")); assert.equal(p2.pagination.summary.from, 13); // 30 rows / 12 per page → page 2 starts at 13 diff --git a/src/dashboard.ts b/src/dashboard.ts index e627a84..f401251 100644 --- a/src/dashboard.ts +++ b/src/dashboard.ts @@ -4,7 +4,8 @@ // composeNav. The dataset stands in for upstream data until plugins/§4 land; everything below // is real, so the filter form, sortable headers and pager round-trip through the URL (zero-JS). -import { composeNav, type NavNode } from "./nav.ts"; +import { DEFAULT_MENU, type MenuConfig } from "./menu-config.ts"; +import { composeNav, type NavNode, type NavOverride } from "./nav.ts"; import { parseListQuery } from "./list-query.ts"; import { paginate } from "./paginate.ts"; @@ -77,7 +78,7 @@ function href(state: State, overrides: Partial = {}): string { return qs ? `?${qs}` : "?"; } -export function buildDashboardModel(url: URL | URLSearchParams | string, roles: string[] = []) { +export function buildDashboardModel(url: URL | URLSearchParams | string, roles: string[] = [], menu: MenuConfig = DEFAULT_MENU) { const query = parseListQuery(url, { defaultPageSize: DEFAULT_PAGE_SIZE }); const status = query.filters.status?.[0] ?? "all"; const team = query.filters.team?.[0] ?? ""; @@ -102,10 +103,10 @@ export function buildDashboardModel(url: URL | URLSearchParams | string, roles: return { filterBar: filterBar(state), - nav: nav(roles), + nav: nav(roles, menu.override), pagination: pagination(state, page), shell: { - brand: { name: "Plainpages", sub: "Console" }, + brand: { name: menu.branding.name, ...(menu.branding.sub != null ? { sub: menu.branding.sub } : {}) }, breadcrumbs: [{ href: "?", label: "Directory" }, { label: "People" }], title: "People", user: { email: "sam.rivers@example.com", initials: "SR", name: "Sam Rivers" }, // demo until §4 @@ -116,7 +117,7 @@ export function buildDashboardModel(url: URL | URLSearchParams | string, roles: export type DashboardModel = ReturnType; -function nav(roles: string[]): NavNode[] { +function nav(roles: string[], override: NavOverride): NavNode[] { return composeNav([[ { count: PEOPLE.length, current: true, href: "/", icon: "i-users", id: "people", label: "People" }, { href: "#teams", icon: "i-grid", id: "teams", label: "Teams" }, @@ -125,7 +126,7 @@ function nav(roles: string[]): NavNode[] { { href: "#exports", id: "exports", label: "Exports" }, ], icon: "i-chart", id: "reports", label: "Reports", open: true }, { href: "#settings", icon: "i-gear", id: "settings", label: "Settings", permission: "admin" }, - ]], {}, roles); + ]], override, roles); } function table(rows: Person[], state: State, sort: { dir: "asc" | "desc"; field: string } | null) { diff --git a/src/menu-config.test.ts b/src/menu-config.test.ts new file mode 100644 index 0000000..e165094 --- /dev/null +++ b/src/menu-config.test.ts @@ -0,0 +1,39 @@ +import assert from "node:assert/strict"; +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { test, type TestContext } from "node:test"; +import { DEFAULT_MENU, loadMenuConfig } from "./menu-config.ts"; + +// Write a throwaway menu.ts (a plain object — defineMenu is identity) and clean it up after. +function scaffold(t: TestContext, source: string): string { + const dir = mkdtempSync(join(tmpdir(), "pp-menu-")); + t.after(() => rmSync(dir, { force: true, recursive: true })); + const file = join(dir, "menu.ts"); + writeFileSync(file, source); + return file; +} + +test("loadMenuConfig returns defaults when no config file exists (clean clone)", async () => { + assert.deepEqual(await loadMenuConfig({ file: join(tmpdir(), "pp-no-such-menu-xyz.ts") }), DEFAULT_MENU); +}); + +test("loadMenuConfig reads branding + override, merging branding over defaults", async (t) => { + const file = scaffold(t, `export default { + branding: { name: "Acme Ops", theme: "dark" }, + override: { hide: ["teams"], order: ["reports", "people"], rename: { people: "Staff" } }, + };`); + const menu = await loadMenuConfig({ file }); + + assert.equal(menu.branding.name, "Acme Ops"); + assert.equal(menu.branding.sub, "Console"); // default kept (only `name`/`theme` overridden) + assert.equal(menu.branding.theme, "dark"); + assert.deepEqual(menu.override.hide, ["teams"]); + assert.deepEqual(menu.override.rename, { people: "Staff" }); +}); + +test("loadMenuConfig fails loud on a malformed config", async (t) => { + await assert.rejects(loadMenuConfig({ file: scaffold(t, `export default [];`) }), /config object/); + await assert.rejects(loadMenuConfig({ file: scaffold(t, `export default { branding: { theme: "neon" } };`) }), /theme/); + await assert.rejects(loadMenuConfig({ file: scaffold(t, `export default { override: { hide: "teams" } };`) }), /hide.*array/s); +}); diff --git a/src/menu-config.ts b/src/menu-config.ts new file mode 100644 index 0000000..051bf4d --- /dev/null +++ b/src/menu-config.ts @@ -0,0 +1,105 @@ +// Central menu config (todo §2): config/menu.ts lets an operator set branding (app name, logo, +// default theme) and reorder/rename/group/hide nav nodes across all plugins. The reorder/rename/ +// group/hide part is the NavOverride composeNav already applies (the override always wins, before +// the per-user permission filter). Authored as TypeScript (defineMenu types it); loaded once at +// boot — fail-loud on a malformed file, defaults when absent (clean clone needs no config). + +import { existsSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { fileURLToPath, pathToFileURL } from "node:url"; +import type { NavOverride } from "./nav.ts"; + +export type Theme = "auto" | "dark" | "light"; + +export interface Branding { + logo?: string; // optional logo asset path/URL (rendered in the shell — next §2 branding item) + name: string; // app name shown in the sidebar brand + sub?: string; // optional brand subtitle + theme?: Theme; // default color theme for the theme-switch +} + +export interface MenuConfig { + branding: Branding; + override: NavOverride; +} + +// What config/menu.ts authors — every field optional; the loader fills branding defaults. +export interface MenuConfigInput { + branding?: Partial; + override?: NavOverride; +} + +export const DEFAULT_BRANDING: Branding = { name: "Plainpages", sub: "Console" }; +export const DEFAULT_MENU: MenuConfig = { branding: DEFAULT_BRANDING, override: {} }; + +const rootDir = join(dirname(fileURLToPath(import.meta.url)), ".."); +export const MENU_CONFIG_FILE = join(rootDir, "config", "menu.ts"); + +// Identity helper: types the authored config, returns it unchanged (mirrors definePlugin). +export function defineMenu(config: MenuConfigInput): MenuConfigInput { + return config; +} + +export interface LoadMenuOptions { + file?: string; +} + +export async function loadMenuConfig(options: LoadMenuOptions = {}): Promise { + const file = options.file ?? MENU_CONFIG_FILE; + if (!existsSync(file)) return DEFAULT_MENU; // clean clone: no central override + + let mod: { default?: unknown }; + try { + mod = await import(pathToFileURL(file).href); + } catch (err) { + throw new Error(`config/menu.ts failed to import — ${err instanceof Error ? err.message : String(err)}`); + } + + const errors = validate(mod.default); + if (errors.length) throw new Error(`config/menu.ts is invalid:\n${errors.map((e) => ` - ${e}`).join("\n")}`); + + const authored = mod.default as MenuConfigInput; + return { + branding: { ...DEFAULT_BRANDING, ...authored.branding }, + override: authored.override ?? {}, + }; +} + +const THEMES = new Set(["auto", "dark", "light"]); + +// Validate the authored shape so a typo fails at boot, not silently at render. Only the fields an +// operator commonly mis-types; composeNav consumes the override defensively beyond that. +function validate(input: unknown): string[] { + if (!isObject(input)) return ["default export must be a config object (use defineMenu)"]; + const errors: string[] = []; + + if (input.branding !== undefined) { + if (!isObject(input.branding)) errors.push("branding must be an object"); + else { + for (const key of ["logo", "name", "sub"] as const) { + if (input.branding[key] !== undefined && typeof input.branding[key] !== "string") errors.push(`branding.${key} must be a string`); + } + if (input.branding.theme !== undefined && !THEMES.has(input.branding.theme as string)) errors.push("branding.theme must be one of auto/dark/light"); + } + } + + if (input.override !== undefined) { + if (!isObject(input.override)) errors.push("override must be an object"); + else { + for (const key of ["hide", "order"] as const) { + if (input.override[key] !== undefined && !isStringArray(input.override[key])) errors.push(`override.${key} must be an array of strings`); + } + if (input.override.groups !== undefined && !Array.isArray(input.override.groups)) errors.push("override.groups must be an array"); + if (input.override.rename !== undefined && !isObject(input.override.rename)) errors.push("override.rename must be an object"); + } + } + return errors; +} + +function isObject(v: unknown): v is Record { + return typeof v === "object" && v !== null && !Array.isArray(v); +} + +function isStringArray(v: unknown): boolean { + return Array.isArray(v) && v.every((x) => typeof x === "string"); +} diff --git a/src/server.ts b/src/server.ts index 40c94eb..d603b43 100644 --- a/src/server.ts +++ b/src/server.ts @@ -1,13 +1,15 @@ import { createApp } from "./app.ts"; import { loadConfig } from "./config.ts"; import { discoverPlugins } from "./discovery.ts"; +import { loadMenuConfig } from "./menu-config.ts"; const config = loadConfig(); // validates the env (incl. enforced secrets) — fails loud at boot +const menu = await loadMenuConfig(); // config/menu.ts override + branding — fails loud if malformed const plugins = await discoverPlugins(); // scans plugins/, validates — fails loud on a bad plugin console.log(`Discovered ${plugins.length} plugin(s)${plugins.length ? `: ${plugins.map((p) => p.id).join(", ")}` : ""}`); -const server = createApp({ cache: config.cacheTemplates, plugins }).listen(config.port, () => { +const server = createApp({ cache: config.cacheTemplates, menu, plugins }).listen(config.port, () => { console.log(`Listening on http://localhost:${config.port}`); }); diff --git a/todo.md b/todo.md index f4ebcf2..7cf8f4f 100644 --- a/todo.md +++ b/todo.md @@ -50,7 +50,7 @@ everything via Docker. - [x] Router: match method+path under `basePath`, resolve path params, run permission gate, call handler with context. → `src/router.ts`: the pure core (`matchRoute`/`allowedMethods`/`isAuthorized`), wired by `app.ts` (the imperative shell). A route mounts at `/` + its path via the now-exported `fullPath` (shared with `findConflicts`, so they can't drift); `:name` segments → `ctx.params.name` (percent-decoded, malformed ⇒ no match). Specificity: a literal segment beats a `:param` (`/users/new` wins over `/users/:id` regardless of declaration order), ties keep discovery order. HEAD answers a GET route; known-path/wrong-method ⇒ 405 + `Allow`. `isAuthorized` = composeNav's gate (no `permission` ⇒ open, else `roles` must include it); fail-closed today since auth (§4) supplies no user yet (gated ⇒ 403). `app.ts` builds the context, gates, calls the handler, and maps `RouteResult` → response (`sendResult`: html/json/redirect/view/void; author headers override; the void escape hatch lets a handler own `ctx.res`); `view` renders the plugin's own `views/.ejs` (the richer resolver — core-partial includes, subfolders — is the next §2 item). Dropped the global non-GET/HEAD 405 (plugins bring other methods). Wired into `server.ts` (`createApp({ plugins })`). Tests-first: `router.test.ts` (match/params/specificity/HEAD/methods/gate) + an `app.test.ts` integration mounting a demo plugin (every RouteResult shape + 403/405/404); typecheck + 98 units green. - [x] Per-plugin view resolver (`plugins//views/*.ejs`) and also all possible partials for ejs in the views folder and sub folderes. → `src/view-resolver.ts` (`renderPluginView`/`resolveViewPath`), wired into `app.ts` for a `view` RouteResult (replaces the router's minimal stub). `resolveViewPath` (pure) maps a view name → `plugins//views/.ejs`, supports nested names (`shifts/edit`), defaults the `.ejs` extension, and refuses traversal/control-char names (same guard as `static.ts`). Rendering passes EJS `views: [/views, coreViewsDir]`: EJS resolves an `include()` relative to the current file first, then those roots — so a plugin view reaches **every core building-block partial** (shell, nav-tree, data-table, …) *and* its own partials/subfolders, plugin-root first so it can deliberately shadow a core partial. Out-of-bounds name ⇒ reject (fail loud). Tests-first: `view-resolver.test.ts` (resolve/nest/extension/traversal/control-char + a nested view that includes both a core partial and its own) + the `app.test.ts` plugin integration now asserts the live `view` page includes `partials/theme-switch`; typecheck + 102 units green. Per-plugin static serving is the next §2 item. - [x] Per-plugin static serving: `plugins//public/` → `/public//`. → `routePublic` (pure, in `src/static.ts`), wired into `app.ts`'s existing `/public/` branch. A request `/public/` whose leading segment names a discovered plugin serves from `plugins//public/`; anything else (e.g. `css/styles.css`) stays on the core `public/`. Disambiguates by the discovered plugin-id set, so only mounted plugins expose assets and core paths are unaffected; plugin ids are URL-safe so the raw segment compares directly (no decode needed). Reuses `serveStatic` unchanged, so the sub-path keeps its decode + traversal/control-char guard (encoded `..` ⇒ 403) and HEAD support; a missing `public/` or file ⇒ 404. Tests-first: a `routePublic` unit (plugin/core split, nested asset, bare `/public/`) + the `app.test.ts` plugin integration now serves a real `demo/public/app.css` (200 + `text/css`) and still 403s a traversal; typecheck + 103 units green. `config/menu.ts` central override is the next §2 item. -- [ ] `config/menu.ts` central override: reorder/rename/hide/group + branding (app name, logo, default theme). +- [x] `config/menu.ts` central override: reorder/rename/hide/group + branding (app name, logo, default theme). → `src/menu-config.ts` (`MenuConfig`/`Branding`/`MenuConfigInput`, `defineMenu()` identity helper, `DEFAULT_MENU`, `loadMenuConfig()`) + the operator file `config/menu.ts`. The override is `composeNav`'s existing `NavOverride` (reorder/rename/group/hide by node id, applied before the per-user filter); branding = `{ name, logo?, sub?, theme? }`. `loadMenuConfig` (imperative shell) dynamically imports `config/menu.ts` if present, validates the authored shape fail-loud (branding field types + `theme` enum, override `hide`/`order` string-arrays / `groups` array / `rename` object), merges branding over defaults; **absent file ⇒ `DEFAULT_MENU`** (clean clone). Wired: `server.ts` loads it at boot → `createApp({ menu })` → `buildDashboardModel(url, roles, menu)` feeds `menu.override` into `composeNav` and `menu.branding` (name/sub) into the shell brand. `config/menu.ts` ships defaults matching prior behaviour (name "Plainpages"/sub "Console", empty override), so a clean clone is unchanged. Added `config` to tsconfig `include` so the authored file is type-checked (Dockerfile `COPY . .` already bakes it). Tests-first: `menu-config.test.ts` (absent⇒defaults / read+merge / malformed⇒throws) + a `dashboard.test.ts` case asserting rename+hide+branding take effect; typecheck (incl. `config/`) + 107 units green; smoke-loaded the real file at boot. **Rendering branding (logo, default theme) into the app shell is the next §2 item.** - [ ] Wire branding into the app shell. - [ ] Run the architecture _and_ the stability reviewer agents on the _whole_ project, not just the latest changes, and address their issues. - [ ] Go over all comments in the code and the README and try to make it shorter and more information dense. Remove not strictly needed stuff. diff --git a/tsconfig.json b/tsconfig.json index 56b43c1..06030dd 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -24,5 +24,5 @@ "forceConsistentCasingInFileNames": true, "skipLibCheck": true }, - "include": ["src"] + "include": ["config", "src"] }