Answer prose round 1: 0 🔴, 0 🟡 (0 misread); apply the nits

This commit is contained in:
2026-11-08 19:25:57 +01:00
parent 777c0bd86d
commit a558f8340f
6 changed files with 8 additions and 12 deletions
+3 -3
View File
@@ -162,7 +162,7 @@ Revisit only if the stated reason stops holding.
"permission" (the `Resource` `permits`: view/edit/delete) is the separate per-row tier. "permission" (the `Resource` `permits`: view/edit/delete) is the separate per-row tier.
- **A permission name is always `<resource>:<action>`** — `scheduling:read`, `users:write`. A bare - **A permission name is always `<resource>:<action>`** — `scheduling:read`, `users:write`. A bare
word names *who someone is* (a role), and roles are groups here. **Enforced at discovery** word names *who someone is* (a role), and roles are groups here. **Enforced at discovery**
(`isValidPermissionName` in `plugin-host/plugin.ts`, checked by `shapeError` over every route/nav (`isValidPermissionName` in `plugin-host/plugin.ts`, checked by `shapeError` over every route, nav and profile-menu
`permission` and every declared name), fail-loud like any other manifest rule — not only in the `permission` and every declared name), fail-loud like any other manifest rule — not only in the
admin GUI, which an operator removes by not copying it in. admin GUI, which an operator removes by not copying it in.
- **Names are authored in plugin code; only grants live in Keto.** The host collects every installed - **Names are authored in plugin code; only grants live in Keto.** The host collects every installed
@@ -288,8 +288,8 @@ Revisit only if the stated reason stops holding.
declarative attribute's alone** — open a panel from script and no engine applies it, so declarative attribute's alone** — open a panel from script and no engine applies it, so
"enhancing" one of these triggers is what would cost it its accessibility. `<details>` stays where "enhancing" one of these triggers is what would cost it its accessibility. `<details>` stays where
it means disclosure rather than popup: the nav tree. `shell.ejs` hand-rolls the same block for the it means disclosure rather than popup: the nav tree. `shell.ejs` hand-rolls the same block for the
profile menu (its trigger composes escaped user values and its Sign-out item is a CSRF POST form; profile menu (its trigger composes escaped user values and its Sign-out item is a CSRF POST form) — keep
the plugin-declared links above it are `user.menu`) — keep the two in step. the two in step.
- **One scroller, the document** (priority 1). `.app` is `min-height: 100dvh`. `.nav`'s - **One scroller, the document** (priority 1). `.app` is `min-height: 100dvh`. `.nav`'s
`overflow-y: auto` and `.side-footer`'s `flex: 0 0 auto` are not leftovers of a bounded frame: `overflow-y: auto` and `.side-footer`'s `flex: 0 0 auto` are not leftovers of a bounded frame:
they are what makes the off-canvas panel usable with a long tree. `#nav-toggle` is `position: fixed` — they are what makes the off-canvas panel usable with a long tree. `#nav-toggle` is `position: fixed` —
+1 -1
View File
@@ -37,7 +37,7 @@ the links. A list that renamed its params in `parseListQuery` passes the same na
Declare `profileMenu: [{ href, label, icon?, …gate }]` in the manifest. Each entry names exactly one Declare `profileMenu: [{ href, label, icon?, …gate }]` in the manifest. Each entry names exactly one
gate like a nav node, and shows above Sign out to a signed-in visitor who passes it. Discovery gate like a nav node, and shows above Sign out to a signed-in visitor who passes it. Discovery
refuses an entry with no `href` or no gate. refuses an entry without an `href`, a `label`, or exactly one gate.
### A plugin can version its own `/public/` files ### A plugin can version its own `/public/` files
+2 -2
View File
@@ -468,7 +468,7 @@ folder-derived `id` to produce the loaded `Plugin`.
| `dashboard` | no | A `RouteHandler` that owns the **gated** app home `/dashboard`. At most one plugin may declare it. See [The landing pages](#the-landing-pages-home--dashboard). | | `dashboard` | no | A `RouteHandler` that owns the **gated** app home `/dashboard`. At most one plugin may declare it. See [The landing pages](#the-landing-pages-home--dashboard). |
| `nav` | no | `NavNode[]` fragment (same shape `composeNav` consumes). Every node names [exactly one gate](#public-pages--menu-items). `icon` is a Lucide sprite id (`src/ui/icons.ts`); node `id`s must be globally unique. A `label` that names a catalog key is [translated](#languages-i18n); anything else renders as written. | | `nav` | no | `NavNode[]` fragment (same shape `composeNav` consumes). Every node names [exactly one gate](#public-pages--menu-items). `icon` is a Lucide sprite id (`src/ui/icons.ts`); node `id`s must be globally unique. A `label` that names a catalog key is [translated](#languages-i18n); anything else renders as written. |
| `permissions` | no | Permissions this plugin gates on. See [Nav & permission gates](#nav--permission-gates). | | `permissions` | no | Permissions this plugin gates on. See [Nav & permission gates](#nav--permission-gates). |
| `profileMenu` | no | `ProfileMenuEntry[]`: `{ href, label, icon?, …gate }` links added above Sign out in the profile dropdown, shown only to a signed-in visitor. Each names [exactly one gate](#public-pages--menu-items); `icon` and `label` follow the same rules as a `nav` node. The host hands the visitor's entries to the shell as `chrome.user.menu`. | | `profileMenu` | no | `ProfileMenuEntry[]`: `{ href, label, icon?, …gate }` links added above Sign out in the profile dropdown, shown only to a signed-in visitor. Each names [exactly one gate](#public-pages--menu-items); `icon` and `label` follow the same rules as a `nav` node. |
| `routes` | no | See [Routes & handlers](#routes--handlers). | | `routes` | no | See [Routes & handlers](#routes--handlers). |
| `hooks` | no | See [Hooks](#hooks). | | `hooks` | no | See [Hooks](#hooks). |
| `settings` | no | Configuration this plugin accepts, one `PLUGIN_SETTING_<ID>_<KEY>` variable per key, resolved and validated at boot and handed to `onBoot`. See [Plugin settings](#plugin-settings). | | `settings` | no | Configuration this plugin accepts, one `PLUGIN_SETTING_<ID>_<KEY>` variable per key, resolved and validated at boot and handed to `onBoot`. See [Plugin settings](#plugin-settings). |
@@ -738,7 +738,7 @@ The host detects collisions across all discovered plugins with `findConflicts` a
| `permission` | warn | A permission name is declared by more than one plugin. Sharing is legitimate; pick a more specific [`<resource>`](#naming-a-permission) if unintended. Also warned: a route, nav node or profile-menu entry gates on a permission no installed plugin declares, so the admin screens can't grant it; declare it in that plugin's `permissions`. | | `permission` | warn | A permission name is declared by more than one plugin. Sharing is legitimate; pick a more specific [`<resource>`](#naming-a-permission) if unintended. Also warned: a route, nav node or profile-menu entry gates on a permission no installed plugin declares, so the admin screens can't grant it; declare it in that plugin's `permissions`. |
Mount-path uniqueness needs no rule of its own — it follows from the id check. Discovery also Mount-path uniqueness needs no rule of its own — it follows from the id check. Discovery also
rejects **per-manifest shape errors**: a non-array `nav`/`profileMenu`/`routes`/`permissions`, a non-function rejects **per-manifest shape errors**: a non-array `nav`/`permissions`/`profileMenu`/`routes`, a non-function
`home`/`dashboard`, a permission name that isn't [`<resource>:<action>`](#naming-a-permission), or a `home`/`dashboard`, a permission name that isn't [`<resource>:<action>`](#naming-a-permission), or a
route, nav node or profile-menu entry that does not name [exactly one gate](#public-pages--menu-items). route, nav node or profile-menu entry that does not name [exactly one gate](#public-pages--menu-items).
-1
View File
@@ -250,7 +250,6 @@ test.describe.serial("authenticated admin journey", () => {
test("logout: signing out ends the session and returns to the login page", async () => { test("logout: signing out ends the session and returns to the login page", async () => {
await page.goto("/dashboard"); await page.goto("/dashboard");
await page.locator("button.profile").click(); // open the profile dropdown await page.locator("button.profile").click(); // open the profile dropdown
// The scheduling plugin's profile entry sits above Sign out, which stays last.
await expect(page.locator("#profile-menu .menu-item")).toHaveText(["My shifts", "Sign out"]); await expect(page.locator("#profile-menu .menu-item")).toHaveText(["My shifts", "Sign out"]);
await expect(page.locator('#profile-menu a.menu-item[href="/scheduling/mine"]')).toHaveCount(1); await expect(page.locator('#profile-menu a.menu-item[href="/scheduling/mine"]')).toHaveCount(1);
await page.locator('form[action="/logout"] button[type="submit"]').click(); await page.locator('form[action="/logout"] button[type="submit"]').click();
+1 -2
View File
@@ -90,8 +90,7 @@ export interface PluginManifest<S extends readonly SettingDecl[] = readonly Sett
hooks?: PluginHooks<S>; hooks?: PluginHooks<S>;
nav?: NavNode[]; // fragment merged into the menu (composeNav); node `icon` is a Lucide sprite id (src/ui/icons.ts), node ids must be globally unique nav?: NavNode[]; // fragment merged into the menu (composeNav); node `icon` is a Lucide sprite id (src/ui/icons.ts), node ids must be globally unique
permissions?: PermissionDecl[]; permissions?: PermissionDecl[];
// Links added above Sign out in the profile dropdown, each gated like a nav node and shown only to a // Links above Sign out in the profile dropdown, each gated like a nav node; only a signed-in visitor sees any.
// signed-in visitor; `icon` is a Lucide sprite id, and a `label` naming a catalog key is translated.
profileMenu?: ProfileMenuEntry[]; profileMenu?: ProfileMenuEntry[];
routes?: Route[]; routes?: Route[];
// Operator-supplied configuration, one PLUGIN_SETTING_<ID>_<KEY> variable per key; the resolved // Operator-supplied configuration, one PLUGIN_SETTING_<ID>_<KEY> variable per key; the resolved
+1 -3
View File
@@ -26,7 +26,7 @@ export interface ProfileMenuEntry extends ProfileLink, Gate {}
export interface ShellUser { export interface ShellUser {
email: string; email: string;
initials: string; initials: string;
menu?: ProfileLink[]; // permission-filtered, translated and locale-carried; the host always sets it, a hand-built fixture may omit it menu?: ProfileLink[]; // gate-filtered, translated, locale-carried. The host always sets it; a hand-built fixture may omit it
name: string; name: string;
} }
@@ -83,8 +83,6 @@ export function buildPluginChrome(opts: ChromeOptions): PageChrome {
}; };
} }
// A plugin's labels are keys in *its* catalog, hence its own translator. Only a signed-in visitor
// has a profile menu; the gate then decides per entry.
function profileLinks(opts: ChromeOptions, t: Translate, carryLocale: (href: string) => string): ProfileLink[] { function profileLinks(opts: ChromeOptions, t: Translate, carryLocale: (href: string) => string): ProfileLink[] {
const user = opts.user ?? null; const user = opts.user ?? null;
if (!user) return []; if (!user) return [];