Wire branding into the app shell (todo §2); render config logo + default theme, fall back to the brand mark

This commit is contained in:
2026-06-16 16:07:24 +02:00
parent 952dd03cc2
commit ff7b55be4c
10 changed files with 43 additions and 10 deletions

View File

@@ -273,7 +273,8 @@ user** by reading the roles in the session JWT (no per-request authz call — se
[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). _(Branding values are wired into the app shell — logo + default theme — next.)_
arbitrary depth). Branding (name, logo, default theme) renders in the app shell — the sidebar
brand shows the configured logo (else a default mark), and the theme sets the theme-switch default.
## Building blocks _(partly designed, planned to extract)_

View File

@@ -20,8 +20,9 @@ time, not in production.
> (`src/view-resolver.ts` — a `view` result renders `plugins/<id>/views/`, with the core partials
> reachable via `include()`), **per-plugin static serving** (`/public/<id>/` → 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.
> (`config/menu.ts`, loaded by `src/menu-config.ts`, with branding — name, logo, default theme —
> rendered in the app shell) are wired. The §2 plugin host is feature-complete; the remaining §2
> items are a project-wide review and comment/test cleanup.
## Anatomy of a plugin

View File

@@ -175,6 +175,7 @@ summary { list-style: none; cursor: pointer; }
display: grid; place-items: center; color: #fff;
}
.brand-mark .ico { stroke: #fff; }
.brand-logo { width: 22px; height: 22px; border-radius: 5px; flex: 0 0 auto; object-fit: contain; }
.brand-name { font-weight: 650; letter-spacing: -.01em; }
.brand-sub { color: var(--text-faint); font-size: var(--fz-xs); margin-left: auto; }

View File

@@ -40,6 +40,17 @@ test("serves the home page: the app-shell People dashboard, filterable via the U
assert.doesNotMatch(await empty.text(), /Avery Kline/);
});
test("renders branding from the menu config into the shell: logo + default theme", async (t) => {
const app = createApp({ menu: { branding: { logo: "/public/brand/logo.svg", name: "Acme Ops", theme: "dark" }, override: {} } });
await new Promise<void>((r) => app.listen(0, r));
t.after(() => app.close());
const html = await (await fetch(`http://localhost:${(app.address() as AddressInfo).port}/`)).text();
assert.match(html, /<img class="brand-logo" src="\/public\/brand\/logo\.svg"/);
assert.match(html, /Acme Ops/);
assert.match(html, /id="theme-dark"\s+checked/); // config default theme reaches the switch
});
test("serves a static file: GET sends body + content-type, HEAD sends headers only", async () => {
const get = await fetch(base + "/public/css/styles.css");
assert.equal(get.status, 200);

View File

@@ -59,11 +59,12 @@ test("dashboard sorts by a column, reflects direction, and the header toggles",
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" },
branding: { logo: "/public/logo.svg", name: "Acme Ops", sub: "Admin", theme: "dark" },
override: { hide: ["teams"], rename: { people: "Staff" } },
});
assert.deepEqual(m.shell.brand, { name: "Acme Ops", sub: "Admin" });
assert.deepEqual(m.shell.brand, { logo: "/public/logo.svg", name: "Acme Ops", sub: "Admin" });
assert.equal(m.shell.theme, "dark");
const labels = m.nav.map((n) => n.label);
assert.ok(labels.includes("Staff")); // "People" renamed
assert.ok(!labels.includes("Teams")); // "Teams" hidden

View File

@@ -106,8 +106,13 @@ export function buildDashboardModel(url: URL | URLSearchParams | string, roles:
nav: nav(roles, menu.override),
pagination: pagination(state, page),
shell: {
brand: { name: menu.branding.name, ...(menu.branding.sub != null ? { sub: menu.branding.sub } : {}) },
brand: {
...(menu.branding.logo != null ? { logo: menu.branding.logo } : {}),
name: menu.branding.name,
...(menu.branding.sub != null ? { sub: menu.branding.sub } : {}),
},
breadcrumbs: [{ href: "?", label: "Directory" }, { label: "People" }],
...(menu.branding.theme != null ? { theme: menu.branding.theme } : {}),
title: "People",
user: { email: "sam.rivers@example.com", initials: "SR", name: "Sam Rivers" }, // demo until §4
},

View File

@@ -37,6 +37,17 @@ test("app shell renders sidebar, topbar and the content slot", async () => {
assert.match(html, /<use href="#i-menu"\s*\/?>/); // hamburger references the menu icon
});
test("app shell renders a configured logo + default theme, falls back to the brand mark", async () => {
const branded = await render({ brand: { logo: "/public/brand/logo.svg", name: "Acme" }, theme: "dark" });
assert.match(branded, /<img class="brand-logo" src="\/public\/brand\/logo\.svg"/);
assert.doesNotMatch(branded, /brand-mark/); // a logo replaces the default mark
assert.match(branded, /id="theme-dark"\s+checked/); // default theme applied to the switch
const plain = await render({ brand: { name: "Acme" } }); // no logo, no theme
assert.match(plain, /<span class="brand-mark">/); // default mark
assert.match(plain, /id="theme-auto"\s+checked/); // theme-switch default
});
test("app shell escapes text but passes slot HTML through, and renders with defaults", async () => {
const escaped = await render({ title: "<x>", body: "<p>raw</p>" });
assert.match(escaped, /<title>&lt;x&gt;<\/title>/); // user text is escaped

View File

@@ -51,7 +51,7 @@ everything via Docker.
- [x] Per-plugin view resolver (`plugins/<id>/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/<id>/views/<view>.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: [<plugin>/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/<id>/public/``/public/<id>/`. → `routePublic` (pure, in `src/static.ts`), wired into `app.ts`'s existing `/public/` branch. A request `/public/<rest>` whose leading segment names a discovered plugin serves from `plugins/<id>/public/<rest>`; 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/<id>`) + 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.
- [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.
- [x] Wire branding into the app shell. → Completes the §2 branding chain (name/sub already flowed). `shell.ejs` now renders `brand.logo` as `<img class="brand-logo" alt="">` when set, else the default `#i-box` brand-mark; the `theme` local (already forwarded to the theme-switch) is now supplied. `buildDashboardModel` puts `menu.branding.logo` into `shell.brand` and `menu.branding.theme` into `shell.theme` (both omitted when unset, so a clean clone is unchanged → brand-mark + auto theme); `views/index.ejs` forwards `theme` to the shell. Added a `.brand-logo` CSS rule (22px, matches `.brand-mark` sizing). Tests-first: `shell.test.ts` (logo replaces the mark + default theme checked; no-logo ⇒ mark + auto) + extended `dashboard.test.ts` (logo→brand, theme→shell.theme) + an `app.test.ts` integration rendering `createApp({ menu })` end-to-end (logo `<img>` + `theme-dark` checked on `/`). Default-app shell rendering is byte-equivalent, so the visual E2E is unaffected; typecheck + 109 units green. The §2 plugin host is feature-complete (remaining §2 items are the project-wide review + comment/test cleanup).
- [ ] 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.
- [ ] Go over all tests and combine/unify ones that cover the same stuff or are very related and could be combined in a good way. Remove tests that aren't helping, we only want tests that are actually helpful to us.

View File

@@ -18,6 +18,7 @@
brand: model.shell.brand,
breadcrumbs: model.shell.breadcrumbs,
nav,
theme: model.shell.theme,
title: model.shell.title,
user: model.shell.user,
}) %>

View File

@@ -1,8 +1,9 @@
<%#
App shell: sidebar (brand + nav slot + footer) · topbar · content slot.
Slots are pre-rendered HTML locals — `nav` (sidebar tree, see nav-tree partial),
`actions` (topbar buttons), `body` (page content). Text locals: `title`, `brand`,
`user`, `breadcrumbs`. Real `user`/branding arrive with §4 auth / §2 menu config.
`actions` (topbar buttons), `body` (page content). Text locals: `title`, `brand`
({ name, logo?, sub? } — logo image else the default mark), `theme` (default for the
theme-switch), `user`, `breadcrumbs`. Branding comes from config/menu.ts; `user` from §4 auth.
%><%
const title = locals.title || "Plainpages";
const brand = locals.brand || { name: "Plainpages" };
@@ -29,7 +30,7 @@
<div class="app">
<aside class="sidebar" aria-label="Primary">
<div class="brand">
<span class="brand-mark"><svg class="ico ico-sm"><use href="#i-box" /></svg></span>
<% if (brand.logo) { %><img class="brand-logo" src="<%= brand.logo %>" alt="" /><% } else { %><span class="brand-mark"><svg class="ico ico-sm"><use href="#i-box" /></svg></span><% } %>
<span class="brand-name"><%= brand.name %></span>
<% if (brand.sub) { %><span class="brand-sub"><%= brand.sub %></span><% } %>
</div>