From a558f8340f9f5f67212774ae147e7c614df63bab Mon Sep 17 00:00:00 2001 From: Lilleman auf Larv Date: Sun, 8 Nov 2026 19:25:57 +0100 Subject: [PATCH] =?UTF-8?q?Answer=20prose=20round=201:=200=20=F0=9F=94=B4,?= =?UTF-8?q?=200=20=F0=9F=9F=A1=20(0=20misread);=20apply=20the=20nits?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- AGENTS.md | 6 +++--- CHANGELOG.md | 2 +- README.md | 4 ++-- e2e-tests/full-flow.spec.ts | 1 - src/plugin-host/plugin.ts | 3 +-- src/ui/chrome.ts | 4 +--- 6 files changed, 8 insertions(+), 12 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index f7297d7..b1156b7 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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. - **A permission name is always `:`** — `scheduling:read`, `users:write`. A bare 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 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 @@ -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 "enhancing" one of these triggers is what would cost it its accessibility. `
` stays where 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; - the plugin-declared links above it are `user.menu`) — keep the two in step. + profile menu (its trigger composes escaped user values and its Sign-out item is a CSRF POST form) — keep + the two in step. - **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: they are what makes the off-canvas panel usable with a long tree. `#nav-toggle` is `position: fixed` — diff --git a/CHANGELOG.md b/CHANGELOG.md index 00e1094..7802815 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 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 diff --git a/README.md b/README.md index fb6da2d..cb94f40 100644 --- a/README.md +++ b/README.md @@ -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). | | `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). | -| `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). | | `hooks` | no | See [Hooks](#hooks). | | `settings` | no | Configuration this plugin accepts, one `PLUGIN_SETTING__` 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 [``](#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 -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 [`:`](#naming-a-permission), or a route, nav node or profile-menu entry that does not name [exactly one gate](#public-pages--menu-items). diff --git a/e2e-tests/full-flow.spec.ts b/e2e-tests/full-flow.spec.ts index 51c26d1..03c8648 100644 --- a/e2e-tests/full-flow.spec.ts +++ b/e2e-tests/full-flow.spec.ts @@ -250,7 +250,6 @@ test.describe.serial("authenticated admin journey", () => { test("logout: signing out ends the session and returns to the login page", async () => { await page.goto("/dashboard"); 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 a.menu-item[href="/scheduling/mine"]')).toHaveCount(1); await page.locator('form[action="/logout"] button[type="submit"]').click(); diff --git a/src/plugin-host/plugin.ts b/src/plugin-host/plugin.ts index 710c161..e0fa6db 100644 --- a/src/plugin-host/plugin.ts +++ b/src/plugin-host/plugin.ts @@ -90,8 +90,7 @@ export interface PluginManifest; 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[]; - // Links added above Sign out in the profile dropdown, each gated like a nav node and shown only to a - // signed-in visitor; `icon` is a Lucide sprite id, and a `label` naming a catalog key is translated. + // Links above Sign out in the profile dropdown, each gated like a nav node; only a signed-in visitor sees any. profileMenu?: ProfileMenuEntry[]; routes?: Route[]; // Operator-supplied configuration, one PLUGIN_SETTING__ variable per key; the resolved diff --git a/src/ui/chrome.ts b/src/ui/chrome.ts index 4f6188c..93102a6 100644 --- a/src/ui/chrome.ts +++ b/src/ui/chrome.ts @@ -26,7 +26,7 @@ export interface ProfileMenuEntry extends ProfileLink, Gate {} export interface ShellUser { email: 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; } @@ -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[] { const user = opts.user ?? null; if (!user) return [];