Update actions/checkout action to v7 #53

Merged
renovate-bot merged 3 commits from renovate/actions-checkout-7.x into main 2026-08-05 08:36:39 +02:00
15 changed files with 185 additions and 61 deletions
Showing only changes of commit a8217a4ff8 - Show all commits
+24
View File
@@ -168,6 +168,24 @@ them. Revisit only if the stated reason stops holding.
workspace dir, so ci.sh's web-image build races another run's container creation on the workspace dir, so ci.sh's web-image build races another run's container creation on the
`<project>-web` tag. Accepted for a single-maintainer cadence; serialize with a workflow `<project>-web` tag. Accepted for a single-maintainer cadence; serialize with a workflow
`concurrency` group if it ever bites. `concurrency` group if it ever bites.
- **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`. 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 - **`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 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 author that using a new icon means registering it there. So the palette may narrow when the last
@@ -253,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, 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 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. 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 - 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 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 `HOST_API_VERSION` constant. Importing the constant makes every plugin always equal the
+7 -2
View File
@@ -912,8 +912,13 @@ set of reusable EJS partials + TS helpers, fully styled and zero-JS:
## Interactivity: zero-JS spine ## Interactivity: zero-JS spine
The core and all building blocks **work with zero JavaScript** — menus, theme switching, The core and all building blocks **work with zero JavaScript** — theme switching and filtering
and filtering are pure CSS + GET forms. On the [low-end, low-bandwidth targets](#overview) 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`; CSS anchor positioning
places it. On a browser too old for the popover API 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 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 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 shows. List state (`?q=…&status=…&sort=…&page=…`) lives **in the URL**, so a view is
+9 -4
View File
@@ -88,12 +88,12 @@ test.describe.serial("authenticated admin journey", () => {
const row = page.locator("tr", { hasText: `lang-${suffix}@plainpages.local` }); const row = page.locator("tr", { hasText: `lang-${suffix}@plainpages.local` });
const editHref = await row.locator('a[href^="/admin/users/"]').first().getAttribute("href"); const editHref = await row.locator('a[href^="/admin/users/"]').first().getAttribute("href");
await page.goto(`${editHref}`); await page.goto(`${editHref}`);
await expect(page.locator('summary[aria-label="Språk"]')).toHaveCount(1); await expect(page.locator('button[aria-label="Språk"]')).toHaveCount(1);
await page.getByRole("button", { name: "Skapa återställningskod" }).click(); // POST-only route await page.getByRole("button", { name: "Skapa återställningskod" }).click(); // POST-only route
await expect(page.getByText("Återställningskod skapad")).toBeVisible(); await expect(page.getByText("Återställningskod skapad")).toBeVisible();
// The picker is here too, and following it lands on a real page in the other language. // The picker is here too, and following it lands on a real page in the other language.
await page.locator('summary[aria-label="Språk"]').click(); await page.locator('button[aria-label="Språk"]').click();
await page.getByRole("link", { name: /English/i }).click(); await page.getByRole("link", { name: /English/i }).click();
expect(page.url()).toContain("locale=en-US"); expect(page.url()).toContain("locale=en-US");
await expect(page.locator("html")).toHaveAttribute("lang", "en-US"); await expect(page.locator("html")).toHaveAttribute("lang", "en-US");
@@ -120,6 +120,11 @@ test.describe.serial("authenticated admin journey", () => {
const row = page.locator("tr", { hasText: email }); const row = page.locator("tr", { hasText: email });
await expect(row).toBeVisible(); await expect(row).toBeVisible();
// Row actions sit behind the kebab popover: opening it reveals them, in the top layer, so the
// scrolling table around the row cannot clip the panel.
await row.locator("button.kebab").click();
await expect(row.locator('a[href^="/admin/users/"]').first()).toBeVisible();
// Delete through the confirm interstitial (the row's Edit link carries the id). // Delete through the confirm interstitial (the row's Edit link carries the id).
const editHref = await row.locator('a[href^="/admin/users/"]').first().getAttribute("href"); const editHref = await row.locator('a[href^="/admin/users/"]').first().getAttribute("href");
await page.goto(`${editHref}/delete`); await page.goto(`${editHref}/delete`);
@@ -186,9 +191,9 @@ 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("summary.profile").click(); // open the profile dropdown await page.locator("button.profile").click(); // open the profile dropdown
// Sign out is the only item in it — the menu offers nothing that goes nowhere. // Sign out is the only item in it — the menu offers nothing that goes nowhere.
await expect(page.locator("details.menu:has(summary.profile) .menu-item")).toHaveText(["Sign out"]); await expect(page.locator("#profile-menu .menu-item")).toHaveText(["Sign out"]);
await page.locator('form[action="/logout"] button[type="submit"]').click(); await page.locator('form[action="/logout"] button[type="submit"]').click();
await page.waitForURL(/\/login(\?|$)/); await page.waitForURL(/\/login(\?|$)/);
// The session is gone: /dashboard is gated, so it bounces back to the login page (no admin nav). // The session is gone: /dashboard is gated, so it bounces back to the login page (no admin nav).
+2 -2
View File
@@ -31,7 +31,7 @@ test("the switcher changes language, and the choice survives clicking through th
// The picker sits in the sidebar footer beside the theme switch; each entry is a plain link to // The picker sits in the sidebar footer beside the theme switch; each entry is a plain link to
// this same page in that language (zero-JS). // this same page in that language (zero-JS).
await page.locator('summary[aria-label="Language"]').click(); await page.locator('button[aria-label="Language"]').click();
await page.getByRole("link", { name: /svenska/i }).click(); await page.getByRole("link", { name: /svenska/i }).click();
await expect(page.locator("html")).toHaveAttribute("lang", "sv-SE"); await expect(page.locator("html")).toHaveAttribute("lang", "sv-SE");
@@ -58,7 +58,7 @@ test("the switcher changes language, and the choice survives clicking through th
await expect(page.locator("html")).toHaveAttribute("lang", "sv-SE"); await expect(page.locator("html")).toHaveAttribute("lang", "sv-SE");
// …and back to English the same way. // …and back to English the same way.
await page.locator('summary[aria-label="Språk"]').click(); await page.locator('button[aria-label="Språk"]').click();
await page.getByRole("link", { name: /English/i }).click(); await page.getByRole("link", { name: /English/i }).click();
await expect(page.locator("html")).toHaveAttribute("lang", "en-US"); await expect(page.locator("html")).toHaveAttribute("lang", "en-US");
await expect(page.getByRole("heading", { name: "Shifts" })).toBeVisible(); await expect(page.getByRole("heading", { name: "Shifts" })).toBeVisible();
+7 -1
View File
@@ -15,5 +15,11 @@ export default defineConfig({
screenshot: "only-on-failure", screenshot: "only-on-failure",
viewport: { width: 1280, height: 800 }, 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"] } },
],
}); });
+29
View File
@@ -70,6 +70,35 @@ test("theme switch flips the palette with no JavaScript", async ({ page }) => {
expect(dark).not.toBe(light); expect(dark).not.toBe(light);
}); });
// The menus are <button popovertarget> + [popover], so the browser dismisses them: the visitor no
// 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 @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');
await expect(panel).toBeHidden();
await trigger.click();
await expect(panel).toBeVisible();
// Anchored to the button that opened it: directly above (.up), right edges flush.
const t = (await trigger.boundingBox())!;
const p = (await panel.boundingBox())!;
expect(Math.abs(p.x + p.width - (t.x + t.width))).toBeLessThan(2);
expect(t.y - (p.y + p.height)).toBeGreaterThan(-1); // above the trigger, subpixel-tolerant
expect(t.y - (p.y + p.height)).toBeLessThan(12);
await page.getByRole("heading", { name: "Starter dashboard" }).click(); // anywhere else on the page
await expect(panel).toBeHidden();
await trigger.click();
await expect(panel).toBeVisible();
await page.keyboard.press("Escape");
await expect(panel).toBeHidden();
});
test("mobile layout hides the sidebar off-canvas behind the hamburger", async ({ page }) => { test("mobile layout hides the sidebar off-canvas behind the hamburger", async ({ page }) => {
await page.setViewportSize({ width: 390, height: 844 }); await page.setViewportSize({ width: 390, height: 844 });
await page.goto("/dashboard"); await page.goto("/dashboard");
+33 -14
View File
@@ -126,7 +126,7 @@ summary { list-style: none; cursor: pointer; }
border-radius: 3px; border-radius: 3px;
} }
@media (prefers-reduced-motion: no-preference) { @media (prefers-reduced-motion: no-preference) {
.sidebar, .scrim, summary, .nav-item, .btn, .chip { transition: .15s ease; } .sidebar, .scrim, summary, .nav-item, .btn, .chip, .profile, .kebab { transition: .15s ease; }
} }
.sr-only { .sr-only {
@@ -475,19 +475,35 @@ span.nav-self { cursor: default; } /* static / non-clickable */
margin: 0; cursor: pointer; } margin: 0; cursor: pointer; }
.check:hover, .radio:hover { color: var(--text); } .check:hover, .radio:hover { color: var(--text); }
/* popover menu (column settings, kebab, etc.) — pure <details> */ /* popover menu (language picker, profile, row kebab) — a <button popovertarget> and its [popover]
.menu { position: relative; display: inline-flex; } panel, wrapped so the pair is one element in any layout. The browser owns open/close, and the top
.menu > summary { display: inline-flex; } layer keeps the panel clear of a scrolling ancestor's overflow. `position-anchor: auto` binds it to
.menu > summary::after { content: none; } 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 { .menu-pop {
position: absolute; top: calc(100% + 6px); right: 0; z-index: 40; position: absolute; inset: auto; margin: 6px 0 0;
position-anchor: auto;
position-try-fallbacks: flip-block, flip-inline;
top: anchor(bottom); right: anchor(right);
min-width: 210px; padding: 6px; min-width: 210px; padding: 6px;
background: var(--surface); border: 1px solid var(--border-2); background: var(--surface); color: var(--text);
border-radius: var(--radius); border: 1px solid var(--border-2); border-radius: var(--radius);
box-shadow: 0 8px 28px rgba(0,0,0,.16); box-shadow: 0 8px 28px rgba(0,0,0,.16);
} }
.menu-pop.left { right: auto; left: 0; } .menu-pop.left { right: auto; left: anchor(left); }
.menu-pop.up { top: auto; bottom: calc(100% + 6px); } .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 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 with both features cannot render this path.
Both are guarded — an engine with popover but no anchor positioning would otherwise leave the
panel absolutely positioned with every anchor() dropped, detached from its trigger. */
@supports not (selector([popover]:popover-open) and (position-anchor: auto)) {
.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; .menu-head { font-size: var(--fz-xs); text-transform: uppercase;
letter-spacing: .05em; color: var(--text-faint); font-weight: 600; letter-spacing: .05em; color: var(--text-faint); font-weight: 600;
padding: 5px 8px; } padding: 5px 8px; }
@@ -610,10 +626,13 @@ th[aria-sort="descending"] .sort-ico { transform: rotate(180deg); }
/* row kebab */ /* row kebab */
.col-actions { width: 44px; text-align: center; } .col-actions { width: 44px; text-align: center; }
.kebab summary { width: 26px; height: 26px; border-radius: var(--radius); .kebab { width: 26px; height: 26px; border-radius: var(--radius);
display: grid; place-items: center; color: var(--text-faint); margin: 0 auto; } display: grid; place-items: center; color: var(--text-faint); margin: 0 auto;
.kebab summary:hover { background: var(--surface-2); color: var(--text); } background: transparent; border: 0; padding: 0; cursor: pointer; }
.kebab[open] summary { background: var(--surface-2); color: var(--text); } .kebab:hover { background: var(--surface-2); color: var(--text); }
/* Held open — a trigger's panel is its next sibling. Its own rule: :has() is non-forgiving, so an
engine that doesn't know :popover-open would drop the hover style along with it. */
.kebab:has(+ .menu-pop:popover-open) { background: var(--surface-2); color: var(--text); }
/* ---------- 10. PAGINATION --------------------------------- */ /* ---------- 10. PAGINATION --------------------------------- */
.pager { .pager {
+5 -2
View File
@@ -63,9 +63,12 @@ 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><span class="badge pos"><span class="dot"><\/span>Active<\/span><\/td>/);
assert.match(html, /<td><a href="\/x">open<\/a><\/td>/); assert.match(html, /<td><a href="\/x">open<\/a><\/td>/);
// Kebab row actions: link item, danger button, separator. // Kebab row actions: a popover trigger and the panel it opens, named per row, then link item,
assert.match(html, /<td class="col-actions"><details class="menu kebab"><summary aria-label="Row actions for Mara Delgado"><svg class="ico ico-sm"><use href="#i-kebab"\s*\/?><\/svg><\/summary><div class="menu-pop">/); // danger button, separator.
assert.match(html, /<td class="col-actions"><div 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, /<a class="menu-item" href="\/people\/1\/edit"><svg class="ico"><use href="#i-edit"\s*\/?><\/svg>Edit<\/a>/);
// actionsId renames the stem, so two tables can share a page without colliding.
assert.match(flat(await render({ ...config, actionsId: "people-actions" })), /popovertarget="people-actions-1"/);
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>/); 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>/);
}); });
+14 -9
View File
@@ -11,6 +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 () => { test("menu renders trigger, positioning, the item matrix and check groups", async () => {
const html = flat(await render({ const html = flat(await render({
id: "cols-menu",
trigger: { icon: "i-cols", text: "Columns", label: "Column settings" }, trigger: { icon: "i-cols", text: "Columns", label: "Column settings" },
align: "left", up: true, width: 240, align: "left", up: true, width: 240,
items: [ items: [
@@ -27,9 +28,9 @@ test("menu renders trigger, positioning, the item matrix and check groups", asyn
], ],
})); }));
// Trigger: icon + text + aria-label; popover carries align/up classes + width. // Trigger: icon + text + aria-label, wired to the panel by id; popover carries align/up + width.
assert.match(html, /<details class="menu"><summary class="btn" aria-label="Column settings"><svg class="ico ico-sm"><use href="#i-cols"\s*\/?><\/svg>Columns<\/summary>/); // The panel is the trigger's next sibling inside the wrapper — the CSS open state reads that.
assert.match(html, /<div class="menu-pop left up" style="min-width:240px">/); assert.match(html, /<div 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. // Item matrix: head, button-with-icon, link, separator, danger button.
assert.match(html, /<div class="menu-head">Actions<\/div>/); assert.match(html, /<div class="menu-head">Actions<\/div>/);
@@ -44,17 +45,21 @@ test("menu renders trigger, positioning, the item matrix and check groups", asyn
}); });
test("menu supports a raw/kebab trigger, escapes labels, and renders empty by default", async () => { test("menu supports a raw/kebab trigger, escapes labels, and renders empty by default", async () => {
// Raw trigger HTML, no summary class, kebab + open flags. // Raw trigger HTML, no button class, kebab flag.
const kebab = flat(await render({ const kebab = flat(await render({
kebab: true, open: true, id: "row-menu", kebab: true,
trigger: { class: "", label: "Row actions", html: '<svg class="ico ico-sm"><use href="#i-kebab"/></svg>' }, trigger: { class: "", label: "Row actions", html: '<svg class="ico ico-sm"><use href="#i-kebab"/></svg>' },
items: [{ label: "Edit", href: "/e" }], items: [{ label: "Edit", href: "/e" }],
})); }));
assert.match(kebab, /<details class="menu kebab" open><summary aria-label="Row actions"><svg class="ico ico-sm"><use href="#i-kebab"\s*\/?><\/svg><\/summary>/); 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). // Labels are escaped (item text + trigger text).
assert.match(flat(await render({ trigger: { text: "<x>" }, items: [{ label: "<y>" }] })), /<summary class="btn">&lt;x&gt;<\/summary>.*&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. // Only an id → a valid empty menu, never throws.
assert.equal(flat(await render()), '<details class="menu"><summary class="btn"></summary><div class="menu-pop"></div></details>'); assert.equal(flat(await render({ id: "m" })), '<div class="menu"><button class="btn" type="button" popovertarget="m"></button><div id="m" class="menu-pop" popover></div></div>');
});
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/);
}); });
+7 -2
View File
@@ -39,8 +39,13 @@ test("app shell renders sidebar, topbar and the content slot", async () => {
// Sign out is a CSRF-guarded POST form (state change, not a GET link), carrying the token. // Sign out is a CSRF-guarded POST form (state change, not a GET link), carrying the token.
assert.match(html, /<form class="menu-item-form" method="post" action="\/logout">/); assert.match(html, /<form class="menu-item-form" method="post" action="\/logout">/);
assert.match(html, /<input type="hidden" name="_csrf" value="tok\.sig" \/>/); assert.match(html, /<input type="hidden" name="_csrf" value="tok\.sig" \/>/);
// …and it is the profile menu's only control: nothing dead sits beside it. // The profile trigger opens that panel — the browser dismisses it on a click outside or Esc.
const profileMenu = html.slice(html.indexOf('<div class="menu-pop'), html.indexOf("</details>")); assert.match(html, /<button class="profile" type="button" popovertarget="profile-menu">/);
assert.match(html, /<div id="profile-menu" class="menu-pop left up" popover/);
// …and Sign out is the panel's only control: nothing dead sits beside it. The window runs to the
// sidebar's end so a control added *after* the form is caught too (this render has no picker —
// ENGLISH_LOCALS ships one locale, so locale-switch emits nothing).
const profileMenu = html.slice(html.indexOf('id="profile-menu"'), html.indexOf("</aside>"));
assert.deepEqual(profileMenu.match(/<button/g), ["<button"]); assert.deepEqual(profileMenu.match(/<button/g), ["<button"]);
// Branding, document title, and the inlined icon sprite (so <use> resolves). // Branding, document title, and the inlined icon sprite (so <use> resolves).
+2 -1
View File
@@ -2,7 +2,7 @@
## Unfinnished work ## Unfinnished work
- [ ] 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? - [ ] 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. - [ ] 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. - [ ] 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. - [ ] 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.
@@ -25,6 +25,7 @@ Prioritized. Overall verdict: architecture is sound (contract-first plugin API,
## Finnished work ## 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 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] 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] 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. - [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.
+15 -4
View File
@@ -3,6 +3,8 @@
Zero-JS (sort = links, select highlight = CSS). Zero-JS (sort = links, select highlight = CSS).
Config: Config:
caption?, selectable?, actions? sr-only caption; toggle the check / kebab columns 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? }[] columns: { label, sortable?, sort?: "asc"|"desc", href?, className? }[]
rows: { name?, cells: Cell[], actions?: Action[] }[] rows: { name?, cells: Cell[], actions?: Action[] }[]
Cell ∈ string | { text, className? } | { user:{name,initials} } | { rowHeader:{text,href?} } | { badge:{tone,label} } | { html, className? } Cell ∈ string | { text, className? } | { user:{name,initials} } | { rowHeader:{text,href?} } | { badge:{tone,label} } | { html, className? }
@@ -17,6 +19,7 @@
const columns = locals.columns || []; const columns = locals.columns || [];
const rows = locals.rows || []; const rows = locals.rows || [];
const emptyText = locals.emptyText || t("table.empty"); // shown when a table that has columns has no 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"> <div class="table-wrap">
<table class="table"> <table class="table">
@@ -44,7 +47,7 @@
<% if (rows.length === 0 && columns.length) { -%> <% if (rows.length === 0 && columns.length) { -%>
<tr><td class="table-empty" colspan="<%= columns.length + (selectable ? 1 : 0) + (withActions ? 1 : 0) %>"><%= emptyText %></td></tr> <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> <tr>
<% if (selectable) { -%> <% if (selectable) { -%>
<td class="col-check"><input type="checkbox" class="row-select" aria-label="<%= t("table.select", { name: row.name || t("table.row") }) %>"></td> <td class="col-check"><input type="checkbox" class="row-select" aria-label="<%= t("table.select", { name: row.name || t("table.row") }) %>"></td>
@@ -66,9 +69,17 @@
<% }) -%> <% }) -%>
<% if (withActions) { -%> <% if (withActions) { -%>
<% if ((row.actions || []).length) { -%> <% if ((row.actions || []).length) { -%>
<td class="col-actions"><details class="menu kebab"><summary aria-label="<%= t("table.rowActions", { name: row.name || t("table.row") }) %>"><svg class="ico ico-sm"><use href="#i-kebab"/></svg></summary><div class="menu-pop"><% row.actions.forEach((a) => { -%> <td class="col-actions"><%- include("menu", {
<% if (a.separatorBefore) { %><div class="menu-sep"></div><% } %><% if (a.href) { %><a class="menu-item<% if (a.danger) { %> danger<% } %>" href="<%= localeHref(a.href) %>"><% if (a.icon) { %><svg class="ico"><use href="#<%= a.icon %>"/></svg><% } %><%= a.label %></a><% } else { %><button class="menu-item<% if (a.danger) { %> danger<% } %>" type="button"><% if (a.icon) { %><svg class="ico"><use href="#<%= a.icon %>"/></svg><% } %><%= a.label %></button><% } %><% }) -%> id: `${actionsId}-${i + 1}`,
</div></details></td> align: null, up: false, width: null, // explicit: EJS would otherwise inherit the page's own
kebab: true,
trigger: { class: "", icon: "i-kebab", label: t("table.rowActions", { name: row.name || t("table.row") }) },
// 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 { -%> <% } else { -%>
<td class="col-actions"></td> <td class="col-actions"></td>
<% } -%> <% } -%>
+4
View File
@@ -3,10 +3,14 @@
so switching is a plain navigation — zero-JS, and the address bar always says which language the 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. page is in. Renders nothing for a single-language deployment.
Locals: localeSwitch (host-supplied: { current, href, label, tag }[]) · up? (open upward, default true) Locals: localeSwitch (host-supplied: { current, href, label, tag }[]) · up? (open upward, default true)
localeMenuId? (the menu's id; name a second picker on the same page. Not `id` — EJS
merges page locals into every include, and `id` is the likeliest key in a plugin's data)
-%> -%>
<% const choices = locals.localeSwitch || []; -%> <% const choices = locals.localeSwitch || []; -%>
<% if (choices.length > 1) { -%> <% if (choices.length > 1) { -%>
<%- include("menu", { <%- include("menu", {
id: locals.localeMenuId || "locale-menu",
align: null, kebab: false, width: null, // explicit: EJS would otherwise inherit the page's own
up: locals.up !== false, up: locals.up !== false,
trigger: { class: "btn icon-btn", icon: "i-globe", label: t("locale.label") }, trigger: { class: "btn icon-btn", icon: "i-globe", label: t("locale.label") },
items: [ items: [
+18 -13
View File
@@ -1,10 +1,15 @@
<%# <%#
Popover menu: pure <details>/<summary>, zero-JS. 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: 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) } trigger { class?(="btn", "" ⇒ none) · label?(aria-label) · icon? · text? · html?(raw inner, wins) }
align? "left" left-align the popover (default right) align? "left" left-align the popover (default right)
up? boolean open upward (footer menus) up? boolean open upward (footer menus)
open? boolean start open
kebab? boolean bare kebab trigger (adds .kebab) kebab? boolean bare kebab trigger (adds .kebab)
width? number|string popover min-width (number ⇒ px) width? number|string popover min-width (number ⇒ px)
items: Item[] popover content, top→bottom items: Item[] popover content, top→bottom
@@ -12,27 +17,27 @@
ownLocale: the href already states its language (the picker) — don't carry the current one onto it 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?}[] } } · { 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 trigger = locals.trigger || {}; // not `t` — that name is the translator in every view
const sumCls = "class" in trigger ? trigger.class : "btn"; const btnCls = [("class" in trigger ? trigger.class : "btn"), locals.kebab ? "kebab" : ""].filter(Boolean).join(" ");
const items = locals.items || []; const items = locals.items || [];
const popCls = "menu-pop" + (locals.align === "left" ? " left" : "") + (locals.up ? " up" : ""); const popCls = "menu-pop" + (locals.align === "left" ? " left" : "") + (locals.up ? " up" : "");
const width = locals.width; const width = locals.width;
-%> -%>
<details class="menu<%= locals.kebab ? " kebab" : "" %>"<%= locals.open ? " open" : "" %>> <div 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 %>"<% } %>>
<summary<% if (sumCls) { %> class="<%= sumCls %>"<% } %><% 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 %><% } } %></summary>
<div class="<%= popCls %>"<% if (width != null) { %> style="min-width:<%= typeof width === "number" ? width + "px" : width %>"<% } %>>
<% items.forEach((it) => { -%> <% items.forEach((it) => { -%>
<% if (it.head != null) { -%> <% if (it.head != null) { -%>
<div class="menu-head"><%= it.head %></div> <div class="menu-head"><%= it.head %></div>
<% } else if (it.sep) { -%> <% } else if (it.sep) { -%>
<div class="menu-sep"></div> <div class="menu-sep"></div>
<% } else if (it.group) { const g = it.group; -%> <% } else if (it.group) { const g = it.group; -%>
<fieldset class="menu-field"><% if (g.legend) { %><legend class="menu-head"><%= g.legend %></legend><% } %><% g.options.forEach((o) => { %><label class="menu-check"><input type="<%= g.control || "checkbox" %>" name="<%= g.name %>" value="<%= o.value %>"<%= o.checked ? " checked" : "" %>><%= o.label %></label><% }) %></fieldset> <fieldset class="menu-field"><% if (g.legend) { %><legend class="menu-head"><%= g.legend %></legend><% } %><% g.options.forEach((o) => { %><label class="menu-check"><input type="<%= g.control || "checkbox" %>" name="<%= g.name %>" value="<%= o.value %>"<%= o.checked ? " checked" : "" %>><%= o.label %></label><% }) %></fieldset>
<% } else if (it.href) { -%> <% } else if (it.href) { -%>
<a class="menu-item<%= it.danger ? " danger" : "" %>" href="<%= it.ownLocale ? it.href : localeHref(it.href) %>"<% if (it.hreflang) { %> hreflang="<%= it.hreflang %>" lang="<%= it.hreflang %>"<% } %><% if (it.current) { %> aria-current="true"<% } %>><% if (it.icon) { %><svg class="ico"><use href="#<%= it.icon %>"/></svg><% } %><%= it.label %></a> <a class="menu-item<%= it.danger ? " danger" : "" %>" href="<%= it.ownLocale ? it.href : localeHref(it.href) %>"<% if (it.hreflang) { %> hreflang="<%= it.hreflang %>" lang="<%= it.hreflang %>"<% } %><% if (it.current) { %> aria-current="true"<% } %>><% if (it.icon) { %><svg class="ico"><use href="#<%= it.icon %>"/></svg><% } %><%= it.label %></a>
<% } else { -%> <% } else { -%>
<button class="menu-item<%= it.danger ? " danger" : "" %>" type="button"><% if (it.icon) { %><svg class="ico"><use href="#<%= it.icon %>"/></svg><% } %><%= it.label %></button> <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></div>
</details>
+9 -7
View File
@@ -57,16 +57,18 @@
<div class="footer-actions"> <div class="footer-actions">
<% if (user.email) { %> <% if (user.email) { %>
<%# signed in: profile menu inline (the summary composes escaped user values) %> <%# Signed in: the same popover block as the menu partial, hand-rolled because this one's
<details class="menu" style="flex:1 1 auto"> trigger composes escaped user values and its item is a CSRF POST form, neither of which
<summary class="profile"> the partial's Item shapes cover (AGENTS.md). Keep the two in step. %>
<div 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="avatar" aria-hidden="true"><%= user.initials %></span>
<span class="profile-meta"> <span class="profile-meta">
<span class="profile-name"><%= user.name %></span> <span class="profile-name"><%= user.name %></span>
<span class="profile-mail"><%= user.email %></span> <span class="profile-mail"><%= user.email %></span>
</span> </span>
</summary> </button>
<div class="menu-pop left up" style="min-width:220px"> <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> <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 %> <%# 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") %>"> <form class="menu-item-form" method="post" action="<%= localeHref("/logout") %>">
@@ -74,7 +76,7 @@
<button class="menu-item danger" type="submit"><svg class="ico"><use href="#i-logout" /></svg><%= t("shell.signOut") %></button> <button class="menu-item danger" type="submit"><svg class="ico"><use href="#i-logout" /></svg><%= t("shell.signOut") %></button>
</form> </form>
</div> </div>
</details> </div>
<% } else if (!hideSignIn) { %> <% } else if (!hideSignIn) { %>
<%# anonymous (a public page in the shell): no session to end — offer a way in instead. <%# 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. signInHref carries this page as return_to (chrome.signInHref); falls back to bare /login.
@@ -82,7 +84,7 @@
<a class="btn btn-primary" href="<%= locals.signInHref || '/login' %>" style="flex:1 1 auto"><svg class="ico ico-sm" aria-hidden="true"><use href="#i-user" /></svg><%= t("shell.signIn") %></a> <a class="btn btn-primary" href="<%= locals.signInHref || '/login' %>" style="flex:1 1 auto"><svg class="ico ico-sm" aria-hidden="true"><use href="#i-user" /></svg><%= t("shell.signIn") %></a>
<% } %> <% } %>
<%- include("locale-switch") %> <%- include("locale-switch", { localeMenuId: "chrome-locale-menu" }) %>
</div> </div>
</div> </div>
</aside> </aside>