Close the popup menus on an outside click, via the popover API #51

Merged
lilleman merged 4 commits from menu-outside-click into main 2026-08-05 08:31:47 +02:00
12 changed files with 99 additions and 58 deletions
Showing only changes of commit cfeee10fa8 - Show all commits
+21 -5
View File
@@ -171,11 +171,21 @@ them. Revisit only if the stated reason stops holding.
- **A dropdown is a `<button popovertarget>` + `[popover]`, never a `<details>`.** The browser then
owns open/close, which is the only zero-JS way to dismiss a menu by clicking outside it (the whole
point), and the panel sits in the top layer so a row kebab is no longer clipped by `.table-wrap`'s
`overflow`. Two things not to "fix": the panel must carry **`position-anchor: auto`** — a bare
`anchor()` resolves to nothing in Chromium, Firefox *and* WebKit (measured in all three before
choosing) — and no `aria-expanded` is written, because a zero-JS invoker cannot keep one truthful;
the state is the browser's to expose. `<details>` stays where it means disclosure rather than
popup: the nav tree. Decided 2026-08-05.
`overflow`. Four rules hold it together, none of them cosmetic. The panel carries
**`position-anchor: auto`** — a bare `anchor()` resolves to nothing in Chromium, Firefox *and*
WebKit alike, which is why the `@engines`-tagged test in `visual.spec.ts` runs in all three rather
than resting on a one-time manual measurement. The panel stays the trigger's **next sibling inside
the `.menu` wrapper**, because the open-state style and the old-browser fallback both read that
adjacency, and a two-element partial cannot be dropped into an arbitrary layout. The `menu` partial
**requires a caller-named `id`** and fails loud without one: it is the `popovertarget` idref, and
generated random ids were tried and rejected the same day — nondeterministic HTML forecloses the
still-open caching decision and names nothing a reader can use. And **neither `aria-expanded` nor
`aria-haspopup` is written**: a zero-JS invoker cannot keep the first truthful, and the second would
promise `role="menu"` keyboard semantics these panels do not implement. `<details>` 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 one item is a CSRF POST form,
neither of which the partial's `Item` shapes cover — keep the two in step, or fold it in if
`todo.md`'s "does the profile dropdown still earn a dropdown" settles the other way. Decided 2026-08-05.
- **`ICON_NAMES` (`src/ui/icons.ts`) is a host-owned registry, not a frozen plugin contract.** It is
deliberately not re-exported from `#plugin-api`, and README → Nav & permission gates already tells an
author that using a new icon means registering it there. So the palette may narrow when the last
@@ -261,6 +271,12 @@ Same test before adding a row to a table or the file map — a clause, not a par
minor bump). Valid while nothing is installed against it: with no third-party plugin in the wild,
a version bump can only produce noise. The promotion trigger is the first external plugin — from
then on, follow the versioning table in README → Contract versioning as written. Decided 2026-08-03.
**The frozen surface includes `views/partials/*.ejs`**, not just the manifest and the barrel: the
view resolver makes every core partial an `include()` root for a plugin's views, so their option
names and emitted markup are author-visible (under this freeze the popover change dropped the `menu`
partial's `open?` and rewrote its markup). Know the hole that leaves — discovery fails loud on a bad
`apiVersion`, but `include("menu", { open: true })` silently ignores the option and a plugin styling
`.menu > summary` silently loses it. Promotion must cover the partial vocabulary too. Added 2026-08-05.
- A plugin's `apiVersion` is a **hand-written literal** semver — the host version the
plugin was built against — bumped by hand on rebuild, **never** the host's
`HOST_API_VERSION` constant. Importing the constant makes every plugin always equal the
+4 -3
View File
@@ -915,9 +915,10 @@ set of reusable EJS partials + TS helpers, fully styled and zero-JS:
The core and all building blocks **work with zero JavaScript** — theme switching and filtering
are pure CSS + GET forms, and menus are the platform's own [popover
API](https://developer.mozilla.org/en-US/docs/Web/API/Popover_API): a `<button popovertarget>`
opens the panel and the browser dismisses it on a click outside or `Esc`. A browser too old for
that (before mid-2024) shows each panel inline instead, so nothing is stranded behind an inert
button. On the [low-end, low-bandwidth targets](#overview)
opens the panel and the browser dismisses it on a click outside or `Esc`; CSS anchor positioning
places it. On a browser too old for either the trigger is inert, so each panel falls back to
flowing inline underneath it — cramped inside a table cell, but nothing is unreachable. That path
is deliberately untested: no browser that supports popovers can render it. On the [low-end, low-bandwidth targets](#overview)
we care about this is usually *faster*: a round-trip returning a small, pre-rendered HTML
page beats a client-side runtime that must boot, fetch JSON, and re-render before anything
shows. List state (`?q=…&status=…&sort=…&page=…`) lives **in the URL**, so a view is
+7 -1
View File
@@ -15,5 +15,11 @@ export default defineConfig({
screenshot: "only-on-failure",
viewport: { width: 1280, height: 800 },
},
projects: [{ name: "chromium", use: { ...devices["Desktop Chrome"] } }],
// CSS anchor positioning is the newest platform feature in the app and every popup menu rests on
// it, so the tests tagged @engines run in all three engines; the rest stay on chromium.
projects: [
{ name: "chromium", use: { ...devices["Desktop Chrome"] } },
{ name: "firefox", grep: /@engines/, use: { ...devices["Desktop Firefox"] } },
{ name: "webkit", grep: /@engines/, use: { ...devices["Desktop Safari"] } },
],
});
+1 -1
View File
@@ -74,7 +74,7 @@ test("theme switch flips the palette with no JavaScript", async ({ page }) => {
// longer has to click the trigger again to get rid of one. Driven through the language picker; the
// profile menu is the same block. Anchoring is asserted too — without `position-anchor` the panel
// silently detaches and lands in the middle of the viewport.
test("a popover menu sits on its trigger and closes on an outside click or Esc — no JavaScript", async ({ page }) => {
test("a popover menu sits on its trigger and closes on an outside click or Esc — no JavaScript @engines", async ({ page }) => {
await page.goto("/dashboard");
const trigger = page.locator('button[aria-label="Language"]');
const panel = page.locator('button[aria-label="Language"] + .menu-pop');
+12 -7
View File
@@ -476,10 +476,11 @@ span.nav-self { cursor: default; } /* static / non-clickable */
.check:hover, .radio:hover { color: var(--text); }
/* popover menu (language picker, profile, row kebab) — a <button popovertarget> and its [popover]
panel. The browser owns open/close, and the top layer keeps the panel clear of a scrolling
ancestor's overflow. `position-anchor: auto` binds it to the button that opened it; a bare
anchor() without it resolves to nothing in every engine. `color` is set because the UA sheet
gives [popover] a CanvasText of its own, which would ignore the theme. */
panel, wrapped so the pair is one element in any layout. The browser owns open/close, and the top
layer keeps the panel clear of a scrolling ancestor's overflow. `position-anchor: auto` binds it to
the button that opened it; a bare anchor() without it resolves to nothing in every engine. `color`
is set because the UA sheet gives [popover] a CanvasText of its own, which would ignore the theme. */
.menu { display: inline-flex; }
.menu-pop {
position: absolute; inset: auto; margin: 6px 0 0;
position-anchor: auto;
@@ -492,10 +493,14 @@ span.nav-self { cursor: default; } /* static / non-clickable */
}
.menu-pop.left { right: auto; left: anchor(left); }
.menu-pop.up { top: auto; bottom: anchor(top); margin: 0 0 6px; }
/* A browser too old for the Popover API leaves the trigger inert, so show the panel inline instead
of stranding Sign out and the language picker behind a button that does nothing. */
/* A browser too old for the Popover API leaves the trigger inert, so let each panel flow under its
trigger rather than stranding Sign out and the language picker behind a button that does nothing.
The wrapper turns block so the pair stacks instead of sitting side by side, and min-width goes —
it would otherwise push the sidebar and a 44px action cell far past their width. Cramped but
reachable, and unreachable by any test: a browser that supports popover cannot render this path. */
@supports not selector([popover]:popover-open) {
.menu-pop, .menu-pop.up { position: static; margin: 6px 0 0; }
.menu { display: block; }
.menu-pop, .menu-pop.up { position: static; min-width: 0; margin: 6px 0 0; }
}
.menu-head { font-size: var(--fz-xs); text-transform: uppercase;
letter-spacing: .05em; color: var(--text-faint); font-weight: 600;
+3 -3
View File
@@ -63,9 +63,9 @@ test("data-table renders sortable headers, row-select, typed cells, badges and k
assert.match(html, /<td><span class="badge pos"><span class="dot"><\/span>Active<\/span><\/td>/);
assert.match(html, /<td><a href="\/x">open<\/a><\/td>/);
// Kebab row actions: a popover trigger and the panel it opens (\1 — the ids must agree), then
// link item, danger button, separator.
assert.match(html, /<td class="col-actions"><button class="kebab" type="button" popovertarget="(menu-[a-z0-9]+)" aria-label="Row actions for Mara Delgado"><svg class="ico ico-sm"><use href="#i-kebab"\s*\/?><\/svg><\/button><div id="\1" class="menu-pop" popover>/);
// Kebab row actions: a popover trigger and the panel it opens, named per row, then link item,
// danger button, separator.
assert.match(html, /<td class="col-actions"><span class="menu"><button class="kebab" type="button" popovertarget="row-actions-1" aria-label="Row actions for Mara Delgado"><svg class="ico ico-sm"><use href="#i-kebab"\s*\/?><\/svg><\/button><div id="row-actions-1" class="menu-pop" popover>/);
assert.match(html, /<a class="menu-item" href="\/people\/1\/edit"><svg class="ico"><use href="#i-edit"\s*\/?><\/svg>Edit<\/a>/);
assert.match(html, /<div class="menu-sep"><\/div><button class="menu-item danger" type="button"><svg class="ico"><use href="#i-trash"\s*\/?><\/svg>Delete<\/button>/);
});
+8 -13
View File
@@ -11,7 +11,7 @@ const flat = (s: string): string => s.replace(/>\s+</g, "><").replace(/\s+/g, "
test("menu renders trigger, positioning, the item matrix and check groups", async () => {
const html = flat(await render({
id: "cols-menu", // given explicitly here; the default is a fresh one per menu (see below)
id: "cols-menu",
trigger: { icon: "i-cols", text: "Columns", label: "Column settings" },
align: "left", up: true, width: 240,
items: [
@@ -29,8 +29,8 @@ test("menu renders trigger, positioning, the item matrix and check groups", asyn
}));
// Trigger: icon + text + aria-label, wired to the panel by id; popover carries align/up + width.
assert.match(html, /<button class="btn" type="button" popovertarget="cols-menu" aria-label="Column settings"><svg class="ico ico-sm"><use href="#i-cols"\s*\/?><\/svg>Columns<\/button>/);
assert.match(html, /<div id="cols-menu" class="menu-pop left up" popover style="min-width:240px">/);
// The panel is the trigger's next sibling inside the wrapper — the CSS open state reads that.
assert.match(html, /<span class="menu"><button class="btn" type="button" popovertarget="cols-menu" aria-label="Column settings"><svg class="ico ico-sm"><use href="#i-cols"\s*\/?><\/svg>Columns<\/button><div id="cols-menu" class="menu-pop left up" popover style="min-width:240px">/);
// Item matrix: head, button-with-icon, link, separator, danger button.
assert.match(html, /<div class="menu-head">Actions<\/div>/);
@@ -54,17 +54,12 @@ test("menu supports a raw/kebab trigger, escapes labels, and renders empty by de
assert.match(kebab, /<button class="kebab" type="button" popovertarget="row-menu" aria-label="Row actions"><svg class="ico ico-sm"><use href="#i-kebab"\s*\/?><\/svg><\/button>/);
// Labels are escaped (item text + trigger text).
assert.match(flat(await render({ trigger: { text: "<x>" }, items: [{ label: "<y>" }] })), /&lt;x&gt;<\/button>.*&lt;y&gt;/);
assert.match(flat(await render({ id: "esc-menu", trigger: { text: "<x>" }, items: [{ label: "<y>" }] })), /&lt;x&gt;<\/button>.*&lt;y&gt;/);
// No locals → a valid empty menu, never throws.
assert.equal(flat(await render({ id: "m" })), '<button class="btn" type="button" popovertarget="m"></button><div id="m" class="menu-pop" popover></div>');
// Only an id → a valid empty menu, never throws.
assert.equal(flat(await render({ id: "m" })), '<span class="menu"><button class="btn" type="button" popovertarget="m"></button><div id="m" class="menu-pop" popover></div></span>');
});
test("menu mints its own popover id, so two menus on one page never cross-wire", async () => {
const idOf = (html: string): string => html.match(/popovertarget="([^"]+)"/)?.[1] ?? "";
const one = flat(await render());
const two = flat(await render());
assert.match(one, new RegExp(`<div id="${idOf(one)}" class="menu-pop" popover>`)); // trigger and panel agree
assert.notEqual(idOf(one), idOf(two)); // …and the next menu gets its own
test("menu demands an id — a trigger wired to nothing is a dead button, so say so", async () => {
await assert.rejects(render({ items: [{ label: "Edit", href: "/e" }] }), /`id` is required/);
});
+2 -1
View File
@@ -2,6 +2,7 @@
## Unfinnished work
- [ ] Record the browser floor Plainpages actually requires, and whether the fallback is the contract or a courtesy. The stylesheet already needs `:has()` (Dec 2023); the menus now need the popover API (Safari 17, Sep 2023) and CSS anchor positioning for placement (newer still, and unguarded — the `@supports` test covers popover only). An iPadOS 16 tablet — capped at Safari 16, and exactly the "tablet on a factory floor, old thin client at a reception desk" README → Overview sells the zero-JS stance on — therefore gets panels flowing inline rather than working menus. Either state a supported floor in the README or accept the fallback as the answer for those devices; nobody has rendered that path on real hardware. Raised by the architecture review 2026-08-05.
- [ ] Decide whether the profile dropdown still earns a dropdown. With the dead Profile link gone it holds one item, Sign out, behind a click — and its "Signed in as X" head only repeats the name and email the trigger already shows. Either put Sign out in the footer directly, or give the menu a second reason to exist. Overlaps the outside-click item above. Raised by review 2026-08-05.
- [ ] When copy+paste the verification code from the email, it doesn't work because it does not trim whitechars around the code in the form. It should trim automatically.
- [ ] Guard against the double-clicked submit, without client-side JavaScript. The README's non-technical persona double-clicks a button that doesn't respond instantly, so a second identical POST is an expected event, not misuse — today it creates two users, mints two recovery codes, or registers two OAuth2 clients. Constraints: HTML/CSS only (no client JS — priority: zero-JS spine), and it must not break an action that is *legitimately* repeatable (an increase-by-one button is not a duplicate, it is two increments). Sketch to evaluate: a CSS-only affordance so the second click has nothing to hit (`:active`/`:focus` state, or the submit visually and semantically settling), paired with the host recognising a duplicate on the server — same session, same route, same payload, within a short window — and then logging it and dropping the second rather than replaying it. Open questions: what identifies "the same submission" (a one-time token minted into each rendered form is stronger than hashing the payload, and the CSRF plumbing already mints per-request tokens), how long the window is, where the record lives given the app is stateless (in-memory like the revoke denylist, or push it to the upstream the plugin already writes to), and how a plugin declares a route as repeatable — an opt-out on the route, or opt-in per form. Raised 2026-08-04 with the personas.
@@ -24,7 +25,7 @@ Prioritized. Overall verdict: architecture is sound (contract-first plugin API,
## Finnished work
- [x] The little menues, like when choosing language or clicking my username, they do not dissapear when clicking outside them, I must click the original trigger or choose something. See if there are more modern ways of handling this with HTML and CSS. I think there is a modal-thing or something? (The modern thing is the **Popover API**. All three popup menus — language picker, profile, row kebab — are now a `<button popovertarget>` plus a `[popover]` panel instead of `<details>`/`<summary>`, so the browser owns open/close: clicking anywhere outside dismisses one, `Esc` dismisses it and returns focus to the trigger, opening one closes the others, and the panel sits in the top layer where `.table-wrap`'s `overflow` can no longer clip a row kebab. Placement is CSS anchor positioning; the panel needs `position-anchor: auto` to bind to the button that opened it — a bare `anchor()` resolves to nothing in Chromium, Firefox and WebKit alike, measured in all three before picking the approach. `data-table.ejs` stopped hand-rolling its kebab and calls the `menu` partial, so the pattern lives in one file; each menu mints its own popover id, since `popovertarget` is an idref and two menus share a page. `<details>` stays in the nav tree, where it means disclosure rather than popup. A browser older than the Popover API (before mid-2024) renders each panel inline, so Sign out is never stranded behind an inert button. `e2e-tests/visual.spec.ts` drives the whole behaviour — opens, anchored to its trigger, outside-click, Esc — and the decision is recorded in AGENTS.md.)
- [x] The little menues, like when choosing language or clicking my username, they do not dissapear when clicking outside them, I must click the original trigger or choose something. See if there are more modern ways of handling this with HTML and CSS. I think there is a modal-thing or something? (The modern thing is the **Popover API**. All three popup menus — language picker, profile, row kebab — are now a `<button popovertarget>` plus a `[popover]` panel instead of `<details>`/`<summary>`, so the browser owns open/close: clicking anywhere outside dismisses one, `Esc` dismisses it and returns focus to the trigger, opening one closes the others, and the panel sits in the top layer where `.table-wrap`'s `overflow` can no longer clip a row kebab. Placement is CSS anchor positioning; the panel needs `position-anchor: auto` to bind to the button that opened it — a bare `anchor()` resolves to nothing in Chromium, Firefox and WebKit alike, measured in all three before picking the approach. `data-table.ejs` stopped hand-rolling its kebab and calls the `menu` partial, so the pattern lives in one file. Each panel is named by its caller (`locale-menu`, `profile-menu`, `row-actions-1`) and the partial fails loud without an `id`, since `popovertarget` is an idref — generated ids were tried first and dropped for being unreadable and nondeterministic. `<details>` stays in the nav tree, where it means disclosure rather than popup. A browser older than the popover API flows each panel inline under its trigger, so Sign out is never stranded behind an inert button. `e2e-tests/visual.spec.ts` drives the whole behaviour — opens, anchored to its trigger, outside-click, Esc — and is tagged `@engines` so it runs in Firefox and WebKit as well as Chromium, because CSS anchor positioning is the newest thing in the app and every popup rests on it. Decisions recorded in AGENTS.md.)
- [x] Organize the files in src in to folders so it is easier to understand the structure of the code.
- [x] Move docs/plugin-contract.md into README.md and remove the docs folder.
- [x] The plugins/scheduling is an example and shouldn't be committed to the plugins directory since that should be empty to be able to be mounted in via docker or other means for the users/develoeprs using this application/framework. Put it in the examples folder instead.
+10 -2
View File
@@ -3,6 +3,8 @@
Zero-JS (sort = links, select highlight = CSS).
Config:
caption?, selectable?, actions? sr-only caption; toggle the check / kebab columns
actionsId? id stem for the row-action menus (default `row-actions`);
name it when two tables share a page
columns: { label, sortable?, sort?: "asc"|"desc", href?, className? }[]
rows: { name?, cells: Cell[], actions?: Action[] }[]
Cell ∈ string | { text, className? } | { user:{name,initials} } | { rowHeader:{text,href?} } | { badge:{tone,label} } | { html, className? }
@@ -17,6 +19,7 @@
const columns = locals.columns || [];
const rows = locals.rows || [];
const emptyText = locals.emptyText || t("table.empty"); // shown when a table that has columns has no rows
const actionsId = locals.actionsId || "row-actions";
-%>
<div class="table-wrap">
<table class="table">
@@ -44,7 +47,7 @@
<% if (rows.length === 0 && columns.length) { -%>
<tr><td class="table-empty" colspan="<%= columns.length + (selectable ? 1 : 0) + (withActions ? 1 : 0) %>"><%= emptyText %></td></tr>
<% } -%>
<% rows.forEach((row) => { -%>
<% rows.forEach((row, i) => { -%>
<tr>
<% if (selectable) { -%>
<td class="col-check"><input type="checkbox" class="row-select" aria-label="<%= t("table.select", { name: row.name || t("table.row") }) %>"></td>
@@ -67,9 +70,14 @@
<% if (withActions) { -%>
<% if ((row.actions || []).length) { -%>
<td class="col-actions"><%- include("menu", {
id: `${actionsId}-${i + 1}`,
kebab: true,
trigger: { class: "", icon: "i-kebab", label: t("table.rowActions", { name: row.name || t("table.row") }) },
items: row.actions.flatMap((a) => (a.separatorBefore ? [{ sep: true }, a] : [a])),
// Mapped field by field, not spread: an Action and a menu Item are separate shapes.
items: row.actions.flatMap((a) => {
const item = { danger: a.danger, href: a.href, icon: a.icon, label: a.label };
return a.separatorBefore ? [{ sep: true }, item] : [item];
}),
}) %></td>
<% } else { -%>
<td class="col-actions"></td>
+2
View File
@@ -3,10 +3,12 @@
so switching is a plain navigation — zero-JS, and the address bar always says which language the
page is in. Renders nothing for a single-language deployment.
Locals: localeSwitch (host-supplied: { current, href, label, tag }[]) · up? (open upward, default true)
id? (the menu's id; name a second picker on the same page)
-%>
<% const choices = locals.localeSwitch || []; -%>
<% if (choices.length > 1) { -%>
<%- include("menu", {
id: locals.id || "locale-menu",
up: locals.up !== false,
trigger: { class: "btn icon-btn", icon: "i-globe", label: t("locale.label") },
items: [
+9 -6
View File
@@ -2,28 +2,31 @@
Popover menu: a <button popovertarget> and the [popover] panel it opens, zero-JS. The browser owns
open/close, so clicking outside or pressing Esc dismisses it, opening one closes the others, and the
panel sits in the top layer instead of being clipped by a scrolling ancestor.
The panel must stay the trigger's next sibling inside the .menu wrapper: the open-state style
(.kebab:has(+ .menu-pop:popover-open)) and the old-browser fallback both read that adjacency.
Config:
id string REQUIRED — the panel's id and the trigger's popovertarget. Name it for what
the menu is (`locale-menu`); it must be unique on the page.
trigger { class?(="btn", "" ⇒ none) · label?(aria-label) · icon? · text? · html?(raw inner, wins) }
align? "left" left-align the popover (default right)
up? boolean open upward (footer menus)
kebab? boolean bare kebab trigger (adds .kebab)
width? number|string popover min-width (number ⇒ px)
id? string popover id; defaults to a fresh one — pass it only to address this menu
items: Item[] popover content, top→bottom
Item ∈ { head } · { sep } · { label, icon?, href? ⇒ <a>, hreflang?, ownLocale?, current?, danger? } (default: menu-item button)
ownLocale: the href already states its language (the picker) — don't carry the current one onto it
· { group: { legend?, name, control?(="checkbox"|"radio"), options:{value,label,checked?}[] } }
%><%
// popovertarget is an idref: without one the trigger opens nothing, so say so instead of rendering
// a dead button.
if (!locals.id) throw new Error("menu partial: `id` is required — it wires the trigger to its panel");
const trigger = locals.trigger || {}; // not `t` — that name is the translator in every view
const btnCls = [("class" in trigger ? trigger.class : "btn"), locals.kebab ? "kebab" : ""].filter(Boolean).join(" ");
const items = locals.items || [];
const popCls = "menu-pop" + (locals.align === "left" ? " left" : "") + (locals.up ? " up" : "");
const width = locals.width;
// popovertarget is an idref, so two menus on one page must not share an id.
const id = locals.id || "menu-" + Math.random().toString(36).slice(2, 10);
-%>
<button<% if (btnCls) { %> class="<%= btnCls %>"<% } %> type="button" popovertarget="<%= id %>"<% if (trigger.label) { %> aria-label="<%= trigger.label %>"<% } %>><% if (trigger.html != null) { %><%- trigger.html %><% } else { if (trigger.icon) { %><svg class="ico ico-sm"><use href="#<%= trigger.icon %>"/></svg><% } if (trigger.text) { %><%= trigger.text %><% } } %></button>
<div id="<%= id %>" class="<%= popCls %>" popover<% if (width != null) { %> style="min-width:<%= typeof width === "number" ? width + "px" : width %>"<% } %>>
<span class="menu"><button<% if (btnCls) { %> class="<%= btnCls %>"<% } %> type="button" popovertarget="<%= locals.id %>"<% if (trigger.label) { %> aria-label="<%= trigger.label %>"<% } %>><% if (trigger.html != null) { %><%- trigger.html %><% } else { if (trigger.icon) { %><svg class="ico ico-sm"><use href="#<%= trigger.icon %>"/></svg><% } if (trigger.text) { %><%= trigger.text %><% } } %></button><div id="<%= locals.id %>" class="<%= popCls %>" popover<% if (width != null) { %> style="min-width:<%= typeof width === "number" ? width + "px" : width %>"<% } %>>
<% items.forEach((it) => { -%>
<% if (it.head != null) { -%>
<div class="menu-head"><%= it.head %></div>
@@ -37,4 +40,4 @@
<button class="menu-item<%= it.danger ? " danger" : "" %>" type="button"><% if (it.icon) { %><svg class="ico"><use href="#<%= it.icon %>"/></svg><% } %><%= it.label %></button>
<% } -%>
<% }) -%>
</div>
</div></span>
+20 -16
View File
@@ -57,22 +57,26 @@
<div class="footer-actions">
<% if (user.email) { %>
<%# signed in: profile menu inline (the trigger composes escaped user values) %>
<button class="profile" type="button" popovertarget="profile-menu">
<span class="avatar" aria-hidden="true"><%= user.initials %></span>
<span class="profile-meta">
<span class="profile-name"><%= user.name %></span>
<span class="profile-mail"><%= user.email %></span>
</span>
</button>
<div id="profile-menu" class="menu-pop left up" popover style="min-width:220px">
<div class="menu-head"><%= t("shell.signedInAs", { name: user.name }) %></div>
<%# Sign out is a state change → a POST form (not a GET link), CSRF-guarded by app.ts %>
<form class="menu-item-form" method="post" action="<%= localeHref("/logout") %>">
<input type="hidden" name="_csrf" value="<%= locals.csrfToken || '' %>" />
<button class="menu-item danger" type="submit"><svg class="ico"><use href="#i-logout" /></svg><%= t("shell.signOut") %></button>
</form>
</div>
<%# Signed in: the same popover block as the menu partial, hand-rolled because this one's
trigger composes escaped user values and its item is a CSRF POST form, neither of which
the partial's Item shapes cover (AGENTS.md). Keep the two in step. %>
<span class="menu" style="flex:1 1 auto">
<button class="profile" type="button" popovertarget="profile-menu">
<span class="avatar" aria-hidden="true"><%= user.initials %></span>
<span class="profile-meta">
<span class="profile-name"><%= user.name %></span>
<span class="profile-mail"><%= user.email %></span>
</span>
</button>
<div id="profile-menu" class="menu-pop left up" popover style="min-width:220px">
<div class="menu-head"><%= t("shell.signedInAs", { name: user.name }) %></div>
<%# Sign out is a state change → a POST form (not a GET link), CSRF-guarded by app.ts %>
<form class="menu-item-form" method="post" action="<%= localeHref("/logout") %>">
<input type="hidden" name="_csrf" value="<%= locals.csrfToken || '' %>" />
<button class="menu-item danger" type="submit"><svg class="ico"><use href="#i-logout" /></svg><%= t("shell.signOut") %></button>
</form>
</div>
</span>
<% } else if (!hideSignIn) { %>
<%# anonymous (a public page in the shell): no session to end — offer a way in instead.
signInHref carries this page as return_to (chrome.signInHref); falls back to bare /login.