Compare commits

..

1 Commits

Author SHA1 Message Date
renovate-bot 6a5adfc507 Update dependency ejs to v7
CI / full-gate (push) Successful in 3m12s
Release-Bump: major
2026-10-28 04:21:16 +00:00
25 changed files with 263 additions and 754 deletions
+1 -1
View File
@@ -19,4 +19,4 @@ jobs:
run: |
docker run --rm -v "$PWD:/repo" -w /repo \
-e REGISTRY_TOKEN -e REGISTRY_USER -e REPO_TOKEN -e REPOSITORY -e SERVER_URL \
node:24.22.2-alpine3.24 node registry-cleanup/cleanup.ts
node:24.22.1-alpine3.24 node registry-cleanup/cleanup.ts
+3 -3
View File
@@ -21,7 +21,7 @@ jobs:
GIT_TAG: ${{ github.ref_name }}
run: |
set -euo pipefail
docker run --rm -v "$PWD:/repo" -w /repo node:24.22.2-alpine3.24 \
docker run --rm -v "$PWD:/repo" -w /repo node:24.22.1-alpine3.24 \
node release-tooling/contract-version.ts "$GIT_TAG" src/plugin-host/plugin.ts
- name: Promote the commit-hash image to semver + latest
env:
@@ -98,9 +98,9 @@ jobs:
VERSION=${INPUT_VERSION:-${GIT_TAG#v}}
VERSION=${VERSION#v}
# An empty dispatch input falls back to the branch name, so gate this like a tag.
docker run --rm -v "$PWD:/repo" -w /repo node:24.22.2-alpine3.24 \
docker run --rm -v "$PWD:/repo" -w /repo node:24.22.1-alpine3.24 \
node release-tooling/contract-version.ts "$VERSION" src/plugin-host/plugin.ts
docker run --rm -v "$PWD:/repo" -w /repo \
-e DOCKERHUB_REPO -e DOCKERHUB_TOKEN -e DOCKERHUB_USER \
node:24.22.2-alpine3.24 \
node:24.22.1-alpine3.24 \
node release-tooling/dockerhub-overview.ts "$VERSION"
+2 -2
View File
@@ -58,11 +58,11 @@ jobs:
if [ -z "$BUMPS" ]; then
echo "Renovate commits since ${LATEST}, but none carry Release-Bump — nothing reached a running Plainpages; skipping"; exit 0
fi
NEXT=$(docker run --rm -v "$PWD:/repo" -w /repo node:24.22.2-alpine3.24 \
NEXT=$(docker run --rm -v "$PWD:/repo" -w /repo node:24.22.1-alpine3.24 \
node release-tooling/next-version.ts "$LATEST" $BUMPS)
# Read the constant off origin/main, not the checkout, which lags the merges this run made.
git show origin/main:src/plugin-host/plugin.ts \
| docker run -i --rm -v "$PWD:/repo" -w /repo node:24.22.2-alpine3.24 \
| docker run -i --rm -v "$PWD:/repo" -w /repo node:24.22.1-alpine3.24 \
node release-tooling/contract-version.ts "$NEXT" -
echo "Releasing $LATEST -> $NEXT"
git tag "$NEXT" origin/main
+1 -14
View File
@@ -221,9 +221,6 @@ Revisit only if the stated reason stops holding.
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 (`users:read` is
a safe helpdesk grant). Don't let the per-resource naming imply otherwise in docs.
- **The CSRF token is deliberately unbound to the session** (`src/auth/csrf.ts`; README → Security
model): the usual cost of a signed double-submit token, covered by `SameSite=Lax` and HSTS.
**Valid while the session cookie stays `SameSite=Lax` or stricter.**
- **Plainpages says "user" everywhere; Ory's word is "identity".** House style, not a renamed
concept. The single exception is the `Identity` DTO in `src/auth/kratos-admin.ts`, which mirrors
Kratos' wire shape — don't rename it.
@@ -370,7 +367,7 @@ docker compose -f compose.yml up --build -d # production
stack up and a *minimal* plugin live. Nothing comes before Quick start. Keep its commands
copy-pasteable; deeper detail lives in its own section, linked.
2. **Returning developer (rest).** A **Contents** ToC right after Quick start, then sections ordered
by **what an adopter reaches for first**, not by architectural layering: Goals → Audience → Overview → Users, groups
by **what an adopter reaches for first**, not by architectural layering: Overview → Users, groups
& permissions → Building plugins → menu/blocks/interactivity → Configuration → Auth → Email →
Architecture → Testing → Production → Observability → JWT-rotation runbook → Project-layout file
map → Extending. Place a new section by how early an adopter needs it. **Users, groups &
@@ -463,13 +460,3 @@ Default to **no comment**. Delete one that restates the adjacent code, repeats a
elsewhere, justifies self-evident code, or records history. Write one only for what a competent
reader of *this* codebase could not infer: a surprising why, a footgun, an invariant, an external
constraint. See [Prose discipline](#prose-discipline).
## Scoring run
The comprehension panel's seats fill their prompts from these fields.
- **Language:** strict TypeScript on Node 24, with EJS views and plain CSS
- **Kind:** a server-rendered web GUI host with a plugin system
- **Domain:** web authentication, authorization and server-rendered admin GUIs
- **Domain docs:** the Ory Kratos, Keto and Hydra documentation
- **3am question:** a visitor on an old TV browser reports that the sign-in button is blank. Which file and rule?
-49
View File
@@ -4,55 +4,6 @@ The release version **is** the plugin contract version (`HOST_API_VERSION`), so
contract break: a plugin's `apiVersion` must match the host's `major.minor` or discovery refuses it
at boot. Entries start at 0.3.0.
## Unreleased
### The mobile nav stays reachable on engines without `:has()`
At 860px and narrower, an engine without `:has()` (Chromium below 105, such as webOS TV 22 and 23,
and Firefox below 121) hid the sidebar off-canvas with no way to open it, so the pages the nav links
to could not be reached. Now the sidebar moves off-canvas, behind the hamburger, only where `:has()`
works. Elsewhere it stays in the flow above the content and the hamburger is hidden.
### The whole nav tree shows on engines without `:has()`
On an engine without `:has()` (Chromium below 105, such as webOS TV 22 and 23, and Firefox below 121)
every nested nav entry was hidden with nothing to open it. Now the tree folds only where `:has()`
works and stays fully open elsewhere, with its toggles hidden. The theme switch, which also needs
`:has()`, is hidden there.
### Button and badge text meets 4.5:1 contrast
The primary button and the current page button take their text colour from the new `--on-accent`
token: white in light mode, near-black in dark mode, where white on the accent was 2.7:1. The
light-mode success, warning and info badge text is darker. A plugin that overrides `--accent` or
`--l-accent` should set `--l-on-accent` and `--d-on-accent` to match.
### Unused auth stylesheet rules are gone
`public/css/auth.css` no longer holds the `.auth-view` screen switch, `.tpl-*` preview controls,
`.forgot-alert`, `.register-alert`, `.remember` and `.auth-foot`; no core view used them. A plugin
view that did must carry its own rules.
### Colours render on engines without `oklch()`
The theme palette in `public/css/styles.css` is hex, not `oklch()`. On an engine older than
Chromium 111 (Tizen 2024, webOS TV 24) `var(--accent)` was invalid, so the primary button was white
text on no background. A plugin that sets a colour custom property, such as `--accent` or `--l-accent`,
should use hex, `rgb()` or `hsl()` too.
### One horizontal inset across the content pane
The topbar, filter bar, pager, form page and empty-table message inset their content by `--pad-x`
(12px), as table cells do, instead of 16px. A plugin that overrides `--pad-x` now moves all of them
together.
### Pages are not kept by a shared cache
Every page and JSON response now carries `Cache-Control: private, no-store`, so a cache or reverse
proxy in front of Plainpages can't store a signed-in page and serve it to the next visitor. A route
whose page is the same for everyone can still set its own `cache-control` through
`RouteResult.headers`; assets are unchanged.
## 0.4.0
**Breaking.** Set `apiVersion: "0.4.0"`. The app shell no longer bounds the content column, so a page
+1 -1
View File
@@ -1,5 +1,5 @@
# Node 24 runs TypeScript directly (type stripping) — no build step. Pinned exact tag.
FROM node:24.22.2-alpine3.24
FROM node:24.22.1-alpine3.24
# Above WORKDIR so dev's `.:/app` bind mount can't shadow them; a volume at /app/node_modules
# instead leaves a root-owned dir in the checkout (the daemon creates mount destinations as root).
+5 -82
View File
@@ -70,8 +70,6 @@ From here, render real pages against the app shell and fetch upstream data — s
## Contents
- [Goals](#goals)
- [Audience](#audience)
- [Overview](#overview)
- [Users, groups & permissions](#users-groups--permissions)
- [naming a permission](#naming-a-permission)
@@ -123,64 +121,6 @@ From here, render real pages against the app shell and fetch upstream data — s
- [Project layout](#project-layout)
- [Extending the core](#extending-the-core)
## Goals
1. **Achieve:** Less boilerplate for a server-rendered web GUI: a new project starts at
its own domain, not at sign-in, a menu or a table.
- Signal: a new plugin reaches a working, gated page with a menu, tables, filters and
forms built only from the core building blocks.
- Counter-signal: the core grows features only one project needs.
- Why: boilerplate removed by absorbing a project's domain only moves it into the core.
2. **Achieve:** Every building block works for the person using it: a non-technical user
gets what they meant on the first try, and can't hurt themselves by accident.
- Signal: bugs users hit in a shipped flow get fixed and stay fixed, such as a pasted
code being refused, a double click acting twice, or an admin locking themselves out.
- Counter-signal: a fix adds client-side JavaScript, or a setting every plugin has to
know about.
- Why: a fix that breaks zero-JS or simplicity moves the irritation from users to every
plugin author.
3. **Maintain:** Plainpages solves only the parts every project shares: auth, the GUI
building blocks (menu, tables, filters, forms) and the plugin structure. Everything
else is a plugin.
4. **Maintain:** Every page is a server-rendered document that works with zero JavaScript,
on old and embedded browsers too. A newer browser only makes it nicer.
5. **Maintain:** State lives in the URL. Only state the URL can't carry lives in the
database, never in local storage or JavaScript. A cookie carries only what must be bound
to the browser: the session and its CSRF token.
6. **Maintain:** The frontend is semantic: the HTML says what each thing is, and the CSS
styles meaning, not looks. That keeps every page accessible.
7. **Achieve:** Hackable: a plugin can change anything, and Plainpages stays out of its way.
- Signal: a plugin replaces any page, partial, menu entry or string without forking the
host, and fewer plugins need a host change to get unblocked.
- Counter-signal: plugins break on upgrade with no version signal, or a plugin mistake
shows up at runtime instead of at boot.
- Why: freedom that breaks silently costs more than the boilerplate it saved.
8. **Achieve:** Speed: pages render fast and stay small.
- Signal: server time per page and bytes sent per page fall.
- Counter-signal: a building block loses a feature or accessibility to save bytes.
- Why: a fast page that does less only moves the work back into each plugin.
9. **Achieve:** LLM-friendly: an LLM builds a correct plugin from the README and the
examples alone.
- Signal: an agent given only those writes a plugin that passes discovery and its E2E
test on the first try.
- Counter-signal: the README grows to restate what the code already shows.
- Why: docs that pass the test by growing cost every later reader, human or LLM.
10. **Maintain:** Every behaviour is an explicit configuration choice, the same in every
environment.
## Audience
- **Developers starting a web GUI project, and the LLMs that build plugins with them.**
They may rely on `@plainpages/plugin-api`, the core partials and the packaging promises,
all versioned by `apiVersion`. A change that breaks a plugin takes a major bump.
- **Operators** who self-host Plainpages. They may rely on semver-tagged images, on
configuration through environment variables and the drop-in mounts (`plugins/`,
`config/`, `locales/`), and on upgrades within a minor version that need no change on
their side.
- **Visitors** to a site built on Plainpages, on any browser, including the older engines in
TVs, kiosks, thin clients and embedded panels. They may rely on pages that load fast and
work without JavaScript, with a keyboard and a screen reader.
## Overview
Plainpages gives you the boring-but-hard parts of a web app — a design system, a menu,
@@ -196,9 +136,11 @@ public front with an authenticated area behind it.
- **You add:** everything else domain-specific, as **plugins** — built from the same building
blocks the admin plugin uses.
Identity, sessions, SSO, OAuth2 and permission checks are delegated to **Ory** sidecar
services. The shape doesn't change as it grows: every plugin is the same self-contained folder,
the hot path is the same I/O-free JWT check, and the host stores nothing of its own.
**Priorities:** simplicity, few dependencies, strict TypeScript, no build step, Docker-only,
environment-agnostic (no `NODE_ENV` — every behaviour is an explicit config toggle). Identity,
sessions, SSO, OAuth2 and permission checks are delegated to **Ory** sidecar services. The shape
doesn't change as it grows: every plugin is the same self-contained folder, the hot path is the
same I/O-free JWT check, and there is no app database.
**Plugins are the extension model.** The plugin API is the product's main surface: powerful,
predictable, and overloadable — a plugin can take over as much of a page as it wants. The host
@@ -1019,15 +961,6 @@ underneath it — cramped, but nothing is unreachable. List state
(`?q=…&status=…&sort=…&page=…`) lives **in the URL**, so a view is bookmarkable and shareable; the
URL is the only state the UI keeps.
**Two browser floors** serve goal 4. Every page's content and actions stay reachable on
Chromium 87, Safari 15 and Firefox 115, the engines in years-old TVs, kiosks and Qt panels. Only
features [Baseline Widely available](https://web-platform-dx.github.io/baseline/) (in every
major engine for 30 months) may shape how a page looks; anything newer, like popovers or anchor
positioning, only enhances. A feature the floor engines lack, such as `:has()`, may
decorate a page but never decide whether something shows or can be read. A colour is hex, `rgb()` or
`hsl()`, never `oklch()`: a custom property holding it passes unchecked, and the `var()` reading it
then renders transparent.
**A field whose options depend on another field is asked for in steps, one GET form each**, so every
choice is in the URL, and nothing typed is lost to one because nothing is typed until they are made.
Once the query names a value, that field renders read-only (`field` with `readonly`, and a `link` back
@@ -1386,11 +1319,6 @@ authorization graph, so treat it the same. `compose.yml` publishes none of the s
so a stolen cookie jar is worth 30 days of re-mintable access, not ten minutes. Only our two cookies
obey `SECURE_COOKIES`; the Kratos one takes its flags from Kratos' own config.
**The CSRF token isn't bound to the session.** It is a signed double-submit token, so an attacker who
can write cookies on your origin can plant one they know. `SameSite=Lax` keeps a cross-site page
out, and HSTS (`SECURE_COOKIES=true`) a network attacker. A hostile page on a sibling subdomain can
still set cookies, so host nothing untrusted on one.
**Offboarding is not instant by default** — a revoked permission or deactivated identity lands
within one token TTL, unless the [denylist](#instant-revoke-the-optional-denylist) is on.
@@ -1652,11 +1580,6 @@ Every response carries security headers (`src/http/security-headers.ts`): a stri
so a branding logo must live under `/public/` or be a `data:` URI; a plugin route can override any
header per-response via `RouteResult.headers`.
Every page and JSON response is `Cache-Control: private, no-store`, so a cache in front of `web`
never keeps one user's page for the next. A route may override it only for a page that is the same for
every visitor: no user, no CSRF token, no `Set-Cookie`, since the host adds a fresh CSRF cookie to
every plugin route.
A deep link reached while signed out — or after the session JWT lapses mid-task — bounces to the
themed sign-in and returns to the **page that was requested** (`return_to`, validated host-relative
by `localPath` in `src/http/safe-url.ts`, so a crafted value can't turn login completion into an open
+1 -1
View File
@@ -49,7 +49,7 @@ services:
# backs it (PLUGIN_SETTING_SCHEDULING_UPSTREAM above points here). Stand-in for the customer's real service —
# stdlib-only, in-memory, no auth. Prod points PLUGIN_SETTING_SCHEDULING_UPSTREAM at the real backend instead.
shifts-upstream:
image: node:24.22.2-alpine3.24
image: node:24.22.1-alpine3.24
command: node /srv/server.ts
restart: unless-stopped
volumes:
-64
View File
@@ -1,64 +0,0 @@
# Comprehension history
## 2026-10-31T00:16:02Z, PR #181 at 56ff987, against eed1050
Ruling: better
| Seat | Navigation | Locality | Shape | Self-sufficiency | Overall |
|---|---|---|---|---|---|
| Mid A | same | better | same | better | better |
| Inherited architect | same | better | same | better | better |
Mid A, decided by:
- Self-sufficiency, Locality: `public/css/styles.css:24` `:root`: the comment above the hex palette says why a colour must not be oklch(), where a colour is edited
- Locality: `src/ui/stylesheet-floor.test.ts:11` `declares no oklch() colour`: a colour edit that breaks the browser floor fails a test instead of an old engine
Inherited architect, decided by:
- Self-sufficiency, Locality: `public/css/styles.css:24` `:root`: the comment above the hex palette says why a colour must not be oklch(), where a colour is edited
- Locality: `src/ui/stylesheet-floor.test.ts:11` `declares no oklch() colour`: a red gate explains the symptom in the file it lands on
- Self-sufficiency: `README.md:1027` "Two browser floors": the colour rule now sits beside the floor rule
## 2026-10-31T00:43:19Z, PR #182 at 19987ba, against 3e70768
Ruling: better
| Seat | Navigation | Locality | Shape | Self-sufficiency | Overall |
|---|---|---|---|---|---|
| Mid A | better | better | better | better | better |
| Inherited architect | better | better | better | better | better |
Mid A, decided by:
- Navigation, Shape, Self-sufficiency, Overall: `public/css/auth.css:1` `auth.css`: about 120 lines of rules for markup no view renders are gone, so a cold reader no longer rules them out first
- Locality, Self-sufficiency, Overall: `public/css/styles.css:31` `--on-accent`: the text colour on the accent is one token where four hard-coded `#fff` stood
- Self-sufficiency, Navigation, Overall: `public/css/styles.css:287` `@supports not selector(:has(a))`: the nav comment, the partial's header and the rule all say the tree folds only where `:has()` works
- Locality: `public/css/styles.css:241` `@supports selector(:has(a))`: the nav-fold behaviour now sits in two blocks about 50 lines apart, the second order-dependent; the comment names it
Inherited architect, decided by:
- Navigation, Shape, Overall: `public/css/auth.css:1` `auth.css`: the false `:target` banner and the phantom state machine are gone, so every selector left names something a view emits
- Self-sufficiency, Overall: `views/partials/nav-tree.ejs:4` `nav-tree.ejs`: the header and `public/css/styles.css:194` now say the branch folds only where `:has()` works; the old text was untrue on old engines
- Locality, Overall: `public/css/styles.css:31` `--l-on-accent`: one token feeds `.btn-primary`, `.page-btn[aria-current]` and `.brand-mark`
- Locality: `public/css/styles.css:287` `@supports not selector(:has(a))`: its `.nav-disc` rule sits about 85 lines from the nav rules it overrides, and `stylesheet-floor.test.ts` guards the order for `.theme-switch` only
- Self-sufficiency: `src/ui/stylesheet-contrast.test.ts:29` `stylesheet-contrast.test.ts`: the darker badge colours now have a stated 4.5:1 reason someone can find
## 2026-10-31T01:09:38Z, PR #183 at 0f20b38, against 364b047
Ruling: better
| Seat | Navigation | Locality | Shape | Self-sufficiency | Overall |
|---|---|---|---|---|---|
| Mid A | same | same | same | better | better |
| Inherited architect | same | better | same | better | better |
Mid A, decided by:
- Self-sufficiency, Overall: `public/css/styles.css:1` `App Shell Template header`: the false `light-dark()` bullet is gone, so no reader has to work out which of two comments is lying
- Self-sufficiency, Overall: `public/css/styles.css:687` `@supports selector(:has(a))` in `@media (max-width: 860px)`: the guard and its comment say what an engine without `:has()` gets, without opening the CHANGELOG
Inherited architect, decided by:
- Self-sufficiency, Overall: `public/css/styles.css:5` `Architecture` header: the false `light-dark()` bullet is gone, so the header no longer contradicts the TOKENS comment
- Locality, Self-sufficiency, Overall: `public/css/styles.css:687` `@supports selector(:has(a))` in `@media (max-width: 860px)`: the guard and its comment put the dependency of the open state on `:has()` where an editor would change it
-49
View File
@@ -1,49 +0,0 @@
# Reading cost
## Reading cost, chunk 57e864bce02a, finished 2026-10-30T23:59:00Z
100624 new input tokens before the first commit.
Read most (opens and searches):
- `src/http/app.test.ts` 9
- `README.md` 7
- `src/http/app.ts` 4
- `todo.md` 3
- `e2e-tests/playwright.config.ts` 2
- `AGENTS.md` 1
- `CHANGELOG.md` 1
- `ci.sh` 1
- `examples/plugins/scheduling/views/shifts.ejs` 1
- `src/auth/csrf.ts` 1
## Reading cost, chunk df5e30a4db5f, finished 2026-10-31T00:22:00Z
80251 new input tokens before the first commit.
Read most (opens and searches):
- `todo.md` 5
- `README.md` 3
- `CHANGELOG.md` 2
- `public/css/styles.css` 2
- `AGENTS.md` 1
- `src/ui/stylesheet-floor.test.ts` 1
- `src/ui/theme-switch.test.ts` 1
## Reading cost, chunk 3bf0b1a97d77, finished 2026-10-31T00:48:14Z
167985 new input tokens before the first commit.
Read most (opens and searches):
- `public/css/styles.css` 17
- `todo.md` 7
- `README.md` 6
- `public/css/auth.css` 5
- `AGENTS.md` 1
- `CHANGELOG.md` 1
- `examples/plugins/scheduling/public/scheduling.css` 1
- `examples/plugins/scheduling/views/overview.ejs` 1
- `examples/plugins/scheduling/views/shifts.ejs` 1
- `src/http/app.test.ts` 1
+3 -3
View File
@@ -53,7 +53,7 @@ services:
# The reference plugin's upstream (examples/shifts-upstream) so /scheduling/shifts shows real rows.
shifts-upstream:
image: node:24.22.2-alpine3.24
image: node:24.22.1-alpine3.24
command: ["node", "/server.ts"]
volumes:
- ./examples/shifts-upstream/server.ts:/server.ts:ro
@@ -66,7 +66,7 @@ services:
# Mock OIDC provider for the SSO login test — stdlib Node, auto-approves, signs an id_token Kratos
# verifies via its jwks. Reachable as the same host (mock-oidc:9000) by both the browser and Kratos.
mock-oidc:
image: node:24.22.2-alpine3.24
image: node:24.22.1-alpine3.24
command: ["node", "/mock-oidc.ts"]
environment:
ISSUER: http://mock-oidc:9000
@@ -81,7 +81,7 @@ services:
# Same-origin gateway: Kratos-owned paths → kratos, everything else → web (e2e-tests/proxy.ts).
proxy:
image: node:24.22.2-alpine3.24
image: node:24.22.1-alpine3.24
command: ["node", "/proxy.ts"]
depends_on:
web:
-1
View File
@@ -10,7 +10,6 @@ export default defineConfig({
testDir: ".",
outputDir: "artifacts/test-output",
fullyParallel: true,
workers: 4, // Playwright's default is half the cores, so a run's parallelism would depend on the machine
forbidOnly: true,
reporter: [["list"], ["html", { open: "never", outputFolder: "artifacts/report" }]],
use: {
-59
View File
@@ -148,21 +148,6 @@ test("mobile layout hides the sidebar off-canvas behind the hamburger", async ({
expect(offCanvas).toBe(true);
});
test("the hamburger opens the drawer over the scrim, and a link in it navigates", async ({ page }) => {
await page.setViewportSize({ width: 390, height: 844 });
await page.goto("/dashboard");
await page.locator(".hamburger").click();
const sidebar = page.locator(".sidebar");
await expect(sidebar).toBeInViewport({ ratio: 1 });
// A click is refused while the scrim sits above the drawer, which a lower z-index would cause.
await sidebar.locator("summary").first().click(); // the dashboard is the current page, so its links are in a folded branch
const link = sidebar.locator(".nav-children a.nav-self").first();
const target = await link.getAttribute("href");
await link.click();
await expect(page).toHaveURL((url) => url.pathname === target?.split("?")[0]);
});
test("Sign-out is a CSRF-guarded POST form: the token is issued on the page, a tokenless POST is refused", async ({ page }) => {
await page.goto("/dashboard");
// The page issues a CSRF cookie and embeds the same token in the Sign-out form (double-submit).
@@ -189,50 +174,6 @@ test("the public landing at / is ungated and links to sign in + register", async
await shot(page, "live-05-public-landing");
});
// Measured on the rendered elements, so a theme block that drops --on-accent fails here.
const contrastOf = (el: Element): number => {
const luminance = (rgb: string): number => {
const [r = 0, g = 0, b = 0] = (rgb.match(/[\d.]+/g) ?? []).map((c) => {
const v = Number(c) / 255;
return v <= 0.03928 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4;
});
return 0.2126 * r + 0.7152 * g + 0.0722 * b;
};
const style = getComputedStyle(el);
const [hi = 0, lo = 0] = [luminance(style.color), luminance(style.backgroundColor)].sort((x, y) => y - x);
return (hi + 0.05) / (lo + 0.05);
};
for (const [name, colorScheme, forceDark] of [["light", "light", false], ["dark", "dark", false], ["forced dark", "light", true]] as const) {
test(`text on the accent reaches 4.5:1 in ${name} mode`, async ({ page, context }) => {
await context.clearCookies();
await page.emulateMedia({ colorScheme });
await page.goto("/");
if (forceDark) await page.locator("#theme-dark").check({ force: true });
// No page of this stack has a second page of results, so add the current-page button.
await page.locator("#main-content").evaluate((el) => el.insertAdjacentHTML("beforeend", '<a class="page-btn" aria-current="page" href="#">2</a>'));
for (const selector of [".landing .btn-primary", ".brand-mark", ".page-btn"]) {
await expect.poll(() => page.locator(selector).first().evaluate(contrastOf), { message: selector }).toBeGreaterThanOrEqual(4.5); // polls past the colour transition
}
});
}
test("a nav branch is folded until its toggle opens it — no JavaScript", async ({ page }) => {
await page.goto("/dashboard");
const child = page.locator('.sidebar a[href="/scheduling"]');
await expect(child).toBeHidden();
await page.locator('.sidebar summary[aria-label="Toggle Scheduling"]').click();
await expect(child).toBeVisible();
});
test("the reference plugin's lead text is inset by --pad-x, like the bars around it", async ({ page }) => {
await page.goto("/scheduling");
const [inset, padX] = await page.locator(".scheduling-page > p").evaluate((p) => [
p.getBoundingClientRect().left - (p.parentElement as HTMLElement).getBoundingClientRect().left,
parseFloat(getComputedStyle(document.documentElement).getPropertyValue("--pad-x")),
] as const);
expect(inset).toBe(padX);
});
test("unknown routes serve the 404 page (a real user-facing flow, covered end-to-end)", async ({ page }) => {
allowConsole(/status of 404 .*\/no-such-page$/); // the navigation under test, which Chromium and WebKit log — not a sub-resource of it
const res = await page.goto("/no-such-page");
@@ -4,9 +4,3 @@
.scheduling-page .table-wrap {
margin-top: var(--space-3, 0.75rem);
}
/* Inset what sits directly in the pane by --pad-x, as the filter bar, table and pager already are. */
.scheduling-page > p,
.scheduling-page > .alert,
.scheduling-page > .btn {
margin-inline: var(--pad-x);
}
+1 -1
View File
@@ -2,7 +2,7 @@
Scheduling · Shifts list (reference plugin). The handler fetched the rows from the upstream
service; this view renders them with the core building blocks inside the native app shell
(ctx.chrome). `include()` reaches the core partials (shell, nav-tree, filter-bar, data-table,
alert) — see the README. Zero-JS: search round-trips the URL.
alert) — see docs/plugin-contract.md. Zero-JS: search round-trips the URL.
Data: chrome, title, breadcrumbs, count, filterBar, table, canWrite, newHref, error?
%><%
const navHtml = include("partials/nav-tree", { nodes: chrome.nav });
+8 -8
View File
@@ -7,8 +7,8 @@
"name": "plainpages",
"dependencies": {
"@larvit/log": "2.3.0",
"ejs": "6.0.1",
"lucide-static": "1.64.0",
"ejs": "7.0.2",
"lucide-static": "1.61.0",
"postgres": "3.4.9"
},
"devDependencies": {
@@ -387,9 +387,9 @@
}
},
"node_modules/ejs": {
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/ejs/-/ejs-6.0.1.tgz",
"integrity": "sha512-UaaM14yby8U3k02ihS1Bmj5Kz2d7CCQM1scxpgs4Mhkq8F1wR2gl3+Ts4h5Ne4Mnt7M9m4Dw7jsuMr3+xO4vZA==",
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/ejs/-/ejs-7.0.2.tgz",
"integrity": "sha512-LcDSEP6Oclb9sTiSyG0MpG0RuKvLDRxCOOt+2sIk+kdk1rMkFU8SRnT0qaNt5zvU0LOV1YXXOvIoZL/sKwabvw==",
"license": "Apache-2.0",
"bin": {
"ejs": "bin/cli.js"
@@ -399,9 +399,9 @@
}
},
"node_modules/lucide-static": {
"version": "1.64.0",
"resolved": "https://registry.npmjs.org/lucide-static/-/lucide-static-1.64.0.tgz",
"integrity": "sha512-UxWmMteOXbKHI5E4f2qVtxCAAsI9gsInYK1lNw0nrOA0w4FkJeOVZ+UOV6aGA2377FZe3qyiFYq3lqjBKZ6OuA==",
"version": "1.61.0",
"resolved": "https://registry.npmjs.org/lucide-static/-/lucide-static-1.61.0.tgz",
"integrity": "sha512-yLAiQB3MWUJEstsEDAwenSA3m7vr1tdLvgnC16Q/XMxRRma0DWwMs8M2bWlvgShrIs1m2mWMJ+0AiahqQO4GrA==",
"license": "ISC"
},
"node_modules/postgres": {
+2 -2
View File
@@ -17,8 +17,8 @@
},
"dependencies": {
"@larvit/log": "2.3.0",
"ejs": "6.0.1",
"lucide-static": "1.64.0",
"ejs": "7.0.2",
"lucide-static": "1.61.0",
"postgres": "3.4.9"
},
"devDependencies": {
+118
View File
@@ -1,3 +1,11 @@
/* =============================================================
Auth flow — login / register / forgot password
Built on the shared tokens in styles.css. Pure HTML/CSS:
- screens switch via :target (no JS)
- SSO is a config section toggled by a class/checkbox; providers
are a <ul> you extend by adding one <li class="sso-item">.
============================================================= */
.auth-stage {
min-height: 100dvh;
display: grid; place-items: center;
@@ -26,6 +34,13 @@
.landing-actions { display: flex; flex-wrap: wrap; gap: 10px; justify-content: center; margin-top: 4px; }
.landing-actions .btn { height: 40px; padding: 0 20px; font-size: var(--fz); }
/* ---- screen switching via :target (login is the default) ---- */
.auth-view { display: none; }
#login { display: block; }
#register:target, #forgot:target { display: block; }
body:has(#register:target) #login,
body:has(#forgot:target) #login { display: none; }
/* card */
.auth-card {
background: var(--surface);
@@ -108,6 +123,8 @@
.field-hint { font-size: var(--fz-xs); color: var(--text-faint); }
.remember { margin-top: 2px; }
/* full-width primary button (extends .btn / .btn-primary from styles.css) */
.btn-block { width: 100%; justify-content: center; height: 40px; font-size: var(--fz); }
@@ -121,6 +138,74 @@
.auth-aside a { color: var(--accent); }
.auth-aside a:hover { text-decoration: underline; }
/* legal footnote */
.auth-foot { margin: 0; text-align: center; font-size: var(--fz-xs); color: var(--text-faint); }
.auth-foot a { color: var(--text-muted); text-decoration: underline; }
/* =============================================================
Template preview controls (delete this block + its markup for
production). Drives theme + SSO on/off with zero JS.
============================================================= */
.tpl-controls {
position: fixed; bottom: 16px; left: 50%; transform: translateX(-50%);
z-index: 50;
display: flex; align-items: center; gap: 12px;
padding: 6px 8px 6px 14px;
background: var(--surface); border: 1px solid var(--border);
border-radius: 99px; box-shadow: 0 6px 22px rgba(0,0,0,.12);
}
.tpl-label { font-size: var(--fz-xs); color: var(--text-faint);
text-transform: uppercase; letter-spacing: .05em; font-weight: 600; }
.tpl-sep { width: 1px; height: 20px; background: var(--border); }
.tpl-controls .theme-switch { width: 156px; }
/* on/off switch */
.tpl-toggle { display: inline-flex; align-items: center; gap: 8px;
font-size: var(--fz-sm); color: var(--text-muted); cursor: pointer; }
.tpl-toggle input { position: absolute; opacity: 0; width: 0; height: 0; }
.tpl-track { width: 32px; height: 18px; border-radius: 99px;
background: var(--border-2); position: relative; transition: background .15s ease; }
.tpl-track::after { content: ""; position: absolute; top: 2px; left: 2px;
width: 14px; height: 14px; border-radius: 50%; background: #fff;
box-shadow: 0 1px 2px rgba(0,0,0,.3); transition: transform .15s ease; }
.tpl-toggle:has(input:checked) .tpl-track { background: var(--accent); }
.tpl-toggle:has(input:checked) .tpl-track::after { transform: translateX(14px); }
.tpl-toggle:has(input:focus-visible) .tpl-track {
outline: 2px solid var(--focus); outline-offset: 2px; }
@media (max-width: 520px) {
.tpl-label { display: none; }
.tpl-controls { gap: 10px; padding: 6px 10px; }
}
/* =============================================================
Forgot-password feedback (Post/Redirect/Get, no JS).
The form POSTs to its own URL; on the redirect-GET the server
re-renders #forgot with a state class so the matching alert shows:
<section id="forgot" class="auth-view state-sent"> … </section>
(state-sent | state-error). No extra view is added.
The preview radios below demonstrate the same states live.
============================================================= */
.forgot-alert { display: none; }
#forgot.state-sent .forgot-alert.is-sent,
body:has(#fstate-sent:checked) #forgot .forgot-alert.is-sent { display: flex; }
#forgot.state-error .forgot-alert.is-error,
body:has(#fstate-error:checked) #forgot .forgot-alert.is-error { display: flex; }
/* the Forgot-state control only appears while the forgot view is open */
.tpl-forgot { display: none; }
body:has(#forgot:target) .tpl-forgot { display: flex; }
.tpl-controls .segmented { padding: 1px; }
.tpl-controls .segmented label { padding: 3px 8px; }
/* =============================================================
Field-level errors (e.g. "email already used"), server-driven.
The server re-renders the field's form with a state class and
adds aria-invalid="true" to the input:
<section id="register" class="auth-view state-email-taken"> … </section>
Reusable: drop a <p class="field-error"> under any field and gate
it on whatever state your server sets.
============================================================= */
.field-error {
display: none; align-items: center; gap: 6px; margin: 0;
font-size: var(--fz-xs); color: var(--neg);
@@ -128,3 +213,36 @@
.field.has-error .field-error { display: flex; } /* server-rendered field shows its own error */
.field-error > .ico { flex: 0 0 auto; color: var(--neg); }
.field-error a { color: var(--neg); font-weight: 600; text-decoration: underline; }
/* register form-level error banner (pairs with field-level errors) */
.register-alert { display: none; }
/* --- state A: email already taken — field-level error only --- */
#register.state-email-taken .err-email,
body:has(#rstate-taken:checked) #register .err-email { display: flex; }
#register.state-email-taken #reg-email,
body:has(#rstate-taken:checked) #register #reg-email { border-color: var(--neg); }
/* --- state B: multiple errors — banner + several field errors together --- */
#register.state-invalid .register-alert,
body:has(#rstate-combined:checked) #register .register-alert { display: flex; }
#register.state-invalid .err-email,
#register.state-invalid .err-password,
body:has(#rstate-combined:checked) #register .err-email,
body:has(#rstate-combined:checked) #register .err-password { display: flex; }
#register.state-invalid #reg-email,
#register.state-invalid #reg-password,
body:has(#rstate-combined:checked) #register #reg-email,
body:has(#rstate-combined:checked) #register #reg-password { border-color: var(--neg); }
/* red focus ring on any invalid register input */
#register.state-email-taken #reg-email:focus,
#register.state-invalid #reg-email:focus,
#register.state-invalid #reg-password:focus,
body:has(#rstate-taken:checked) #register #reg-email:focus,
body:has(#rstate-combined:checked) #register #reg-email:focus,
body:has(#rstate-combined:checked) #register #reg-password:focus { box-shadow: 0 0 0 2px var(--neg-bg); }
/* the Register-state control only appears while the register view is open */
.tpl-register { display: none; }
body:has(#register:target) .tpl-register { display: flex; }
+48 -63
View File
@@ -2,6 +2,8 @@
App Shell Template — pure HTML/CSS, no JavaScript
-------------------------------------------------------------
Architecture
- Design tokens via light-dark() so theme follows system by
default and can be forced Light / Dark via radio + :has().
- Everything is a reusable "block": .nav-item, .filter,
.chip, .badge, .btn, .menu, .table — combine freely.
============================================================= */
@@ -19,35 +21,33 @@
widgets (date picker, scrollbars, selects).
------------------------------------------------------------- */
:root {
/* Hex, never oklch: below Chromium 111 `var(--accent)` is invalid at computed-value time and the
primary button loses its background. A fallback declaration cannot catch that. */
/* ---- raw LIGHT palette ---- */
--l-bg:#f3f3f4; --l-surface:#ffffff; --l-surface-2:#f6f6f7; --l-surface-3:#fbfbfc;
--l-border:#e5e5e8; --l-border-2:#d6d6da;
--l-text:#18181b; --l-text-muted:#6b6b73; --l-text-faint:#9a9aa2;
--l-accent:#3669ac; --l-accent-bg:#e5f3ff;
--l-accent-bd:#a7c7f2; --l-focus:#2a68b7; --l-on-accent:#ffffff;
--l-pos:#1f7a3d; --l-pos-bg:#e1f5e4; --l-pos-bd:#a8d5b0;
--l-neg:#c53637; --l-neg-bg:#ffebe8; --l-neg-bd:#febab4;
--l-warn:#8f5d00; --l-warn-bg:#fef0d4; --l-warn-bd:#e3c78e;
--l-info:#1d6aa5;--l-info-bg:#e2f5ff;--l-info-bd:#a2cdf2;
--l-accent:oklch(0.52 0.12 256); --l-accent-bg:oklch(0.96 0.03 256);
--l-accent-bd:oklch(0.82 0.07 256); --l-focus:oklch(0.52 0.14 256);
--l-pos:oklch(0.54 0.13 150); --l-pos-bg:oklch(0.95 0.03 150); --l-pos-bd:oklch(0.83 0.07 150);
--l-neg:oklch(0.55 0.18 25); --l-neg-bg:oklch(0.96 0.03 25); --l-neg-bd:oklch(0.85 0.08 25);
--l-warn:oklch(0.55 0.12 75); --l-warn-bg:oklch(0.96 0.04 85); --l-warn-bd:oklch(0.84 0.08 85);
--l-info:oklch(0.54 0.12 245);--l-info-bg:oklch(0.96 0.03 245);--l-info-bd:oklch(0.83 0.07 245);
/* ---- raw DARK palette ---- */
--d-bg:#0d0d0f; --d-surface:#161619; --d-surface-2:#1d1d22; --d-surface-3:#1a1a1e;
--d-border:#2a2a31; --d-border-2:#3a3a43;
--d-text:#ededf0; --d-text-muted:#9b9ba6; --d-text-faint:#6a6a74;
--d-accent:#6ba0e8; --d-accent-bg:#1d3351;
--d-accent-bd:#3f5f8a; --d-focus:#72adfb; --d-on-accent:#0d0d0f;
--d-pos:#62c37a; --d-pos-bg:#1a3520; --d-pos-bd:#376040;
--d-neg:#ff7e76; --d-neg-bg:#4b1d1b; --d-neg-bd:#8a423d;
--d-warn:#e4b750; --d-warn-bg:#3e2d10; --d-warn-bd:#72592c;
--d-info:#65b2f1;--d-info-bg:#12334c;--d-info-bd:#305f85;
--d-accent:oklch(0.70 0.12 256); --d-accent-bg:oklch(0.32 0.06 256);
--d-accent-bd:oklch(0.48 0.08 256); --d-focus:oklch(0.74 0.13 256);
--d-pos:oklch(0.74 0.14 150); --d-pos-bg:oklch(0.30 0.05 150); --d-pos-bd:oklch(0.45 0.07 150);
--d-neg:oklch(0.74 0.16 25); --d-neg-bg:oklch(0.30 0.07 25); --d-neg-bd:oklch(0.47 0.10 25);
--d-warn:oklch(0.80 0.13 85); --d-warn-bg:oklch(0.31 0.05 80); --d-warn-bd:oklch(0.48 0.07 80);
--d-info:oklch(0.74 0.12 245);--d-info-bg:oklch(0.31 0.06 245);--d-info-bd:oklch(0.47 0.08 245);
/* ---- active mapping: default = LIGHT ---- */
--bg:var(--l-bg); --surface:var(--l-surface); --surface-2:var(--l-surface-2); --surface-3:var(--l-surface-3);
--border:var(--l-border); --border-2:var(--l-border-2);
--text:var(--l-text); --text-muted:var(--l-text-muted); --text-faint:var(--l-text-faint);
--accent:var(--l-accent); --accent-bg:var(--l-accent-bg); --accent-bd:var(--l-accent-bd); --focus:var(--l-focus); --on-accent:var(--l-on-accent);
--accent:var(--l-accent); --accent-bg:var(--l-accent-bg); --accent-bd:var(--l-accent-bd); --focus:var(--l-focus);
--pos:var(--l-pos); --pos-bg:var(--l-pos-bg); --pos-bd:var(--l-pos-bd);
--neg:var(--l-neg); --neg-bg:var(--l-neg-bg); --neg-bd:var(--l-neg-bd);
--warn:var(--l-warn); --warn-bg:var(--l-warn-bg); --warn-bd:var(--l-warn-bd);
@@ -58,7 +58,7 @@
--radius: 5px;
--nav-w: 264px;
--row-h: 34px;
--pad-x: 12px; /* side padding of the content pane's bars and form page, and of every table cell, so their text lines up */
--pad-x: 12px;
--fz: 13px;
--fz-sm: 12px;
--fz-xs: 11px;
@@ -74,7 +74,7 @@
--bg:var(--d-bg); --surface:var(--d-surface); --surface-2:var(--d-surface-2); --surface-3:var(--d-surface-3);
--border:var(--d-border); --border-2:var(--d-border-2);
--text:var(--d-text); --text-muted:var(--d-text-muted); --text-faint:var(--d-text-faint);
--accent:var(--d-accent); --accent-bg:var(--d-accent-bg); --accent-bd:var(--d-accent-bd); --focus:var(--d-focus); --on-accent:var(--d-on-accent);
--accent:var(--d-accent); --accent-bg:var(--d-accent-bg); --accent-bd:var(--d-accent-bd); --focus:var(--d-focus);
--pos:var(--d-pos); --pos-bg:var(--d-pos-bg); --pos-bd:var(--d-pos-bd);
--neg:var(--d-neg); --neg-bg:var(--d-neg-bg); --neg-bd:var(--d-neg-bd);
--warn:var(--d-warn); --warn-bg:var(--d-warn-bg); --warn-bd:var(--d-warn-bd);
@@ -88,7 +88,7 @@ html:has(#theme-dark:checked) {
--bg:var(--d-bg); --surface:var(--d-surface); --surface-2:var(--d-surface-2); --surface-3:var(--d-surface-3);
--border:var(--d-border); --border-2:var(--d-border-2);
--text:var(--d-text); --text-muted:var(--d-text-muted); --text-faint:var(--d-text-faint);
--accent:var(--d-accent); --accent-bg:var(--d-accent-bg); --accent-bd:var(--d-accent-bd); --focus:var(--d-focus); --on-accent:var(--d-on-accent);
--accent:var(--d-accent); --accent-bg:var(--d-accent-bg); --accent-bd:var(--d-accent-bd); --focus:var(--d-focus);
--pos:var(--d-pos); --pos-bg:var(--d-pos-bg); --pos-bd:var(--d-pos-bd);
--neg:var(--d-neg); --neg-bg:var(--d-neg-bg); --neg-bd:var(--d-neg-bd);
--warn:var(--d-warn); --warn-bg:var(--d-warn-bg); --warn-bd:var(--d-warn-bd);
@@ -101,7 +101,7 @@ html:has(#theme-light:checked) {
--bg:var(--l-bg); --surface:var(--l-surface); --surface-2:var(--l-surface-2); --surface-3:var(--l-surface-3);
--border:var(--l-border); --border-2:var(--l-border-2);
--text:var(--l-text); --text-muted:var(--l-text-muted); --text-faint:var(--l-text-faint);
--accent:var(--l-accent); --accent-bg:var(--l-accent-bg); --accent-bd:var(--l-accent-bd); --focus:var(--l-focus); --on-accent:var(--l-on-accent);
--accent:var(--l-accent); --accent-bg:var(--l-accent-bg); --accent-bd:var(--l-accent-bd); --focus:var(--l-focus);
--pos:var(--l-pos); --pos-bg:var(--l-pos-bg); --pos-bd:var(--l-pos-bd);
--neg:var(--l-neg); --neg-bg:var(--l-neg-bg); --neg-bd:var(--l-neg-bd);
--warn:var(--l-warn); --warn-bg:var(--l-warn-bg); --warn-bd:var(--l-warn-bd);
@@ -169,9 +169,9 @@ summary { list-style: none; cursor: pointer; }
.brand-mark {
width: 22px; height: 22px; border-radius: 5px; flex: 0 0 auto;
background: var(--accent);
display: grid; place-items: center; color: var(--on-accent);
display: grid; place-items: center; color: #fff;
}
.brand-mark .ico { stroke: var(--on-accent); }
.brand-mark .ico { stroke: #fff; }
.brand-logo { width: 22px; height: 22px; border-radius: 5px; flex: 0 0 auto; object-fit: contain; }
.brand-name { font-weight: 650; letter-spacing: -.01em; }
.brand-sub { color: var(--text-faint); font-size: var(--fz-xs); margin-left: auto; }
@@ -189,10 +189,9 @@ summary { list-style: none; cursor: pointer; }
• CLICKABLE = label is <a href> → navigates on click.
STATIC = label is <span> → not a navigation target.
So "header" and "clickable" are independent: any combination works.
Children sit in a sibling <ul class="nav-children">. Where :has() works, a
branch is folded unless THIS row's own <details open> (scoped with > so a node
only ever controls its own subtree — infinite depth, no JS). Without :has()
every branch stays open, since nothing could reopen one.
Children sit in a sibling <ul class="nav-children">, revealed by
:has() on THIS row's own <details open> (scoped with > so a node
only ever controls its own subtree — infinite depth, no JS).
============================================================ */
.nav-row { display: flex; align-items: center; gap: 2px; }
@@ -236,10 +235,8 @@ span.nav-self { cursor: default; } /* static / non-clickable */
.nav-self[aria-current="page"] .ico { color: var(--accent); }
/* children + infinite indentation with a guide line per level */
@supports selector(:has(a)) {
.nav-node > .nav-children { display: none; }
.nav-node:has(> .nav-row > .nav-disc[open]) > .nav-children { display: block; }
}
.nav-node > .nav-children { display: none; }
.nav-node:has(> .nav-row > .nav-disc[open]) > .nav-children { display: block; }
.nav-children { padding-left: 22px; position: relative; }
.nav-children::before {
content: ""; position: absolute; left: 10px; top: 2px; bottom: 2px;
@@ -282,13 +279,6 @@ span.nav-self { cursor: default; } /* static / non-clickable */
outline: 2px solid var(--focus); outline-offset: 1px;
}
/* Without :has() every branch is open and every theme rule is inert: neither control does anything.
After the base rules, since @supports adds no specificity. */
@supports not selector(:has(a)) {
.nav-disc { visibility: hidden; } /* keeps its width, so the labels stay aligned */
.theme-switch { display: none; }
}
.footer-actions { display: flex; align-items: center; gap: 4px; }
.profile {
display: flex; align-items: center; gap: 9px; flex: 1 1 auto;
@@ -319,7 +309,7 @@ span.nav-self { cursor: default; } /* static / non-clickable */
}
.btn:hover { background: var(--surface-2); }
.btn-primary { background: var(--accent); border-color: var(--accent);
color: var(--on-accent); font-weight: 550; }
color: #fff; font-weight: 550; }
.btn-primary:hover { filter: brightness(1.06); }
.btn-ghost { background: transparent; border-color: transparent; }
.btn-ghost:hover { background: var(--surface-2); }
@@ -346,7 +336,7 @@ span.nav-self { cursor: default; } /* static / non-clickable */
.topbar {
flex: 0 0 auto; height: 48px;
display: flex; align-items: center; gap: 12px;
padding: 0 var(--pad-x); border-bottom: 1px solid var(--border);
padding: 0 16px; border-bottom: 1px solid var(--border);
background: var(--surface);
}
.hamburger { display: none; } /* shown only on narrow */
@@ -362,7 +352,7 @@ span.nav-self { cursor: default; } /* static / non-clickable */
.filters {
flex: 0 0 auto; background: var(--surface);
border-bottom: 1px solid var(--border);
padding: 10px var(--pad-x);
padding: 10px 16px;
display: flex; flex-direction: column; gap: 9px;
}
.filter-row {
@@ -594,7 +584,7 @@ table.table {
.col-num { text-align: right; }
.cell-strong { font-weight: 550; }
.cell-muted { color: var(--text-muted); }
.table-empty { text-align: center; color: var(--text-muted); padding: 28px var(--pad-x); }
.table-empty { text-align: center; color: var(--text-muted); padding: 28px 12px; }
.cell-mono { font-variant-numeric: tabular-nums;
font-family: ui-monospace, "SF Mono", Menlo, monospace; font-size: var(--fz-sm); }
@@ -658,7 +648,7 @@ th[aria-sort="descending"] .sort-ico { transform: rotate(180deg); }
.pager {
flex: 0 0 auto;
display: flex; align-items: center; gap: 12px;
padding: 8px var(--pad-x); border-top: 1px solid var(--border);
padding: 8px 16px; border-top: 1px solid var(--border);
background: var(--surface); font-size: var(--fz-sm); color: var(--text-muted);
}
.pager .spacer { flex: 1 1 auto; }
@@ -673,34 +663,29 @@ th[aria-sort="descending"] .sort-ico { transform: rotate(180deg); }
}
.page-btn:hover { background: var(--surface-2); color: var(--text); }
.page-btn[aria-current="page"] { background: var(--accent); border-color: var(--accent);
color: var(--on-accent); font-weight: 600; }
color: #fff; font-weight: 600; }
.page-btn:disabled { opacity: .4; cursor: not-allowed; }
/* ---------- 11. RESPONSIVE / HAMBURGER --------------------- */
.scrim { display: none; }
@media (max-width: 860px) {
.app { grid-template-columns: minmax(0, 1fr); grid-template-rows: auto 1fr; }
.content { grid-column: 1; grid-row: 2; }
.search { min-width: 150px; flex: 1 1 auto; }
/* Off-canvas only where :has() works: without it nothing can read #nav-toggle, so the sidebar
stays in the flow above the content and the hamburger stays hidden. */
@supports selector(:has(a)) {
.sidebar {
position: fixed; inset: 0 auto 0 0; width: min(86vw, var(--nav-w));
z-index: 60; transform: translateX(-100%);
box-shadow: 2px 0 24px rgba(0,0,0,.18);
}
.hamburger { display: inline-flex; }
/* checkbox-driven open state */
body:has(#nav-toggle:checked) .sidebar { transform: translateX(0); }
body:has(#nav-toggle:checked) .scrim {
display: block; position: fixed; inset: 0; z-index: 50;
background: rgba(0,0,0,.42);
}
.app { grid-template-columns: minmax(0, 1fr); }
.sidebar {
position: fixed; inset: 0 auto 0 0; width: min(86vw, var(--nav-w));
z-index: 60; transform: translateX(-100%);
box-shadow: 2px 0 24px rgba(0,0,0,.18);
}
.content { grid-column: 1; }
.hamburger { display: inline-flex; }
/* checkbox-driven open state */
body:has(#nav-toggle:checked) .sidebar { transform: translateX(0); }
body:has(#nav-toggle:checked) .scrim {
display: block; position: fixed; inset: 0; z-index: 50;
background: rgba(0,0,0,.42);
}
.search { min-width: 150px; flex: 1 1 auto; }
}
@media (max-width: 560px) {
.crumbs, .page-sub { display: none; }
@@ -711,7 +696,7 @@ th[aria-sort="descending"] .sort-ico { transform: rotate(180deg); }
#nav-toggle { position: fixed; top: 0; left: 0; opacity: 0; pointer-events: none; }
/* admin forms: create/edit user, account actions */
.form-page { padding: 16px var(--pad-x); display: flex; flex-direction: column; gap: 14px; max-width: 560px; }
.form-page { padding: 16px; display: flex; flex-direction: column; gap: 14px; max-width: 560px; }
.form-card {
display: flex; flex-direction: column; gap: 14px;
padding: 18px; background: var(--surface);
-33
View File
@@ -284,39 +284,6 @@ test("every response carries the security headers; HSTS follows SECURE_COOKIES",
assert.match(res.headers.get("strict-transport-security") ?? "", /max-age=\d+/);
});
test("Cache-Control: pages and JSON are private, no-store unless the route sets its own; assets are left alone", async (t) => {
const NO_STORE = "private, no-store";
const csrf = (await fetch(base + "/")).headers.get("set-cookie")?.split(";")[0] ?? "";
assert.match(csrf, new RegExp(`^${CSRF_COOKIE}=`));
// The signed-out landing is no-store as well: it and the signed-in page share one URL.
assert.equal((await fetch(base + "/")).headers.get("cache-control"), NO_STORE);
assert.equal((await fetch(base + "/", { headers: { cookie: csrf } })).headers.get("cache-control"), NO_STORE);
assert.equal((await fetch(base + "/", { headers: { cookie: csrf }, method: "HEAD" })).headers.get("cache-control"), NO_STORE);
assert.equal((await fetch(base + "/", { headers: { cookie: `${csrf}; ${session()}` } })).headers.get("cache-control"), NO_STORE);
assert.equal((await fetch(base + "/dashboard", { headers: { cookie: session() } })).headers.get("cache-control"), NO_STORE);
assert.equal((await fetch(base + "/dashboard", { redirect: "manual" })).headers.get("cache-control"), NO_STORE); // sign-in bounce
assert.equal((await fetch(base + "/nope", { headers: { cookie: csrf } })).headers.get("cache-control"), NO_STORE); // 404
assert.equal((await fetch(base + "/public/css/styles.css")).headers.get("cache-control"), null);
const demo: Plugin = {
apiVersion: "1.0.0",
home: () => ({ html: "<p>plugin home</p>" }),
id: "demo",
routes: [
{ handler: () => ({ html: "open" }), method: "GET", path: "/open", public: true },
{ handler: () => ({ headers: { "Cache-Control": "public, max-age=600" }, html: "same" }), method: "GET", path: "/same", public: true },
{ handler: () => ({ json: { ok: true } }), method: "GET", path: "/data", public: true },
],
};
const url = await startApp(t, [demo]);
for (const path of ["/demo/open", "/demo/data", "/"]) {
assert.equal((await fetch(url + path)).headers.get("cache-control"), NO_STORE, path);
}
assert.equal((await fetch(url + "/demo/same")).headers.get("cache-control"), "public, max-age=600");
});
// Production caches compiled templates; rendering must stay correct across repeated requests.
test("renders correctly with template caching enabled", async () => {
const app = createApp({ cache: true });
-7
View File
@@ -36,8 +36,6 @@ import { localPath } from "./safe-url.ts";
import { routePublic, serveStatic } from "./static.ts";
import { renderPluginView } from "../plugin-host/view-resolver.ts";
const NO_STORE = "private, no-store";
const rootDir = join(dirname(fileURLToPath(import.meta.url)), "..", "..");
export interface AppOptions {
@@ -205,11 +203,6 @@ export function createApp(options: AppOptions = {}): Server {
// A cache in front of us must key on the language. Set after the static branch: an asset is
// the same bytes in every language, and a Vary there fragments its entry per raw header.
res.setHeader("vary", "accept-language");
// Fail closed: a cache in front of us must not keep a page that may carry a user's data. Not
// even the signed-out `/` is shared: it and the signed-in page have one URL, and a cache keyed
// on URL and Accept-Language would hand the signed-out one to a signed-in visitor. A route that
// is the same for everyone sets its own value in RouteResult.headers; the host doesn't police it.
res.setHeader("cache-control", NO_STORE);
// Canonical host (APP_URL): send an off-host visitor to the configured origin so the browser,
// the themed forms and the cross-origin Kratos POST share one cookie host — otherwise the
-38
View File
@@ -1,38 +0,0 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { dirname, join } from "node:path";
import { test } from "node:test";
import { fileURLToPath } from "node:url";
const css = readFileSync(join(dirname(fileURLToPath(import.meta.url)), "..", "..", "public", "css", "styles.css"), "utf8");
const token = (name: string): string => {
const hex = new RegExp(`--${name}:\\s*(#[0-9a-f]{6})\\b`, "i").exec(css)?.[1];
assert.ok(hex, `--${name} is not a six-digit hex colour`);
return hex;
};
const luminance = (hex: string): number => {
const [r = 0, g = 0, b = 0] = [1, 3, 5].map((i) => {
const c = Number.parseInt(hex.slice(i, i + 2), 16) / 255;
return c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4;
});
return 0.2126 * r + 0.7152 * g + 0.0722 * b;
};
const contrast = (a: string, b: string): number => {
const [hi = 0, lo = 0] = [luminance(a), luminance(b)].sort((x, y) => y - x);
return (hi + 0.05) / (lo + 0.05);
};
// WCAG AA for normal text.
for (const theme of ["l", "d"]) {
for (const tone of ["pos", "neg", "warn", "info"]) {
test(`${tone} badge text reaches 4.5:1 in ${theme === "l" ? "light" : "dark"} mode`, () => {
assert.ok(contrast(token(`${theme}-${tone}`), token(`${theme}-${tone}-bg`)) >= 4.5);
});
}
test(`text on the accent reaches 4.5:1 in ${theme === "l" ? "light" : "dark"} mode`, () => {
assert.ok(contrast(token(`${theme}-on-accent`), token(`${theme}-accent`)) >= 4.5);
});
}
-69
View File
@@ -1,69 +0,0 @@
import assert from "node:assert/strict";
import { readdirSync, readFileSync } from "node:fs";
import { dirname, join } from "node:path";
import { test } from "node:test";
import { fileURLToPath } from "node:url";
const cssDir = join(dirname(fileURLToPath(import.meta.url)), "..", "..", "public", "css");
// A custom property holding oklch() passes unchecked on an engine below Chromium 111, so the
// var() that reads it is invalid at computed-value time: white text on no background.
for (const file of readdirSync(cssDir).filter((f) => f.endsWith(".css"))) {
test(`${file} declares no oklch() colour`, () => {
assert.ok(!/oklch\(/i.test(readFileSync(join(cssDir, file), "utf8")));
});
}
const stylesheet = readFileSync(join(cssDir, "styles.css"), "utf8").replace(/\/\*[\s\S]*?\*\//g, "");
// The index of the `}` closing the block whose `{` is the first one at or after `start`.
function blockEnd(start: number): number {
let depth = 0;
for (let i = stylesheet.indexOf("{", start); i < stylesheet.length; i++) {
if (stylesheet[i] === "{") depth++;
if (stylesheet[i] === "}" && --depth === 0) return i;
}
throw new Error("unbalanced braces in styles.css");
}
// The text is all there is to check: Playwright's engines all support `:has()`, so no E2E sees the
// stylesheet an older engine gets.
// Chromium below 105 and Firefox below 121 have no `:has()`, and nothing else reveals a folded
// branch there, so the fold may only exist where `:has()` works.
test("styles.css folds .nav-children only inside @supports selector(:has(a))", () => {
const open = stylesheet.indexOf("@supports selector(:has(a)) {");
assert.ok(open >= 0, "no @supports selector(:has(a)) block");
const close = blockEnd(open);
const outside = stylesheet.slice(0, open) + stylesheet.slice(close + 1);
assert.ok(!/\.nav-children\s*\{[^}]*display:\s*none/.test(outside), "a .nav-children rule hides it outside the guard");
assert.match(stylesheet.slice(open, close), /\.nav-children\s*\{\s*display:\s*none/, "the fold rule is gone");
});
// `@supports` adds no specificity, so the hiding rule has to come after the base `.theme-switch` rule.
test("styles.css hides the theme switch and the nav toggles where :has() is missing, after their base rules", () => {
const guard = stylesheet.indexOf("@supports not selector(:has(a)) {");
assert.ok(guard >= 0, "no @supports not selector(:has(a)) block");
const base = stylesheet.search(/^\.theme-switch\s*\{/m);
assert.ok(base >= 0 && base < guard, "the guard precedes the base .theme-switch rule");
const block = stylesheet.slice(guard, stylesheet.indexOf("\n}", guard));
assert.match(block, /\.theme-switch\s*\{\s*display:\s*none/);
assert.match(block, /\.nav-disc\s*\{\s*visibility:\s*hidden/);
});
// Without `:has()` nothing reads #nav-toggle, so an off-canvas sidebar there is unreachable and the
// hamburger does nothing: the sidebar has to stay in the flow, with the hamburger hidden.
test("styles.css moves the sidebar off-canvas at 860px only inside @supports selector(:has(a))", () => {
const open = stylesheet.indexOf("@media (max-width: 860px) {");
assert.ok(open >= 0, "no @media (max-width: 860px) block");
const close = blockEnd(open);
const media = stylesheet.slice(open, close);
const guard = media.indexOf("@supports selector(:has(a)) {");
assert.ok(guard >= 0, "no @supports selector(:has(a)) block inside the 860px media block");
const guardEnd = blockEnd(open + guard) - open;
const outside = media.slice(0, guard) + media.slice(guardEnd + 1);
const inside = media.slice(guard, guardEnd);
for (const offCanvas of [/translateX\(-100%\)/, /position:\s*fixed/, /#nav-toggle/, /\.hamburger/]) {
assert.doesNotMatch(outside, offCanvas, `${offCanvas} applies outside the guard`);
assert.match(inside, offCanvas, `${offCanvas} is gone from the guard`);
}
});
+67 -196
View File
@@ -1,203 +1,74 @@
# todo
# Primary todo
## Scoring
## Unfinnished work
`Score = 100 * V / (2**S + 8)`
- [ ] **Publish every release to Docker Hub, and give the Hub page its short description.** Today Hub's newest tag is 0.4.0 (2026-09-10), while git has v0.4.1–v0.4.12, and the overview still says 0.4.0. No `release.yml` run exists for the tags auto-release pushes as `renovate-bot`, so neither the images nor the overview reach Hub; find out why Gitea doesn't fire `push: tags` for them, fix it, and backfill the missing tags. The short description is empty. fejkdata already did this (`../fejkdata`, commits `51d924a` and `34c8234`), and two parts port directly: a `dockerhub/description.txt`-style one-liner, which here can ride the existing PATCH as its `description` field; and a unit test that the description opens a README sentence and every command the overview shows appears in the README. fejkdata also syncs the page on any push to `main` that changes it, with its own Read/Write/Delete token in an environment only `main` may use, so the image-push token needs no Delete scope. Decide whether that fits here, where `{{VERSION}}` must render a released tag.
- [ ] Rename the plugin "admin" to something less generic, like "auth-admin" or "users-groups-admin".
- [ ] Guard the group paths against self-lockout, or accept them explicitly. The self-revoke guard covers only your own *direct* grants on the Users screen; unticking a permission on a group you belong to, removing yourself from that group, or deleting it can all still strip your own effective access with no warning. Recorded in AGENTS.md as a known gap — the robust fix is a "last effective holder" check, which needs a reverse Keto query.
- [ ] The permission picker has no concurrency baseline, so two operators editing the same user/group silently discard each other's change. Sketch: post the rendered set as a hidden baseline; if it no longer matches Keto, re-render with "this changed while you had the page open" rather than applying.
- [ ] A grant whose plugin was uninstalled is invisible and unremovable in the GUI. `grantDiff` deliberately never revokes an undeclared name, but nothing *shows* it either — so it can't be audited or cleaned, and reinstalling that plugin silently reactivates access nobody remembers granting. Sketch: a read-only "held, but no installed plugin offers this" list with a remove action.
- [ ] A plugin may gate a route on a permission it never declares — declaring stays optional on purpose. The cost is a dead end: the picker is built from declarations only, so that route is ungrantable from the GUI with no boot error and a permanent 403 as the operator's only clue. Sketch: a discovery *warning* (not an error) naming the gated-but-undeclared permission.
- [ ] Saving permissions gives no confirmation, and a partial failure is silent. `applyGrants` loops writes then deletes with no transaction, so a Keto error midway leaves a half-applied set behind the generic error page; and a successful save is indistinguishable from "nothing changed". The `alert alert-pos` pattern the recovery-code banner uses is already available.
- [ ] In Playwright tests, try different resolutions and sizes, from BIG desktop down to tiny phone.
- [ ] Decide whether `e2e-tests/` should be typechecked. It is outside `tsconfig.include`, so the gate never checks its most logic-bearing file (`console-guard.ts`). Including it needs the DOM lib and `@playwright/test` present wherever `npm run typecheck` runs, which today is the `web` image that installs neither.
- [ ] Decide whether Playwright's `workers` should be pinned. Unset, it sizes the pool from `os.cpus()`, which reports the host's cores regardless of a container CPU quota — and with `retries: 0` a starved runner turns a slow test straight into a red gate. Fine on the current act_runner; revisit if CI ever runs constrained.
- [ ] Record the browser floor Plainpages requires, and whether the fallback is the contract or a courtesy. The stylesheet needs `:has()` (Dec 2023); the menus need the popover API (Safari 17) and CSS anchor positioning for placement (newer still, and unguarded — the `@supports` test covers popover only). An iPadOS 16 tablet, capped at Safari 16, therefore gets panels flowing inline rather than working menus. Either state a supported floor or accept the fallback for those devices; nobody has rendered that path on real hardware.
- [ ] Decide whether the profile dropdown still earns a dropdown. It holds one item, Sign out, behind a click, and its "Signed in as X" head repeats what the trigger already shows.
- [ ] Trim whitespace around the verification code — a copy+pasted code from the email fails, today as a browser `pattern` refusal rather than a Kratos rejection, now carrying a digits-only hint so that refusal isn't bare. The paste still fails. **Own session.** No zero-JS fix exists: `pattern` is rejective and cannot transform, so loosening it only lets the untrimmed value through. The real fix is a host-side proxy of the flow POST, which is feasible and half-built — `submitFlow` (`src/auth/kratos-public.ts:132`) already relays cookies and normalises a 422 `redirect_browser_to`, and has no callers outside tests. CSRF improves rather than breaks: the host already writes Kratos' cookie onto its own origin (`src/auth/routes.ts:77`), so the POST becomes same-origin, and `form-action 'self'` could finally join the CSP. Two risks: one `flow.ui.action` serves all five flows, so this rewrites the sign-in path, not just code entry; and Go's nosurf checks Referer only over https, which no test here covers. **Check first:** `kratos-admin.ts:75` returns a `recovery_link` alongside the code — if the stock courier mail carries one (unverified), a template change fixes the UX with no host code. Code entry has no E2E coverage today either way.
- [ ] Guard against the double-clicked submit, without client-side JavaScript. A non-technical user clicks a button twice when nothing happens fast enough, 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, and it must not break an action that is *legitimately* repeatable. Sketch: a CSS-only affordance so the second click has nothing to hit, paired with the host recognising a duplicate on the server — same session, route and payload within a short window — then logging and dropping it. Open questions: what identifies "the same submission" (a one-time token minted into each rendered form beats hashing the payload, and the CSRF plumbing already mints per-request tokens), the window length, where the record lives given the app is stateless, and how a plugin declares a route repeatable.
- [ ] Decide the caching contract for rendered pages. Responses carry `Vary: Accept-Language` but nothing sets `Cache-Control`, so a shared cache has no instruction and a signed-in page is not marked `private`. Either set the headers deliberately (public cacheable, gated `private, no-store`) or record in AGENTS.md that the reverse proxy owns this.
- [ ] Decide whether the single generic Keto `Resource` namespace should become per-domain namespaces (`Shift`, `Document`, …), as Ory's own examples model it. One global `Resource` bucket is the project's own "no catch-all names" rule applied to namespaces. A design question, not a naming one.
- [ ] Decide what `ICON_NAMES` (`src/ui/icons.ts`) actually is. `i-chart`, `i-copy`, `i-download` and `i-sliders` have no caller anywhere — so either they go, or the comment should say the palette is curated and may carry an id ahead of its first use. Not cosmetic: the sprite is inlined into every page, and the rule decides whether a future removal is routine cleanup or a plugin-facing regression.
- [ ] Decide (once) whether the CSRF token staying unbound to `sub`/session is accepted. `src/auth/csrf.ts` signs `<nonce>.<HMAC(secret, nonce)>` with no session binding, so any validly-signed token passes for any user — an attacker who can write cookies on the origin can fix a token they know. Standard for unbound signed double-submit and plausibly fine behind `SameSite=Lax` + HSTS. Accepted ⇒ record it in AGENTS.md and README → Security model; not accepted ⇒ bind the nonce to `sub`.
- [ ] Verify the documented Docker commands on macOS and fix whatever misbehaves — **macOS is a supported dev host**, but nothing here has been run on one. Two suspects, both from the `--user "$(id -u):$(id -g)"` idiom: a macOS `id -g` is `20`, which is `dialout` inside the noble image rather than a user group, and Docker Desktop remaps bind-mount ownership in its own VM layer. The same question covers rootless Docker, where README already says to *drop* the flag.
- [ ] Map Kratos' 401 on a self-service flow init, so an anonymous `GET /settings` with no `?flow` renders instead of 500ing. `flowPage` (`src/auth/routes.ts`) maps 403/404/410 → restart the flow, 400 `session_already_available` → `/auth/complete`, and ≥500 → the themed 503, then rethrows everything else — and Kratos answers the settings-flow init with 401 when there is no session. `/settings` is correctly `public` (the recovery flow lands there with a live Kratos session but no app JWT), so the gate is not the fix: a 401 should redirect to `/login` with the page as `return_to`. No E2E covers an anonymous hit on a flow page that needs a session.
`Bar = 1`
### Architectural review findings (2026-07-02)
`Next ID = 39`
Prioritized. Overall verdict: architecture is sound; these are refinements.
## Items
- [ ] **MEDIUM — Add complexity/method-size static analysis to the CI gate.** Only `tsc --strict` today; a size/complexity rule would have caught the `app.ts` growth.
- [ ] **LOW — The users list offers a pencil "Edit" row action to a `users:read` holder.** The link is harmless (it opens the read-only detail page), but the label contradicts what the reader can do. Needs `canWrite` threaded into `listTable` plus a `common.view` core catalog key and an `i-eye` entry in `ICON_NAMES`.
- [ ] **MEDIUM→LOW — Add a list-page view-model helper in `src/ui/`.** Every list screen hand-rewrites the same ~40 lines bridging `parseListQuery`/`paginate` to the EJS partials; at minimum a `buildPaginationModel(page, hrefFor)` block.
- [ ] **LOW→MEDIUM — Retire `src/ui/shell-context.ts`.** `ShellModel`/`buildShellContext` has one consumer left (dashboard) and duplicates `PageChrome` on almost every field. Fold the dashboard onto `ctx.chrome` + title/breadcrumbs; keep `shellUser` as the shared primitive.
- [ ] **LOW — Fix stale doc references to removed `docs/plugin-contract.md`** in `views/index.ejs` (user-visible dashboard text; also links /scheduling as if pre-installed) and `examples/plugins/scheduling/views/shifts.ejs`.
- [ ] **LOW — Decide (once) on a `ctx.system` facade.** `@plainpages/plugin-api` exposes raw Ory client shapes, so an Ory client refactor is a major `apiVersion` bump. AGENTS.md accepts this; revisit only if external plugin authors appear.
- [ ] **LOW — README/AGENTS.md gaps:** state the intended lifetime/horizon explicitly, add a short domain glossary (host, manifest, chrome, nav fragment, permission token, system plugin, denylist…), and note the expected plugin-author population and plugin count — per-plugin dependency isolation prices N dependency trees on disk and in RSS, and that number is what says whether the trade needs revisiting (build-time dedupe for baked images stays open).
| ID | Release | Blocked by | Exempt | Item | L | E | I | R | S | Moves | Score |
|---|---|---|---|---|---|---|---|---|---|---|---|
| 26 | 0.4.21 | | | **Add a domain glossary and the project's intended horizon to the README.** | 1 | 2 | 1 | 2 | 3 | 9 by 3, code health by 2 | 11.9 |
| 8 | 0.4.21 | | | **Run the visual E2E specs at viewports from a large desktop down to a small phone.** | 2 | 1 | 1 | 2 | 4 | code health by 3 | 9.0 |
| 9 | 0.4.21 | | | **Typecheck `e2e-tests/` in the gate.** | 1 | 1 | 1 | 1 | 3 | code health by 2 | 9.0 |
| 24 | 0.4.21 | | | **Retire `src/ui/shell-context.ts` and render the dashboard from `ctx.chrome`.** | 2 | 1 | 2 | 3 | 4 | code health by 3 | 9.0 |
| 17 | 0.4.21 | | | **Remove the icons no caller uses from `ICON_NAMES` and the sprite.** | 1 | 1 | 1 | 1 | 1 | 8 by 1, code health by 1 | 8.8 |
| 21 | 0.4.21 | | | **Fail the gate on a function that exceeds a size or complexity limit.** | 3 | 1 | 1 | 3 | 5 | code health by 4 | 7.2 |
| 38 | 0.4.21 | | | **Correct the TOKENS comment at the top of `public/css/styles.css` so it no longer claims `:has()` swaps are "rock-solid" or that the theme follows the OS on every engine.** | 1 | 2 | 1 | 2 | 1 | code health by 1 | 7.2 |
| 32 | 0.4.21 | | | **Fail the gate on a stylesheet feature the browser floor lacks, unless it is guarded.** | 3 | 1 | 1 | 3 | 4 | code health by 2 | 6.0 |
| 20 | 0.4.21 | | | **Redirect an anonymous hit on a session-needing flow page to sign-in, instead of a 500.** | 2 | 2 | 2 | 4 | 3 | 2 by 4 | 4.2 |
| 4 | 0.4.21 | | | **Refuse a permission save whose baseline no longer matches Keto, so concurrent edits are not silently lost.** | 2 | 2 | 2 | 4 | 4 | 2 by 4 | 2.8 |
| 7 | 0.4.21 | | | **Confirm a successful permission save, and report a partial failure instead of hiding it.** | 2 | 2 | 2 | 4 | 4 | 2 by 4 | 2.8 |
| 22 | 0.4.21 | | | **Offer a `users:read` holder a View row action on the users list, not Edit.** | 2 | 2 | 1 | 3 | 3 | 2 by 2 | 2.1 |
| 34 | 0.4.21 | | | **Serve every stylesheet and script under a content-versioned URL with a year-long immutable `Cache-Control`, so a page view downloads none of them again.** | 4 | 2 | 3 | 7 | 6 | 8 by 6, 2 by 2 | 1.8 |
| 3 | 0.4.21 | | | **Guard the group paths against self-lockout, or accept the gap explicitly.** | 3 | 2 | 2 | 5 | 6 | 2 by 6 | 1.4 |
| 1 | 0.4.21 | | owner | **Publish every release to Docker Hub, and give the Hub page its short description.** | 3 | 3 | 2 | 6 | 5 | 1 by 3 | 1.3 |
| 13 | 0.4.21 | | | **Accept a verification code pasted with surrounding whitespace.** | 4 | 2 | 3 | 7 | 7 | 2 by 8 | 1.0 |
| 19 | 0.5.0 | | question | **Run the documented Docker commands on macOS and fix whatever misbehaves.** | 2 | 2 | 1 | 3 | 3 | 1 by 3 | 3.3 |
| 16 | 0.5.0 | | question | **Decide whether the single Keto `Resource` namespace splits into per-domain namespaces.** | 3 | 2 | 3 | 6 | 6 | 7 by 2 | 0.5 |
| 6 | 0.5.0 | | | **Warn at discovery when a route or nav node gates on a permission no plugin declares.** | 2 | 1 | 1 | 2 | 2 | 7 by 3, 9 by 3 | 8.0 |
| 23 | 0.5.0 | | | **Give list pages one view-model helper from query to pagination, instead of ~40 hand-written lines each.** | 3 | 1 | 2 | 4 | 5 | 1 by 4, 9 by 2, code health by 3 | 7.9 |
| 28 | 0.5.0 | | | **Let a plugin add entries to the profile menu through the plugin API.** | 2 | 2 | 2 | 4 | 4 | 7 by 4, 1 by 2 | 4.2 |
| 2 | 0.5.0 | | | **Rename the admin example plugin to a name that says what it administers.** | 3 | 1 | 2 | 4 | 6 | 9 by 3, code health by 2 | 2.6 |
| 5 | 0.5.0 | | | **List grants that no installed plugin declares, with a remove action.** | 2 | 2 | 2 | 4 | 4 | 2 by 3 | 2.1 |
| 14 | 0.5.0 | | | **Drop a double-clicked duplicate submit, with no client-side JavaScript.** | 4 | 2 | 3 | 7 | 7 | 2 by 7, 1 by 4 | 1.4 |
## Finnished work
## Details
### 26. Add a domain glossary and the project's intended horizon to the README.
Glossary: host, manifest, chrome, nav fragment, permission token, system plugin, denylist. Also the
expected plugin count, since per-plugin dependency isolation prices N dependency trees on disk and
in RSS.
### 24. Retire `src/ui/shell-context.ts` and render the dashboard from `ctx.chrome`.
`ShellModel`/`buildShellContext` has one consumer, the dashboard, and duplicates `PageChrome` on
almost every field. Keep `shellUser` as the shared primitive.
### 17. Remove the icons no caller uses from `ICON_NAMES` and the sprite.
`i-chart`, `i-copy` and `i-download` have no caller, and the sprite is inlined into every page.
AGENTS.md → "`ICON_NAMES` is a host-owned registry" already makes this routine cleanup.
### 21. Fail the gate on a function that exceeds a size or complexity limit.
Only `tsc --strict` runs today. A new tool is a new dependency, so justify it.
### 38. Correct the TOKENS comment at the top of `public/css/styles.css` so it no longer claims `:has()` swaps are "rock-solid" or that the theme follows the OS on every engine.
Section 1's TOKENS comment says the theme follows the OS by default and that `:has()` variable swaps
are "rock-solid". On an engine without `:has()` (Chromium below 105, Firefox below 121) every theme
rule is inert, the OS-dark rule included, so those engines stay light, as the
`@supports not selector(:has(a))` block further down says. A reader trusting the comment expects dark
mode there.
### 32. Fail the gate on a stylesheet feature the browser floor lacks, unless it is guarded.
Nothing checks README → "Two browser floors", and no E2E engine is old enough to show a break.
Lint every stylesheet, the examples' included, against Chromium 87, Safari 15 and Firefox 115, and
allow a feature only inside an `@supports` that guards it.
### 20. Redirect an anonymous hit on a session-needing flow page to sign-in, instead of a 500.
`flowPage` (`src/auth/routes.ts`) rethrows a 401, which Kratos answers to a settings-flow init with no
session. `/settings` stays `public`, since recovery lands there with a Kratos session but no app JWT;
a 401 should redirect to `/login` with the page as `return_to`. Add the missing E2E.
### 4. Refuse a permission save whose baseline no longer matches Keto, so concurrent edits are not silently lost.
Sketch: post the rendered set as a hidden baseline; when it no longer matches Keto, re-render with
"this changed while you had the page open" rather than applying.
### 7. Confirm a successful permission save, and report a partial failure instead of hiding it.
`applyGrants` loops writes then deletes with no transaction, so a Keto error midway leaves a
half-applied set behind the generic error page. The `alert alert-pos` pattern of the recovery-code
banner is available.
### 22. Offer a `users:read` holder a View row action on the users list, not Edit.
The pencil opens the read-only detail page, so it is harmless, but the label contradicts what the
reader can do. Needs `canWrite` threaded into `listTable`, a `common.view` core catalog key and an
`i-eye` icon.
### 34. Serve every stylesheet and script under a content-versioned URL with a year-long immutable `Cache-Control`, so a page view downloads none of them again.
`serveStatic` (`src/http/static.ts`) sends only `content-length` and `content-type`, so every page
view downloads `styles.css`, `auth.css` and each plugin's stylesheet again: about 50 kB on a plugin's
issue page, seconds per page over GPRS. Firefox can paint the page unstyled meanwhile, when an
extension forces layout early.
- A versioned URL carries a hash of the file's content, as `?v=<hash>` or in the path, and answers
`Cache-Control: public, max-age=31536000, immutable`. A release changes the hash, so it still
reaches every browser at once.
- An unversioned `/public/` URL answers a short `max-age` with revalidation: `ETag` or
`Last-Modified`, and 304 on `If-None-Match` or `If-Modified-Since`, for GET and HEAD.
- Every `/public/` href the host renders comes out versioned: the shell's `<link>`s and favicon,
the error pages (`views/403.ejs`, `404`, `500`, `503`, `error`), `views/oauth-consent.ejs`, and
the `styles` hrefs a plugin passes to `views/partials/shell.ejs`.
- **Recommended: the host versions a plugin's hrefs itself.** A plugin names its asset once, as
today, and the shell adds the version to any `/public/` href. A helper would make every plugin
author remember to call it, and adds to the barrel.
- **Contract:** `views/partials/*.ejs` are contract surface (AGENTS.md → Rules). Host-side
versioning keeps the `styles` option and its meaning, and an unversioned href keeps working, so
no minor bump of `HOST_API_VERSION` is needed and 0.4.21 holds. A helper exported through
`@plainpages/plugin-api` would add to the contract and need 0.5.0.
- Pages stay `private, no-store` (`src/http/app.ts`); the static branch returns before that header
is set.
Tests first, with expected values from this item: `src/http` unit tests for both header sets and the
304s, and an E2E test that a repeat page view fetches no stylesheet again. State the choice once in a
comment in `static.ts`, and in a sentence where the README tells plugin authors and operators about
assets. Requested by plainpages-plugin-fastjira, whose own item waits on the release that ships this.
### 3. Guard the group paths against self-lockout, or accept the gap explicitly.
The self-revoke guard covers only your own direct grants on the Users screen. Unticking a permission
on a group you belong to, leaving that group, or deleting it can still strip your own access with no
warning. AGENTS.md records this as a known gap. The robust fix is a "last effective holder" check,
which needs a reverse Keto query.
### 1. Publish every release to Docker Hub, and give the Hub page its short description.
Hub's newest tag is 0.4.0, while git has v0.4.1 onwards, and the overview still says 0.4.0. No
`release.yml` run exists for the tags auto-release pushes as `renovate-bot`; find out why Gitea
doesn't fire `push: tags` for them, fix it, and backfill the missing tags. fejkdata did this
(`../fejkdata`, commits `51d924a` and `34c8234`), and two parts port directly: a one-line
description riding the existing PATCH as its `description` field, and a unit test that the
description opens a README sentence and every command the overview shows appears in the README.
fejkdata also syncs the page on any push to `main`, with its own token in an environment only `main`
may use; decide whether that fits here, where `{{VERSION}}` must render a released tag.
### 13. Accept a verification code pasted with surrounding whitespace.
A pasted code fails the browser's `pattern` check. No zero-JS fix exists: `pattern`
cannot transform, so loosening it only lets the untrimmed value through to Kratos. **Check first:**
`kratos-admin.ts` returns a `recovery_link` alongside the code; if the stock courier mail can carry
one, a template change fixes this with no host code. Otherwise proxy the flow POST through the host:
`submitFlow` (`src/auth/kratos-public.ts`) already relays cookies and normalises a 422
`redirect_browser_to`, and has no callers outside tests. CSRF improves, since the POST becomes
same-origin and `form-action 'self'` can join the CSP. Risks: one `flow.ui.action` serves all five
flows, so this rewrites the sign-in path; and Go's nosurf checks Referer only over https, which no
test covers. Code entry has no E2E coverage today.
### 19. Run the documented Docker commands on macOS and fix whatever misbehaves.
Needs a Mac. Two suspects, both from `--user "$(id -u):$(id -g)"`: a macOS `id -g` is `20`, which is
`dialout` inside the noble image, and Docker Desktop remaps bind-mount ownership in its VM.
### 16. Decide whether the single Keto `Resource` namespace splits into per-domain namespaces.
Ory's own examples model `Shift`, `Document` and so on. One global `Resource` bucket is the
project's "no catch-all names" rule applied to namespaces.
### 6. Warn at discovery when a route or nav node gates on a permission no plugin declares.
Declaring stays optional on purpose, but the picker is built from declarations only, so such a route
is ungrantable from the GUI and answers a permanent 403. A warning, not an error.
### 23. Give list pages one view-model helper from query to pagination, instead of ~40 hand-written lines each.
Every list screen bridges `parseListQuery`/`paginate` to the partials by hand. At minimum a
`buildPaginationModel(page, hrefFor)`. Exported through the barrel, so it is contract surface.
### 28. Let a plugin add entries to the profile menu through the plugin API.
The profile dropdown stays a dropdown: Sign out is its only entry today, and it will gain more.
`views/partials/shell.ejs` hand-rolls it (AGENTS.md → UI, the dropdown rule), so the entries need a
manifest field that discovery checks, gated like a nav node.
### 2. Rename the admin example plugin to a name that says what it administers.
Candidates: `auth-admin`, `users-groups-admin`. The id is also the mount path, so the compose files,
E2E stacks and README move with it.
### 5. List grants that no installed plugin declares, with a remove action.
`grantDiff` never revokes an undeclared name, and nothing shows one either, so it can't be audited or
cleaned, and reinstalling its plugin silently reactivates access nobody remembers granting.
### 14. Drop a double-clicked duplicate submit, with no client-side JavaScript.
A second identical POST is expected from a non-technical user, and today creates two
users, mints two recovery codes, or registers two OAuth2 clients. It must not break a legitimately
repeatable action. Sketch: a CSS-only affordance so the second click has nothing to hit, plus the
host recognising a duplicate (same session, route and payload within a short window), logging and
dropping it. Open: what identifies "the same submission" (a one-time token minted into each form
beats hashing the payload), the window, where the record lives given the host is stateless, and how
a plugin declares a route repeatable.
- [x] Configure a plugin at install time: the manifest declares `settings`, the operator sets one `PLUGIN_SETTING_<ID>_<KEY>` variable per key, and the resolved values arrive on `onBoot` typed from the declaration. Read-only admin screen at `/admin/plugin-settings`.
- [x] Give a plugin persistent storage: `storage: true` provisions a Postgres database + login role named `plugin_<id>`, credentials arrive on `onBoot`, passwords are derived from `PLUGIN_DB_SECRET` rather than stored.
- [x] Refuse a stray `package.json`/`node_modules` in `config/` by name, as plugin folders already are.
- [x] Let Renovate reach the example plugins' manifests (`ignorePaths` overrides `config:recommended`).
- [x] The seeded admin is granted each permission once — `seedPermissions` dedupes and the grant PUT is idempotent.
- [x] Run the E2E runner as the invoking user so its artifacts aren't root-owned.
- [x] Install node_modules above `WORKDIR /app` so no mount leaves a root-owned dir in the checkout.
- [x] Enforce `<resource>:<action>` permission names at discovery; split `admin` per screen.
- [x] Make the declared-permission catalog the fixed list; delete the Permissions screen, move granting onto Users and Groups.
- [x] Fail a Playwright test on any browser console warning, error or uncaught exception, in every engine.
- [x] Skip the CI gate when only markdown changed.
- [x] Replace the `<details>` popup menus with the Popover API so an outside click dismisses them.
- [x] Organize the files in src into folders.
- [x] Move docs/plugin-contract.md into README.md and remove the docs folder.
- [x] Move the scheduling example out of `plugins/` into `examples/`.
- [x] Make `config/` an empty drop-in mount with the defaults as fallback.
- [x] Turn the built-in admin pages into a drop-in example plugin.
- [x] CI/CD — test on push to any branch except main.
- [x] CI/CD — require a PR to main, gated on a green build, fast-forward-only.
- [x] CI/CD — force-push mirror to GitHub after every merge to main.
- [x] CI/CD — build and push the app image, tagged with the commit hash, as part of the gate.
- [x] CI/CD — re-tag the hash image to semver on a `vX.Y.Z` tag.
- [x] CI/CD — sync released tags to Docker Hub.
- [x] Write README-dockerhub.md for the Docker Hub overview.
- [x] CI/CD — set up the Renovate bot.
- [x] CI/CD — give Renovate a read-only `GITHUB_COM_TOKEN` so github.com lookups aren't rate-limited.
- [x] CI/CD — auto-release on Renovate updates, versioned from the `Release-Bump:` trailer.
- [x] Add an e2e test for the admin plugin's OAuth2-clients (Hydra) screen.
- [x] Document the auth security model in the README.
- [x] Add i18n support.
- [x] Settle the identity-vs-user vocabulary.
- [x] Use one uniform verb per action in the English UI (sign in / sign out / create account).
- [x] Remove the dead "Profile" link from the sidebar profile menu.
- [x] Remove the unspecified "Settings"/"Preferences" cog from the sidebar footer.
- [x] **HIGH — Split `handleRequest` in `src/http/app.ts`** — extract the built-in endpoints into named handlers on an internal route table.
+2 -2
View File
@@ -1,8 +1,8 @@
<%#
Recursive nav tree. Each node: { label, href?, icon?, count?, current?, open?, children? }.
Shape is orthogonal: header (children → disclosure toggle + nested <ul>) vs leaf (spacer),
and clickable (href → <a>) vs static (<span>). Where :has() works, the CSS reveals children
only when the sibling .nav-disc is [open]; elsewhere it shows them all. `root` picks the outer class.
and clickable (href → <a>) vs static (<span>). The CSS reveals children only when the
sibling .nav-disc is [open]. `root` picks the outer class.
%><%
const nodes = locals.nodes || [];
const root = locals.root !== false;