Retire shell-context and render the dashboard from ctx.chrome
CI / full-gate (push) Successful in 3m18s

This commit is contained in:
2026-10-31 08:37:45 +01:00
parent de4eb7d9ee
commit 352de29013
6 changed files with 45 additions and 20 deletions
+1 -1
View File
@@ -1825,7 +1825,7 @@ src/ The app — strict tsc, no build step. *.test.ts sit beside
plugin-host/ plugin.ts (the contract) · plugin-api.ts (the `@plainpages/plugin-api` barrel) · system.ts plugin-host/ plugin.ts (the contract) · plugin-api.ts (the `@plainpages/plugin-api` barrel) · system.ts
(ctx.system) · discovery · router · hooks · view-resolver · storage (the rules) · (ctx.system) · discovery · router · hooks · view-resolver · storage (the rules) ·
storage-provisioning (the DDL; bootstrap-only, holds the driver) storage-provisioning (the DDL; bootstrap-only, holds the driver)
ui/ chrome (the one global menu) · shell-context · dashboard · nav (composeNav) · ui/ chrome (the one global menu) · nav (composeNav) ·
menu-config (`#menu-config`) · icons (lucide sprite builder) · list-query · paginate menu-config (`#menu-config`) · icons (lucide sprite builder) · list-query · paginate
plugin-api/ The `@plainpages/plugin-api` package — the author barrel, linked into /node_modules plugin-api/ The `@plainpages/plugin-api` package — the author barrel, linked into /node_modules
+1 -2
View File
@@ -8,7 +8,6 @@ import { buildPluginChrome, type PageChrome } from "../ui/chrome.ts";
import { buildContext, type RequestContext, type User } from "./context.ts"; import { buildContext, type RequestContext, type User } from "./context.ts";
import { csrfCookie, ensureCsrfToken, verifyCsrfRequest } from "../auth/csrf.ts"; import { csrfCookie, ensureCsrfToken, verifyCsrfRequest } from "../auth/csrf.ts";
import type { Denylist } from "../auth/denylist.ts"; import type { Denylist } from "../auth/denylist.ts";
import { buildDashboardModel } from "../ui/dashboard.ts";
import { PLUGINS_DIR } from "../plugin-host/discovery.ts"; import { PLUGINS_DIR } from "../plugin-host/discovery.ts";
import { GuardError, loginRedirect } from "../auth/guards.ts"; import { GuardError, loginRedirect } from "../auth/guards.ts";
import { ENGLISH_I18N } from "../i18n/english.ts"; import { ENGLISH_I18N } from "../i18n/english.ts";
@@ -168,7 +167,7 @@ export function createApp(options: AppOptions = {}): Server {
await sendResult(ctx.res, result, pluginViewsFor(pluginCtx, dashboardPlugin.id), pluginCtx.localeHref); await sendResult(ctx.res, result, pluginViewsFor(pluginCtx, dashboardPlugin.id), pluginCtx.localeHref);
return null; return null;
} }
return { data: { model: buildDashboardModel({ csrfToken: csrf.token, menu, user: ctx.user, nav: ctx.chrome.nav, t: ctx.t }) }, view: "index" }; return { data: { chrome: ctx.chrome }, view: "index" };
}; };
// The internal route table, matched after plugin routes: the auth/OAuth2 group (src/auth/ // The internal route table, matched after plugin routes: the auth/OAuth2 group (src/auth/
+7 -1
View File
@@ -1,6 +1,6 @@
import assert from "node:assert/strict"; import assert from "node:assert/strict";
import test from "node:test"; import test from "node:test";
import { buildPluginChrome } from "./chrome.ts"; import { buildPluginChrome, shellUser } from "./chrome.ts";
import { DEFAULT_MENU, type MenuConfig } from "./menu-config.ts"; import { DEFAULT_MENU, type MenuConfig } from "./menu-config.ts";
import type { NavNode } from "./nav.ts"; import type { NavNode } from "./nav.ts";
import type { Plugin } from "../plugin-host/plugin.ts"; import type { Plugin } from "../plugin-host/plugin.ts";
@@ -74,3 +74,9 @@ test("branding logo + default theme flow through when set", () => {
assert.equal(chrome.brand.name, "Acme"); assert.equal(chrome.brand.name, "Acme");
assert.equal(chrome.theme, "dark"); assert.equal(chrome.theme, "dark");
}); });
test("shellUser derives the profile from the real user; anonymous → Guest", () => {
assert.deepEqual(shellUser(null), { email: "", initials: "G", name: "Guest" });
// Real user: name = email local part, email kept, initials = first two letters of the local part.
assert.deepEqual(shellUser({ email: "ada@example.com", id: "u1", permissions: [] }), { email: "ada@example.com", initials: "AD", name: "ada" });
});
+28 -1
View File
@@ -8,10 +8,15 @@ import type { Translate } from "../i18n/translate.ts";
import { type MenuConfig } from "./menu-config.ts"; import { type MenuConfig } from "./menu-config.ts";
import { composeNav, type NavNode } from "./nav.ts"; import { composeNav, type NavNode } from "./nav.ts";
import type { Plugin } from "../plugin-host/plugin.ts"; import type { Plugin } from "../plugin-host/plugin.ts";
import { branding, shellUser, type ShellUser } from "./shell-context.ts";
const DASHBOARD_NAV: NavNode = { href: "/dashboard", icon: "i-grid", id: "dashboard", label: "nav.dashboard", session: true }; const DASHBOARD_NAV: NavNode = { href: "/dashboard", icon: "i-grid", id: "dashboard", label: "nav.dashboard", session: true };
export interface ShellUser {
email: string;
initials: string;
name: string;
}
export interface PageChrome { export interface PageChrome {
brand: { logo?: string; name: string; sub?: string }; brand: { logo?: string; name: string; sub?: string };
csrfToken: string; // double-submit token for the shell's Sign-out form + a plugin's own forms csrfToken: string; // double-submit token for the shell's Sign-out form + a plugin's own forms
@@ -65,6 +70,28 @@ export function buildPluginChrome(opts: ChromeOptions): PageChrome {
}; };
} }
// The User carries no display name (the JWT holds only id/email/permissions), so the profile shows
// the email's local part as the name; anonymous ⇒ "Guest".
export function shellUser(user: User | null | undefined, t: Translate = ENGLISH): ShellUser {
if (!user) {
const guest = t("shell.guest");
return { email: "", initials: ([...guest][0] ?? "?").toUpperCase(), name: guest }; // by character: the word is translated
}
const local = user.email.split("@")[0] || user.email;
return { email: user.email, initials: (local.slice(0, 2) || "U").toUpperCase(), name: local };
}
// `name`/`sub` pass through `t`, so a catalog key is translated and an operator's own wording renders as written.
function branding(menu: MenuConfig, t: Translate): { logo?: string; name: string; sub?: string; theme?: string } {
const b = menu.branding;
return {
...(b.logo != null ? { logo: b.logo } : {}),
name: t(b.name),
...(b.sub != null ? { sub: t(b.sub) } : {}),
...(b.theme != null ? { theme: b.theme } : {}),
};
}
function translateNav(nodes: NavNode[], t: Translate): NavNode[] { function translateNav(nodes: NavNode[], t: Translate): NavNode[] {
return nodes.map((node) => ({ return nodes.map((node) => ({
...node, ...node,
-6
View File
@@ -12,7 +12,6 @@
| ID | Release | Blocked by | Exempt | Item | L | E | I | R | S | Moves | Score | | ID | Release | Blocked by | Exempt | Item | L | E | I | R | S | Moves | Score |
|---|---|---|---|---|---|---|---|---|---|---|---| |---|---|---|---|---|---|---|---|---|---|---|---|
| 24 | 0.4.21 | | | **Retire `src/ui/shell-context.ts` and render the dashboard from `ctx.chrome`.** | 2 | 1 | 2 | 3 | 4 | code health by 3 | 9.0 |
| 21 | 0.4.21 | | | **Fail the gate on a function that exceeds a size or complexity limit.** | 3 | 1 | 1 | 3 | 5 | code health by 4 | 7.2 | | 21 | 0.4.21 | | | **Fail the gate on a function that exceeds a size or complexity limit.** | 3 | 1 | 1 | 3 | 5 | code health by 4 | 7.2 |
| 32 | 0.4.21 | | | **Fail the gate on a stylesheet feature the browser floor lacks, unless it is guarded.** | 3 | 1 | 1 | 3 | 4 | code health by 2 | 6.0 | | 32 | 0.4.21 | | | **Fail the gate on a stylesheet feature the browser floor lacks, unless it is guarded.** | 3 | 1 | 1 | 3 | 4 | code health by 2 | 6.0 |
| 20 | 0.4.21 | | | **Redirect an anonymous hit on a session-needing flow page to sign-in, instead of a 500.** | 2 | 2 | 2 | 4 | 3 | 2 by 4 | 4.2 | | 20 | 0.4.21 | | | **Redirect an anonymous hit on a session-needing flow page to sign-in, instead of a 500.** | 2 | 2 | 2 | 4 | 3 | 2 by 4 | 4.2 |
@@ -34,11 +33,6 @@
## Details ## Details
### 24. Retire `src/ui/shell-context.ts` and render the dashboard from `ctx.chrome`.
`ShellModel`/`buildShellContext` has one consumer, the dashboard, and duplicates `PageChrome` on
almost every field. Keep `shellUser` as the shared primitive.
### 21. Fail the gate on a function that exceeds a size or complexity limit. ### 21. Fail the gate on a function that exceeds a size or complexity limit.
Only `tsc --strict` runs today. A new tool is a new dependency, so justify it. Only `tsc --strict` runs today. A new tool is a new dependency, so justify it.
+8 -9
View File
@@ -2,9 +2,9 @@
Dashboard: the gated post-login app home. The built-in default is a short instructional Dashboard: the gated post-login app home. The built-in default is a short instructional
starter — what /dashboard is and how to replace it from a plugin. Composes the app shell (the one starter — what /dashboard is and how to replace it from a plugin. Composes the app shell (the one
global menu via model.nav) around the prose. A plugin owns the real dashboard via a `dashboard` global menu via model.nav) around the prose. A plugin owns the real dashboard via a `dashboard`
handler; this renders until then. Data: model { nav, shell }. handler; this renders until then. Data: chrome (PageChrome).
%><% %><%
const nav = include("partials/nav-tree", { nodes: model.nav }); const nav = include("partials/nav-tree", { nodes: chrome.nav });
// These four messages carry markup (<code> spans), so they render raw — the documented // These four messages carry markup (<code> spans), so they render raw — the documented
// markup-carrying case. They interpolate nothing, so there is no untrusted data to escape. // markup-carrying case. They interpolate nothing, so there is no untrusted data to escape.
const body = ` const body = `
@@ -27,12 +27,11 @@
-%> -%>
<%- include("partials/shell", { <%- include("partials/shell", {
body, body,
brand: model.shell.brand, brand: chrome.brand,
breadcrumbs: model.shell.breadcrumbs, breadcrumbs: [{ label: t("dashboard.title") }],
csrfToken: model.shell.csrfToken, csrfToken: chrome.csrfToken,
nav, nav,
signInHref: model.shell.signInHref, theme: chrome.theme,
theme: model.shell.theme, title: t("dashboard.title"),
title: model.shell.title, user: chrome.user,
user: model.shell.user,
}) %> }) %>