Answer prose round 1: 0 🔴, 10 🟡 (0 misread); define code health, restore the topic index, give each decision a premise of its own
CI / full-gate (push) Successful in 3m39s

This commit is contained in:
2026-11-08 21:09:19 +01:00
parent 4c8ba083ce
commit 3179be0d11
8 changed files with 88 additions and 62 deletions
+3 -2
View File
@@ -69,8 +69,9 @@ jobs:
if: always() if: always()
run: | run: |
set -uo pipefail set -uo pipefail
# Cleanup, and the runner's Docker config is shared (docs/decisions.md) — a lost race here # This is cleanup: it must not fail a release that published, nor skip the overview job
# must not fail a release that published, nor skip the overview job that follows. # that follows. The runner's Docker config is shared, so a logout can lose a race
# (docs/decisions.md → "CI docker logins share the runner host's Docker config").
docker logout gitea.larvit.se || true docker logout gitea.larvit.se || true
docker logout docker.io || true docker logout docker.io || true
+19 -4
View File
@@ -10,8 +10,9 @@ change, sweep the prose you touched — this file, `README.md`, the example READ
comments — and cut it back to what a competent reader could not infer: comments — and cut it back to what a competent reader could not infer:
- **Delete history.** Git holds it. No "this moved from X", "used to be Y", "was tried and - **Delete history.** Git holds it. No "this moved from X", "used to be Y", "was tried and
rejected", "(declined twice)", dated changelog entries, or the symptom that prompted a fix. Record rejected", "(declined twice)", dated changelog entries (a decision's date in `docs/decisions.md`
the decision and the reason it *currently* turns on, nothing else. is not one), or the symptom that prompted a fix. Record the decision and the reason it
*currently* turns on, nothing else.
- **Delete restatement.** A comment that says what the adjacent line says, a doc paragraph that - **Delete restatement.** A comment that says what the adjacent line says, a doc paragraph that
re-explains a table above it, a file-map entry that expands the filename. The fix is deletion, re-explains a table above it, a file-map entry that expands the filename. The fix is deletion,
not trimming. not trimming.
@@ -69,8 +70,10 @@ branch, create a PR and merge it when the CI/CD turns green.
## Decisions ## Decisions
In `docs/decisions.md`, each with its reasoning and the premise it rests on. A review honours them Each title below is an entry in `docs/decisions.md`, with its reasoning and the premise it rests
rather than re-raising them. on; a review honours them.
### Structure & contracts
- `src/` is grouped by concern, and the core ships no domain screens - `src/` is grouped by concern, and the core ships no domain screens
- Plugins and config import the host only through the two barrels - Plugins and config import the host only through the two barrels
@@ -88,6 +91,9 @@ rather than re-raising them.
- `ctx.chrome` is lazily memoized, never built unconditionally - `ctx.chrome` is lazily memoized, never built unconditionally
- A plugin-owned render always runs on that plugin's context - A plugin-owned render always runs on that plugin's context
- Email is delegated to Kratos - Email is delegated to Kratos
### Authorization
- The vocabulary is `User` → `Group` → `Permission`, and there is no `Role` - The vocabulary is `User` → `Group` → `Permission`, and there is no `Role`
- A permission name is always `<resource>:<action>`, enforced at discovery - A permission name is always `<resource>:<action>`, enforced at discovery
- Permission names are authored in plugin code; only grants live in Keto - Permission names are authored in plugin code; only grants live in Keto
@@ -101,6 +107,9 @@ rather than re-raising them.
- `users:write` and `groups:write` each amount to full administrative access - `users:write` and `groups:write` each amount to full administrative access
- The CSRF token is unbound to the session - The CSRF token is unbound to the session
- Plainpages says "user" everywhere, where Ory says "identity" - Plainpages says "user" everywhere, where Ory says "identity"
### i18n
- The locale lives in the URL, never in a cookie - The locale lives in the URL, never in a cookie
- The core building blocks carry the locale, so a plugin doesn't have to - The core building blocks carry the locale, so a plugin doesn't have to
- `locale` and the i18n view locals are reserved by the host - `locale` and the i18n view locals are reserved by the host
@@ -110,9 +119,15 @@ rather than re-raising them.
- An unknown translation key renders as itself - An unknown translation key renders as itself
- `t()` returns raw text, and the view escapes it - `t()` returns raw text, and the view escapes it
- RTL is out of scope until there is a real use case - RTL is out of scope until there is a real use case
### UI
- A dropdown is a `<button popovertarget>` and a `[popover]`, never a `<details>` - A dropdown is a `<button popovertarget>` and a `[popover]`, never a `<details>`
- The document is the one scroller - The document is the one scroller
- `ICON_NAMES` is a host-owned registry, not a plugin contract - `ICON_NAMES` is a host-owned registry, not a plugin contract
### Build, test & release
- Dependencies install to `/node_modules`, above `WORKDIR /app` - Dependencies install to `/node_modules`, above `WORKDIR /app`
- A container whose output a human then edits or deletes runs as the invoking user - A container whose output a human then edits or deletes runs as the invoking user
- Anything the browser logs fails the E2E test that provoked it - Anything the browser logs fails the E2E test that provoked it
+53 -47
View File
@@ -1,6 +1,7 @@
# Decisions # Decisions
"Goal N" is README → Goals; "code health" is the goal every project shares. "Goal N" is README → Goals. "Code health" is the goal every project shares: the code is easy to
understand and change safely.
## `src/` is grouped by concern, and the core ships no domain screens ## `src/` is grouped by concern, and the core ships no domain screens
@@ -14,7 +15,8 @@ is a drop-in plugin (`examples/plugins/admin/`).
## Plugins and config import the host only through the two barrels ## Plugins and config import the host only through the two barrels
2026-08-17, the maintainer. Goal 7. Valid while the barrels are the contract surface. 2026-08-17, the maintainer. Goal 7. Valid while plugin authors typecheck against a host tree rather
than a published package.
`@plainpages/plugin-api` → `plugin-api/index.ts` → `src/plugin-host/plugin-api.ts`, and `@plainpages/plugin-api` → `plugin-api/index.ts` → `src/plugin-host/plugin-api.ts`, and
`#menu-config` → `src/ui/menu-config.ts`; never a relative `../../src/*` path. These two barrels are `#menu-config` → `src/ui/menu-config.ts`; never a relative `../../src/*` path. These two barrels are
@@ -22,7 +24,7 @@ the whole contract surface, so don't "fix" either back to a relative path.
## The Ory client types are contract-visible through the barrel ## The Ory client types are contract-visible through the barrel
2026-07-02, the maintainer. Goal 7. Valid while the barrel re-exports them. 2026-07-02, the maintainer. Goal 7. Valid while plugins get Ory admin clients through `ctx.system`.
`@plainpages/plugin-api` re-exports `KratosAdmin`, `KetoClient`, `HydraAdmin`, their DTOs and their `@plainpages/plugin-api` re-exports `KratosAdmin`, `KetoClient`, `HydraAdmin`, their DTOs and their
error classes. Changing those shapes needs a major `apiVersion` bump, not a free refactor. error classes. Changing those shapes needs a major `apiVersion` bump, not a free refactor.
@@ -87,8 +89,8 @@ uploads.
Per-plugin databases and roles bound accidents, not hostile plugins: `PLUGIN_DB_SECRET` is in Per-plugin databases and roles bound accidents, not hostile plugins: `PLUGIN_DB_SECRET` is in
`web`'s environment during `onBoot`, and a plugin already holds `ctx.system`'s Ory admin clients. So `web`'s environment during `onBoot`, and a plugin already holds `ctx.system`'s Ory admin clients. So
cross-plugin DB isolation is containment, and README says so rather than implying a sandbox. This cross-plugin DB isolation is containment, and README says so. This follows AGENTS.md → priority 7:
follows AGENTS.md → priority 7: crash-isolation is a non-goal. crash-isolation is a non-goal.
`server.ts` still deletes the secret from `process.env` right after `loadConfig`, before discovery `server.ts` still deletes the secret from `process.env` right after `loadConfig`, before discovery
imports any plugin module. That ordering is the whole point: move the line earlier if anything, imports any plugin module. That ordering is the whole point: move the line earlier if anything,
@@ -118,8 +120,9 @@ and `src/compose.test.ts` for a rename.
2026-08-19, the maintainer. Goal 7. Valid while no plugin needs an orderly drain of its pools. 2026-08-19, the maintainer. Goal 7. Valid while no plugin needs an orderly drain of its pools.
Adding to the context costs a minor bump and removing from it a major, so the shape errs small Removing any field from the context costs a major bump, so all six stay. Adding one costs only a
elsewhere. Pools handed to a plugin are reaped on process exit. minor, so the context otherwise gains a field only when a plugin needs it. Pools handed to a plugin
are reaped on process exit.
## `config/` is a plain dir with no `package.json` of its own ## `config/` is a plain dir with no `package.json` of its own
@@ -132,10 +135,10 @@ override ever needs dependencies, `config/` needs the package treatment `plugins
2026-08-18, the maintainer. Goal 7. Valid while nothing is installed in the wild. 2026-08-18, the maintainer. Goal 7. Valid while nothing is installed in the wild.
Allowing it would cost a warning and a re-parse per file, not a break: Node detects module syntax, Refusing is the safe direction: refuse→warn relaxes freely, while warn→refuse breaks installed
so even a `.js` helper loads. And an operator on a read-only third-party mount cannot apply the plugins. Allowing it would cost only a warning and a re-parse per file, not a break: Node detects
remedy. Refused anyway because that direction is safe: refuse→warn relaxes freely, while warn→refuse module syntax, so even a `.js` helper loads. The cost of refusing is that an operator on a read-only
breaks installed plugins. third-party mount cannot apply the remedy.
## `examples/` mirrors the drop-in mount dirs ## `examples/` mirrors the drop-in mount dirs
@@ -163,10 +166,9 @@ the plugin's own keys render as bare keys on the pages it owns.
## Email is delegated to Kratos ## Email is delegated to Kratos
2026-06-23, the maintainer. Goal 3. Valid while Kratos sends recovery and verification mail. 2026-06-23, the maintainer. Goal 3. Valid while Kratos owns the recovery and verification flows.
Kratos renders and sends recovery and verification mail; `web` never touches SMTP. Customization is `web` never touches SMTP. Mail is customized through Kratos' `courier.template_override_path`.
Kratos' `courier.template_override_path`, not app code.
## The vocabulary is `User` → `Group` → `Permission`, and there is no `Role` ## The vocabulary is `User` → `Group` → `Permission`, and there is no `Role`
@@ -182,9 +184,10 @@ per-row tier.
2026-08-05, the maintainer. Goal 7. Valid while permissions are named by plugin authors. 2026-08-05, the maintainer. Goal 7. Valid while permissions are named by plugin authors.
Examples: `scheduling:read`, `users:write`. A bare word names who someone is (a role), and roles are Examples: `scheduling:read`, `users:write`. A bare word names who someone is (a role), and roles are
groups here. `isValidPermissionName` in `plugin-host/plugin.ts`, checked by `shapeError` over every groups here. `shapeError` checks every route, nav and profile-menu `permission`, and every declared
route, nav and profile-menu `permission` and every declared name, refuses the boot like any other name, with `isValidPermissionName` (`plugin-host/plugin.ts`). A bad name refuses the boot like any
manifest rule. It is not left to the admin GUI, which an operator removes by not copying it in. other manifest rule. The check is not left to the admin GUI, which an operator removes by not
copying it in.
## Permission names are authored in plugin code; only grants live in Keto ## Permission names are authored in plugin code; only grants live in Keto
@@ -236,16 +239,13 @@ function of the transport verb, and a route table must answer "what does this ne
## A gate is one of `public`, `session` or `permission`, named exactly once ## A gate is one of `public`, `session` or `permission`, named exactly once
2026-09-02, the maintainer. Goal 7. Valid while the central override's `groups` builds header nodes 2026-09-02, the maintainer. Goal 7. Valid while routes and nav nodes are declared in plugin
without a gate. manifests.
A route or nav node names exactly one gate. Discovery refuses none, two, and a flag spelled anything A route or nav node names exactly one gate. Discovery refuses none, two, and a flag spelled anything
but `true`, so a forgotten gate fails the boot rather than publishing a page. `src/auth/gate.ts` is but `true`, so a forgotten gate fails the boot rather than publishing a page. `src/auth/gate.ts` is
the one home of the rule that the plugin router, the host's own route table and the menu all read. the one home of the rule that the plugin router, the host's own route table and the menu all read.
The rule binds manifests only; the comment on `allows` says why a node without a gate passes it.
Exactly-one-gate is a discovery-time rule on manifests, not a runtime invariant. `allows({}, user)`
stays open by design, because the central override's `groups` builds header nodes that carry no
gate; making `allows` fail closed would hide every operator-grouped section.
`session` exists because a plugin whose data is the visitor's own (their upstream account, their own `session` exists because a plugin whose data is the visitor's own (their upstream account, their own
tokens) has no distinction a permission could name. The alternative, granting every newly tokens) has no distinction a permission could name. The alternative, granting every newly
@@ -258,7 +258,8 @@ user-changeable and can be reassigned to someone who would then inherit the prev
2026-09-02, the maintainer. Goal 9. Valid while `examples/plugins/scheduling` stays a teaching 2026-09-02, the maintainer. Goal 9. Valid while `examples/plugins/scheduling` stays a teaching
artifact rather than a maintained product. artifact rather than a maintained product.
An example is read far more often than it is changed, and each page reads top to bottom on its own. Each page then reads top to bottom on its own, and an example rarely changes, so the two copies cost
little.
## A `:read`-only holder is never shown a write affordance ## A `:read`-only holder is never shown a write affordance
@@ -269,19 +270,20 @@ remove. The permission picker still renders, disabled, because seeing who holds
of `:read`. A write-intent GET (a create form, a delete-confirm page) is the exception to of `:read`. A write-intent GET (a create form, a delete-confirm page) is the exception to
`actionForMethod` and gates on `:write`. `actionForMethod` and gates on `:write`.
Guards against self-lockout go with it, since undoing one needs a `curl` against Keto: Three guards go with it:
- You cannot revoke your own direct grants. - You cannot revoke your own direct grants, since undoing that self-lockout needs a `curl` against
Keto.
- A permission held through a group renders ticked but disabled, because unticked would state the - A permission held through a group renders ticked but disabled, because unticked would state the
opposite of the truth. opposite of the truth.
- Unticking a permission on a group you are in, removing yourself (or a nested group you are in) - Unticking a permission on a group you are in, removing yourself (or a nested group you are in)
from its members, or deleting the group is refused when you would lose a permission you don't hold from its members, or deleting the group is refused when you would lose a permission you don't hold
directly. directly. Undoing that self-lockout would need a `curl` against Keto too.
## `users:write` and `groups:write` each amount to full administrative access ## `users:write` and `groups:write` each amount to full administrative access
2026-08-05, the maintainer. Goal 2. Valid while `groups:write` can add anyone to any group and 2026-08-05, the maintainer. Goal 2. Valid while the admin GUI splits its write permissions by
`users:write` can mint a recovery code. resource.
`groups:write` adds you to any group, including one holding every permission; `users:write` mints a `groups:write` adds you to any group, including one holding every permission; `users:write` mints a
recovery code for any account. The containment the split buys is real on the read half only: recovery code for any account. The containment the split buys is real on the read half only:
@@ -291,8 +293,8 @@ recovery code for any account. The containment the split buys is real on the rea
2026-10-30, the maintainer. Goal 5. Valid while the session cookie stays `SameSite=Lax` or stricter. 2026-10-30, the maintainer. Goal 5. Valid while the session cookie stays `SameSite=Lax` or stricter.
`src/auth/csrf.ts`; README → Security model. It is the usual cost of a signed double-submit token, Leaving the token unbound is the usual cost of a signed double-submit token (`src/auth/csrf.ts`;
covered by `SameSite=Lax` and HSTS. README → Security model). `SameSite=Lax` and HSTS cover that cost.
## Plainpages says "user" everywhere, where Ory says "identity" ## Plainpages says "user" everywhere, where Ory says "identity"
@@ -305,11 +307,11 @@ House style, not a renamed concept. The single exception is the `Identity` DTO i
2026-08-03, the maintainer. Goal 5. Valid while a page's language should show in its address. 2026-08-03, the maintainer. Goal 5. Valid while a page's language should show in its address.
The order is `?locale=sv-SE` → `Accept-Language` → `en-US`, and when the URL asked for a locale the The host resolves the locale from `?locale=sv-SE`, then `Accept-Language`, then `en-US`. When the
host carries it onto the links it renders. A cookie would make a page's language invisible in its URL asked for a locale, the host carries it onto the links it renders. A cookie would make a page's
address and unshareable; the cost is that a plugin wraps its own hrefs. Matching is exact on a full language invisible in its address and unshareable; the cost is that a plugin wraps its own hrefs.
tag (`sv-FI` ≠ `sv-SE`), except that a lone language from `Accept-Language` takes the first regional Matching is exact on a full tag (`sv-FI` ≠ `sv-SE`), except that a lone language from
catalog for it. `Accept-Language` takes the first regional catalog for it.
## The core building blocks carry the locale, so a plugin doesn't have to ## The core building blocks carry the locale, so a plugin doesn't have to
@@ -407,8 +409,7 @@ by `.table-wrap`'s `overflow`. Four rules hold it together:
That `aria-expanded` guarantee is the declarative attribute's alone: open a panel from script and no That `aria-expanded` guarantee is the declarative attribute's alone: open a panel from script and no
engine applies it, so "enhancing" one of these triggers would cost it its accessibility. `<details>` engine applies it, so "enhancing" one of these triggers would cost it its accessibility. `<details>`
stays where it means disclosure rather than popup: the nav tree. `shell.ejs` hand-rolls the same stays where it means disclosure rather than popup: the nav tree. `shell.ejs` hand-rolls the same
block for the profile menu, because its trigger composes escaped user values and its Sign-out item block for the profile menu, and its comment says why. Keep the two in step.
is a CSRF POST form. Keep the two in step.
## The document is the one scroller ## The document is the one scroller
@@ -495,18 +496,23 @@ destination, so `git mv src/app.ts notes.md` would otherwise read as docs and sk
source file that was gone. `src/ci-gate.test.ts` locks the flags as a text guard, since the test source file that was gone. `src/ci-gate.test.ts` locks the flags as a text guard, since the test
image ships neither `git` nor `bash`. image ships neither `git` nor `bash`.
This is why the Docker Hub overview is `release-tooling/dockerhub-overview.md.tmpl` and not a `.md`: Because a `.md` change skips the gate, the Docker Hub overview is
a release reads it and a unit test guards it, so a `.md` name would let a broken `{{VERSION}}` merge `release-tooling/dockerhub-overview.md.tmpl`. A release reads it and a unit test guards it, so under
with its own guard skipped. `README.md` is the one markdown tests read, and a README-only change a `.md` name a broken `{{VERSION}}` would merge with that guard skipped.
skips them. Accepted: the `apiVersion` samples `release-tooling/contract-version.test.ts` checks are
illustrative, and the copies that matter (`examples/`, `views/`, the template) are gated. A Hub page `README.md` is the one markdown file tests read, so a README-only change skips them. That leaves two
command the README dropped (`release-tooling/dockerhub-overview.test.ts`) fails the next gated gaps:
change.
- An `apiVersion` sample in the README can go stale unchecked by
`release-tooling/contract-version.test.ts`. Accepted, because the README's samples are
illustrative; the copies authors build from (`examples/`, `views/`, the template) are gated.
- A README-only change that drops a command the Hub page shows fails only the next gated change, in
`release-tooling/dockerhub-overview.test.ts`.
## CI docker logins share the runner host's Docker config ## CI docker logins share the runner host's Docker config
2026-07-04, the maintainer. Code health. Valid while releases keep a single-maintainer cadence; 2026-07-04, the maintainer. Code health. Valid while releases keep a single-maintainer cadence; if
serialize with a workflow `concurrency` group if it ever bites. these races start costing re-runs, serialize the workflows with a `concurrency` group.
The act_runner is host-mode, so `docker login` and `logout` in the workflows mutate one shared The act_runner is host-mode, so `docker login` and `logout` in the workflows mutate one shared
`~/.docker/config.json`. Concurrent jobs can race: one job's logout can 401 another's push, and the `~/.docker/config.json`. Concurrent jobs can race: one job's logout can 401 another's push, and the
+2 -1
View File
@@ -30,7 +30,8 @@ export function permissionName(resource: AdminResource, action: AdminAction): st
// Every screen reads on GET/HEAD and mutates on POST. The route table and the in-handler guard both // Every screen reads on GET/HEAD and mutates on POST. The route table and the in-handler guard both
// go through this rather than each spelling the permission out, so they cannot drift. Deliberately // go through this rather than each spelling the permission out, so they cannot drift. Deliberately
// local: generalised, it would make authorization a function of the transport verb // local: generalised, it would make authorization a function of the transport verb
// (docs/decisions.md). // (docs/decisions.md → "`actionForMethod` is plugin-local and stays out of
// `@plainpages/plugin-api`").
export function actionForMethod(method: string): AdminAction { export function actionForMethod(method: string): AdminAction {
const verb = method.toUpperCase(); const verb = method.toUpperCase();
return verb === "GET" || verb === "HEAD" ? "read" : "write"; return verb === "GET" || verb === "HEAD" ? "read" : "write";
+3 -3
View File
@@ -10,9 +10,9 @@ export interface Gate {
session?: boolean; // any signed-in user, no grant to hold; anonymous is sent to /login session?: boolean; // any signed-in user, no grant to hold; anonymous is sent to /login
} }
// A gate naming none of the three allows everyone. Discovery refuses one on a route or a plugin's // A gate naming none of the three allows everyone. Discovery refuses one on a plugin's route, nav
// nav node, so only the header nodes `config/menu.ts`'s `groups` builds get here without a gate, // node or profile-menu entry, so only the header nodes `config/menu.ts`'s `groups` builds get here
// and failing closed would hide every section an operator grouped. // without a gate, and failing closed would hide every section an operator grouped.
export function allows(gate: Gate, user: User | null): boolean { export function allows(gate: Gate, user: User | null): boolean {
if (gate.public === true) return true; if (gate.public === true) return true;
if (gate.session === true) return user !== null; if (gate.session === true) return user !== null;
+3 -3
View File
@@ -31,9 +31,9 @@ test("init SQL gives each Ory service its own database, and leaves plugin databa
} }
}); });
// docs/decisions.md records that the driver runs the provisioning DDL in bootstrap and nothing // A single value imported from the wrong module puts the driver in web's graph without changing
// else. A single value imported from the wrong module puts it in web's graph without changing // behaviour, so nothing but this would notice (docs/decisions.md → "Plugin storage hands over
// behaviour, so nothing but this would notice. // credentials, not a client").
test("the Postgres driver reaches bootstrap only, never web's import graph", () => { test("the Postgres driver reaches bootstrap only, never web's import graph", () => {
const files = sourceFiles(); const files = sourceFiles();
assert.ok(files.length > 40, "walks the source tree"); assert.ok(files.length > 40, "walks the source tree");
+2 -1
View File
@@ -20,7 +20,8 @@ import { buildCredentials, storagePluginIds, type StorageCredentials } from "./p
const config = loadConfig(); // validates the env (incl. enforced secrets) — fails loud at boot const config = loadConfig(); // validates the env (incl. enforced secrets) — fails loud at boot
// The storage secret is in `config` now, so drop it from the environment before ANY plugin code // The storage secret is in `config` now, so drop it from the environment before ANY plugin code
// runs: a plugin module's top level evaluates during discovery, long before onBoot. Defence in // runs: a plugin module's top level evaluates during discovery, long before onBoot. Defence in
// depth, not a boundary (docs/decisions.md) — and only ever move this line earlier, never later. // depth, not a boundary (docs/decisions.md → "The trust boundary is the `web` process, not the
// plugin") — and only ever move this line earlier, never later.
delete process.env["PLUGIN_DB_SECRET"]; delete process.env["PLUGIN_DB_SECRET"];
// App-level logger: structured, OTLP-capable when OTLP_ENDPOINT is set. The hot path clones it // App-level logger: structured, OTLP-capable when OTLP_ENDPOINT is set. The hot path clones it
// per request for access logging + a trace span (src/http/request-tracking.ts); console-only otherwise. // per request for access logging + a trace span (src/http/request-tracking.ts); console-only otherwise.
+3 -1
View File
@@ -60,7 +60,9 @@
<% if (user.email) { %> <% if (user.email) { %>
<%# Signed in: the same popover block as the menu partial, hand-rolled because this one's <%# Signed in: the same popover block as the menu partial, hand-rolled because this one's
trigger composes escaped user values and its Sign-out item is a CSRF POST form, neither of trigger composes escaped user values and its Sign-out item is a CSRF POST form, neither of
which the partial's Item shapes cover (docs/decisions.md). Keep the two in step. %> which the partial's Item shapes cover (docs/decisions.md → "A dropdown is a
`<button popovertarget>` and a `[popover]`, never a `<details>`"). Keep the two in
step. %>
<div class="menu" style="flex:1 1 auto"> <div class="menu" style="flex:1 1 auto">
<button class="profile" type="button" popovertarget="profile-menu"> <button class="profile" type="button" popovertarget="profile-menu">
<span class="avatar" aria-hidden="true"><%= user.initials %></span> <span class="avatar" aria-hidden="true"><%= user.initials %></span>