From de4eb7d9eee21a80cf98abe99b765bbd8b1c5f50 Mon Sep 17 00:00:00 2001 From: Lilleman auf Larv Date: Sat, 31 Oct 2026 08:37:45 +0100 Subject: [PATCH 1/5] Record the reading cost of the previous chunk --- docs/reading-cost.md | 17 +++++++++ src/ui/dashboard.test.ts | 28 --------------- src/ui/dashboard.ts | 29 --------------- src/ui/shell-context.test.ts | 32 ----------------- src/ui/shell-context.ts | 70 ------------------------------------ 5 files changed, 17 insertions(+), 159 deletions(-) delete mode 100644 src/ui/dashboard.test.ts delete mode 100644 src/ui/dashboard.ts delete mode 100644 src/ui/shell-context.test.ts delete mode 100644 src/ui/shell-context.ts diff --git a/docs/reading-cost.md b/docs/reading-cost.md index ad8f288..47125bd 100644 --- a/docs/reading-cost.md +++ b/docs/reading-cost.md @@ -72,3 +72,20 @@ Read most (opens and searches): - `CHANGELOG.md` 2 - `AGENTS.md` 1 - `src/auth/jwt-middleware.ts` 1 + +## Reading cost, chunk 9e8ffd0aa8dc, finished 2026-10-31T07:36:53Z + +98975 new input tokens before the first commit. + +Read most (opens and searches): + +- `README.md` 9 +- `ci.sh` 3 +- `e2e-tests/visual.spec.ts` 3 +- `todo.md` 3 +- `CHANGELOG.md` 2 +- `e2e-tests/compose.visual.yml` 2 +- `e2e-tests/language.spec.ts` 2 +- `e2e-tests/mock-oidc.ts` 2 +- `src/ci-gate.test.ts` 2 +- `.dockerignore` 1 diff --git a/src/ui/dashboard.test.ts b/src/ui/dashboard.test.ts deleted file mode 100644 index 48126ec..0000000 --- a/src/ui/dashboard.test.ts +++ /dev/null @@ -1,28 +0,0 @@ -import assert from "node:assert/strict"; -import { test } from "node:test"; -import { buildDashboardModel } from "./dashboard.ts"; -import type { NavNode } from "./nav.ts"; - -// The default /dashboard is an instructional starter: no mock data, just the unified -// menu + shell. The host passes the one global menu (ctx.chrome.nav); the model passes it through. -const NAV: NavNode[] = [{ href: "/dashboard", label: "Dashboard" }, { children: [{ href: "/admin/users", label: "Users" }], label: "Admin" }]; - -test("dashboard model: titled shell, passes the unified nav + csrf + user through", () => { - const m = buildDashboardModel({ csrfToken: "tok.sig", user: { email: "ada@x.io", id: "u1", permissions: ["admin"] }, nav: NAV }); - assert.equal(m.shell.title, "Dashboard"); - assert.equal(m.shell.csrfToken, "tok.sig"); - assert.equal(m.shell.user.name, "ada"); // real signed-in identity, not a demo profile - assert.deepEqual(m.nav, NAV); // the host's menu is used verbatim — the dashboard builds no nav of its own -}); - -test("dashboard model: sensible defaults (empty nav, no token, Guest) and branding from menu", () => { - const m = buildDashboardModel(); - assert.deepEqual(m.nav, []); - assert.equal(m.shell.csrfToken, ""); - assert.equal(m.shell.user.name, "Guest"); - assert.equal(m.shell.brand.name, "Plainpages"); - - const branded = buildDashboardModel({ menu: { branding: { name: "Acme Ops", sub: "Admin", theme: "dark" }, override: {} } }); - assert.equal(branded.shell.brand.name, "Acme Ops"); - assert.equal(branded.shell.theme, "dark"); -}); diff --git a/src/ui/dashboard.ts b/src/ui/dashboard.ts deleted file mode 100644 index c26d0ea..0000000 --- a/src/ui/dashboard.ts +++ /dev/null @@ -1,29 +0,0 @@ -// Dashboard view model: the gated "/dashboard" app home. By default a short instructional -// starter — what the dashboard is and how to replace it by exporting a `dashboard` handler from a -// plugin (views/index.ejs holds the prose). A plugin owns the real one (PluginManifest.dashboard); -// this placeholder renders until then. Pure: `nav` is the one global menu (ctx.chrome.nav), built -// once per request by the host, so the dashboard shows the exact same menu as every other page. - -import type { User } from "../http/context.ts"; -import { ENGLISH } from "../i18n/english.ts"; -import type { Translate } from "../i18n/translate.ts"; -import { DEFAULT_MENU, type MenuConfig } from "./menu-config.ts"; -import type { NavNode } from "./nav.ts"; -import { buildShellContext } from "./shell-context.ts"; - -export function buildDashboardModel(opts: { csrfToken?: string; menu?: MenuConfig; nav?: NavNode[]; t?: Translate; user?: User | null } = {}) { - const t = opts.t ?? ENGLISH; - return { - nav: opts.nav ?? [], - shell: buildShellContext({ - breadcrumbs: [{ label: t("dashboard.title") }], - csrfToken: opts.csrfToken ?? "", - menu: opts.menu ?? DEFAULT_MENU, - t, - title: t("dashboard.title"), - user: opts.user ?? null, - }), - }; -} - -export type DashboardModel = ReturnType; diff --git a/src/ui/shell-context.test.ts b/src/ui/shell-context.test.ts deleted file mode 100644 index cfd2649..0000000 --- a/src/ui/shell-context.test.ts +++ /dev/null @@ -1,32 +0,0 @@ -import assert from "node:assert/strict"; -import { test } from "node:test"; -import { buildShellContext, shellUser } from "./shell-context.ts"; - -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" }); -}); - -test("buildShellContext maps branding + breadcrumbs, omitting unset optional fields", () => { - const bare = buildShellContext({ menu: { branding: { name: "Plainpages" }, override: {} }, title: "Users" }); - assert.deepEqual(bare.brand, { name: "Plainpages" }); // no logo/sub when unset - assert.equal(bare.theme, undefined); - assert.equal(bare.csrfToken, ""); - assert.equal(bare.user.name, "Guest"); - assert.equal(bare.signInHref, undefined); // omitted unless supplied (a public built-in screen would set it) - - const full = buildShellContext({ - breadcrumbs: [{ href: "/", label: "Home" }, { label: "Users" }], - csrfToken: "tok.sig", - menu: { branding: { logo: "/l.svg", name: "Acme", sub: "Ops", theme: "dark" }, override: {} }, - signInHref: "/login?return_to=%2Fx", - title: "Users", - user: { email: "a@b.c", id: "u1", permissions: ["admin"] }, - }); - assert.deepEqual(full.brand, { logo: "/l.svg", name: "Acme", sub: "Ops" }); - assert.equal(full.theme, "dark"); - assert.equal(full.csrfToken, "tok.sig"); - assert.equal(full.breadcrumbs?.length, 2); - assert.equal(full.signInHref, "/login?return_to=%2Fx"); -}); diff --git a/src/ui/shell-context.ts b/src/ui/shell-context.ts deleted file mode 100644 index aa173a2..0000000 --- a/src/ui/shell-context.ts +++ /dev/null @@ -1,70 +0,0 @@ -// Shell view-model builder: the brand/theme/user/title block every app-shell page -// (the home dashboard, the built-in admin screens) hands to shell.ejs. Pure. Extracted so the -// shell user is the *real* signed-in identity — no hardcoded demo profile — and branding is -// read from one place. 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 with the full email beneath, initials from -// the local part; anonymous ⇒ "Guest". - -import type { User } from "../http/context.ts"; -import { ENGLISH } from "../i18n/english.ts"; -import type { Translate } from "../i18n/translate.ts"; -import { type MenuConfig } from "./menu-config.ts"; - -// The brand block both the chrome and the shell model carry. `name`/`sub` pass through `t`, so a -// catalog key is translated and an operator's own wording renders as written. -export 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 } : {}), - }; -} - -export interface ShellUser { - email: string; - initials: string; - name: string; -} - -export interface ShellModel { - brand: { logo?: string; name: string; sub?: string }; - breadcrumbs?: { href?: string; label: string }[]; - csrfToken: string; - signInHref?: string; // anonymous "Sign in" target (mirrors PageChrome.signInHref); a gated screen omits it - theme?: string; - title: string; - user: ShellUser; -} - -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 }; -} - -export function buildShellContext(opts: { - breadcrumbs?: { href?: string; label: string }[]; - csrfToken?: string; - menu: MenuConfig; - signInHref?: string; - t?: Translate; - title: string; - user?: User | null; -}): ShellModel { - const t = opts.t ?? ENGLISH; - const { theme, ...brand } = branding(opts.menu, t); - return { - brand, - ...(opts.breadcrumbs ? { breadcrumbs: opts.breadcrumbs } : {}), - csrfToken: opts.csrfToken ?? "", - ...(opts.signInHref != null ? { signInHref: opts.signInHref } : {}), - ...(theme != null ? { theme } : {}), - title: opts.title, - user: shellUser(opts.user, t), - }; -} -- 2.52.0 From 352de29013ec66347893fd25a2fd19fda09aad1b Mon Sep 17 00:00:00 2001 From: Lilleman auf Larv Date: Sat, 31 Oct 2026 08:37:45 +0100 Subject: [PATCH 2/5] Retire shell-context and render the dashboard from ctx.chrome --- README.md | 2 +- src/http/app.ts | 3 +-- src/ui/chrome.test.ts | 8 +++++++- src/ui/chrome.ts | 29 ++++++++++++++++++++++++++++- todo.md | 6 ------ views/index.ejs | 17 ++++++++--------- 6 files changed, 45 insertions(+), 20 deletions(-) diff --git a/README.md b/README.md index 252e5d0..c02d72d 100644 --- a/README.md +++ b/README.md @@ -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 (ctx.system) · discovery · router · hooks · view-resolver · storage (the rules) · 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 plugin-api/ The `@plainpages/plugin-api` package — the author barrel, linked into /node_modules diff --git a/src/http/app.ts b/src/http/app.ts index b354c63..f55baff 100644 --- a/src/http/app.ts +++ b/src/http/app.ts @@ -8,7 +8,6 @@ import { buildPluginChrome, type PageChrome } from "../ui/chrome.ts"; import { buildContext, type RequestContext, type User } from "./context.ts"; import { csrfCookie, ensureCsrfToken, verifyCsrfRequest } from "../auth/csrf.ts"; import type { Denylist } from "../auth/denylist.ts"; -import { buildDashboardModel } from "../ui/dashboard.ts"; import { PLUGINS_DIR } from "../plugin-host/discovery.ts"; import { GuardError, loginRedirect } from "../auth/guards.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); 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/ diff --git a/src/ui/chrome.test.ts b/src/ui/chrome.test.ts index df39503..87b79fd 100644 --- a/src/ui/chrome.test.ts +++ b/src/ui/chrome.test.ts @@ -1,6 +1,6 @@ import assert from "node:assert/strict"; 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 type { NavNode } from "./nav.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.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" }); +}); diff --git a/src/ui/chrome.ts b/src/ui/chrome.ts index 984cd87..d5f42fb 100644 --- a/src/ui/chrome.ts +++ b/src/ui/chrome.ts @@ -8,10 +8,15 @@ import type { Translate } from "../i18n/translate.ts"; import { type MenuConfig } from "./menu-config.ts"; import { composeNav, type NavNode } from "./nav.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 }; +export interface ShellUser { + email: string; + initials: string; + name: string; +} + export interface PageChrome { brand: { logo?: string; name: string; sub?: string }; 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[] { return nodes.map((node) => ({ ...node, diff --git a/todo.md b/todo.md index 87050da..6c4759e 100644 --- a/todo.md +++ b/todo.md @@ -12,7 +12,6 @@ | 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 | | 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 | @@ -34,11 +33,6 @@ ## 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. Only `tsc --strict` runs today. A new tool is a new dependency, so justify it. diff --git a/views/index.ejs b/views/index.ejs index 29a9dee..b5a9e53 100644 --- a/views/index.ejs +++ b/views/index.ejs @@ -2,9 +2,9 @@ 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 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 ( spans), so they render raw — the documented // markup-carrying case. They interpolate nothing, so there is no untrusted data to escape. const body = ` @@ -27,12 +27,11 @@ -%> <%- include("partials/shell", { body, - brand: model.shell.brand, - breadcrumbs: model.shell.breadcrumbs, - csrfToken: model.shell.csrfToken, + brand: chrome.brand, + breadcrumbs: [{ label: t("dashboard.title") }], + csrfToken: chrome.csrfToken, nav, - signInHref: model.shell.signInHref, - theme: model.shell.theme, - title: model.shell.title, - user: model.shell.user, + theme: chrome.theme, + title: t("dashboard.title"), + user: chrome.user, }) %> -- 2.52.0 From 57a1a2899fe73b58b2c9f1d864669d8861ff7f1d Mon Sep 17 00:00:00 2001 From: Lilleman auf Larv Date: Sat, 31 Oct 2026 08:38:10 +0100 Subject: [PATCH 3/5] =?UTF-8?q?Answer=20security=20round=201:=200=20?= =?UTF-8?q?=F0=9F=94=B4,=200=20=F0=9F=9F=A1=20(0=20misread);=20no=20findin?= =?UTF-8?q?gs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit -- 2.52.0 From 437a2e8580df77402a5116521479164303bcc28d Mon Sep 17 00:00:00 2001 From: Lilleman auf Larv Date: Sat, 31 Oct 2026 08:41:59 +0100 Subject: [PATCH 4/5] =?UTF-8?q?Answer=20stability=20round=201:=200=20?= =?UTF-8?q?=F0=9F=94=B4,=203=20=F0=9F=9F=A1=20(0=20misread);=20pin=20dashb?= =?UTF-8?q?oard=20heading=20and=20branding,=20fix=20stale=20comment?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 4 ++-- src/http/app.test.ts | 2 ++ src/ui/chrome.test.ts | 9 ++++++++- src/ui/chrome.ts | 4 +--- views/index.ejs | 6 ++---- 5 files changed, 15 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index c02d72d..8e6a230 100644 --- a/README.md +++ b/README.md @@ -1825,8 +1825,8 @@ 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 (ctx.system) · discovery · router · hooks · view-resolver · storage (the rules) · storage-provisioning (the DDL; bootstrap-only, holds the driver) - ui/ chrome (the one global menu) · nav (composeNav) · - menu-config (`#menu-config`) · icons (lucide sprite builder) · list-query · paginate + ui/ chrome (`ctx.chrome`: menu, brand, user) · nav (composeNav) · 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 views/ Core EJS in the one app shell: home, index, auth, oauth-consent, error, 403/404/500/503, diff --git a/src/http/app.test.ts b/src/http/app.test.ts index 706c227..591f059 100644 --- a/src/http/app.test.ts +++ b/src/http/app.test.ts @@ -65,6 +65,8 @@ test("the dashboard at /dashboard: the instructional starter in the unified shel assert.match(html, /