Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 33fb6996c6 |
@@ -19,4 +19,4 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
docker run --rm -v "$PWD:/repo" -w /repo \
|
docker run --rm -v "$PWD:/repo" -w /repo \
|
||||||
-e REGISTRY_TOKEN -e REGISTRY_USER -e REPO_TOKEN -e REPOSITORY -e SERVER_URL \
|
-e REGISTRY_TOKEN -e REGISTRY_USER -e REPO_TOKEN -e REPOSITORY -e SERVER_URL \
|
||||||
node:26.13.0-alpine3.24 node registry-cleanup/cleanup.ts
|
node:24.22.1-alpine3.24 node registry-cleanup/cleanup.ts
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ jobs:
|
|||||||
GIT_TAG: ${{ github.ref_name }}
|
GIT_TAG: ${{ github.ref_name }}
|
||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
docker run --rm -v "$PWD:/repo" -w /repo node:26.13.0-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
|
node release-tooling/contract-version.ts "$GIT_TAG" src/plugin-host/plugin.ts
|
||||||
- name: Promote the commit-hash image to semver + latest
|
- name: Promote the commit-hash image to semver + latest
|
||||||
env:
|
env:
|
||||||
@@ -98,9 +98,9 @@ jobs:
|
|||||||
VERSION=${INPUT_VERSION:-${GIT_TAG#v}}
|
VERSION=${INPUT_VERSION:-${GIT_TAG#v}}
|
||||||
VERSION=${VERSION#v}
|
VERSION=${VERSION#v}
|
||||||
# An empty dispatch input falls back to the branch name, so gate this like a tag.
|
# 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:26.13.0-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
|
node release-tooling/contract-version.ts "$VERSION" src/plugin-host/plugin.ts
|
||||||
docker run --rm -v "$PWD:/repo" -w /repo \
|
docker run --rm -v "$PWD:/repo" -w /repo \
|
||||||
-e DOCKERHUB_REPO -e DOCKERHUB_TOKEN -e DOCKERHUB_USER \
|
-e DOCKERHUB_REPO -e DOCKERHUB_TOKEN -e DOCKERHUB_USER \
|
||||||
node:26.13.0-alpine3.24 \
|
node:24.22.1-alpine3.24 \
|
||||||
node release-tooling/dockerhub-overview.ts "$VERSION"
|
node release-tooling/dockerhub-overview.ts "$VERSION"
|
||||||
|
|||||||
@@ -58,11 +58,11 @@ jobs:
|
|||||||
if [ -z "$BUMPS" ]; then
|
if [ -z "$BUMPS" ]; then
|
||||||
echo "Renovate commits since ${LATEST}, but none carry Release-Bump — nothing reached a running Plainpages; skipping"; exit 0
|
echo "Renovate commits since ${LATEST}, but none carry Release-Bump — nothing reached a running Plainpages; skipping"; exit 0
|
||||||
fi
|
fi
|
||||||
NEXT=$(docker run --rm -v "$PWD:/repo" -w /repo node:26.13.0-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)
|
node release-tooling/next-version.ts "$LATEST" $BUMPS)
|
||||||
# Read the constant off origin/main, not the checkout, which lags the merges this run made.
|
# 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 \
|
git show origin/main:src/plugin-host/plugin.ts \
|
||||||
| docker run -i --rm -v "$PWD:/repo" -w /repo node:26.13.0-alpine3.24 \
|
| docker run -i --rm -v "$PWD:/repo" -w /repo node:24.22.1-alpine3.24 \
|
||||||
node release-tooling/contract-version.ts "$NEXT" -
|
node release-tooling/contract-version.ts "$NEXT" -
|
||||||
echo "Releasing $LATEST -> $NEXT"
|
echo "Releasing $LATEST -> $NEXT"
|
||||||
git tag "$NEXT" origin/main
|
git tag "$NEXT" origin/main
|
||||||
|
|||||||
@@ -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
|
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
|
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.
|
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
|
- **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
|
concept. The single exception is the `Identity` DTO in `src/auth/kratos-admin.ts`, which mirrors
|
||||||
Kratos' wire shape — don't rename it.
|
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
|
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.
|
copy-pasteable; deeper detail lives in its own section, linked.
|
||||||
2. **Returning developer (rest).** A **Contents** ToC right after Quick start, then sections ordered
|
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 →
|
& permissions → Building plugins → menu/blocks/interactivity → Configuration → Auth → Email →
|
||||||
Architecture → Testing → Production → Observability → JWT-rotation runbook → Project-layout file
|
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 &
|
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
|
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
|
reader of *this* codebase could not infer: a surprising why, a footgun, an invariant, an external
|
||||||
constraint. See [Prose discipline](#prose-discipline).
|
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?
|
|
||||||
|
|||||||
@@ -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
|
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.
|
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
|
## 0.4.0
|
||||||
|
|
||||||
**Breaking.** Set `apiVersion: "0.4.0"`. The app shell no longer bounds the content column, so a page
|
**Breaking.** Set `apiVersion: "0.4.0"`. The app shell no longer bounds the content column, so a page
|
||||||
|
|||||||
+1
-1
@@ -1,5 +1,5 @@
|
|||||||
# Node 24 runs TypeScript directly (type stripping) — no build step. Pinned exact tag.
|
# Node 24 runs TypeScript directly (type stripping) — no build step. Pinned exact tag.
|
||||||
FROM node:26.13.0-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
|
# 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).
|
# instead leaves a root-owned dir in the checkout (the daemon creates mount destinations as root).
|
||||||
|
|||||||
@@ -70,8 +70,6 @@ From here, render real pages against the app shell and fetch upstream data — s
|
|||||||
|
|
||||||
## Contents
|
## Contents
|
||||||
|
|
||||||
- [Goals](#goals)
|
|
||||||
- [Audience](#audience)
|
|
||||||
- [Overview](#overview)
|
- [Overview](#overview)
|
||||||
- [Users, groups & permissions](#users-groups--permissions)
|
- [Users, groups & permissions](#users-groups--permissions)
|
||||||
- [naming a permission](#naming-a-permission)
|
- [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)
|
- [Project layout](#project-layout)
|
||||||
- [Extending the core](#extending-the-core)
|
- [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
|
## Overview
|
||||||
|
|
||||||
Plainpages gives you the boring-but-hard parts of a web app — a design system, a menu,
|
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
|
- **You add:** everything else domain-specific, as **plugins** — built from the same building
|
||||||
blocks the admin plugin uses.
|
blocks the admin plugin uses.
|
||||||
|
|
||||||
Identity, sessions, SSO, OAuth2 and permission checks are delegated to **Ory** sidecar
|
**Priorities:** simplicity, few dependencies, strict TypeScript, no build step, Docker-only,
|
||||||
services. The shape doesn't change as it grows: every plugin is the same self-contained folder,
|
environment-agnostic (no `NODE_ENV` — every behaviour is an explicit config toggle). Identity,
|
||||||
the hot path is the same I/O-free JWT check, and the host stores nothing of its own.
|
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,
|
**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
|
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
|
(`?q=…&status=…&sort=…&page=…`) lives **in the URL**, so a view is bookmarkable and shareable; the
|
||||||
URL is the only state the UI keeps.
|
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
|
**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.
|
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
|
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
|
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.
|
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
|
**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.
|
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
|
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`.
|
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
|
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
|
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
|
by `localPath` in `src/http/safe-url.ts`, so a crafted value can't turn login completion into an open
|
||||||
|
|||||||
@@ -49,7 +49,7 @@ services:
|
|||||||
# backs it (PLUGIN_SETTING_SCHEDULING_UPSTREAM above points here). Stand-in for the customer's real service —
|
# 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.
|
# stdlib-only, in-memory, no auth. Prod points PLUGIN_SETTING_SCHEDULING_UPSTREAM at the real backend instead.
|
||||||
shifts-upstream:
|
shifts-upstream:
|
||||||
image: node:26.13.0-alpine3.24
|
image: node:24.22.1-alpine3.24
|
||||||
command: node /srv/server.ts
|
command: node /srv/server.ts
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
volumes:
|
volumes:
|
||||||
|
|||||||
@@ -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
|
|
||||||
@@ -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
|
|
||||||
@@ -53,7 +53,7 @@ services:
|
|||||||
|
|
||||||
# The reference plugin's upstream (examples/shifts-upstream) so /scheduling/shifts shows real rows.
|
# The reference plugin's upstream (examples/shifts-upstream) so /scheduling/shifts shows real rows.
|
||||||
shifts-upstream:
|
shifts-upstream:
|
||||||
image: node:26.13.0-alpine3.24
|
image: node:24.22.1-alpine3.24
|
||||||
command: ["node", "/server.ts"]
|
command: ["node", "/server.ts"]
|
||||||
volumes:
|
volumes:
|
||||||
- ./examples/shifts-upstream/server.ts:/server.ts:ro
|
- ./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
|
# 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.
|
# verifies via its jwks. Reachable as the same host (mock-oidc:9000) by both the browser and Kratos.
|
||||||
mock-oidc:
|
mock-oidc:
|
||||||
image: node:26.13.0-alpine3.24
|
image: node:24.22.1-alpine3.24
|
||||||
command: ["node", "/mock-oidc.ts"]
|
command: ["node", "/mock-oidc.ts"]
|
||||||
environment:
|
environment:
|
||||||
ISSUER: http://mock-oidc:9000
|
ISSUER: http://mock-oidc:9000
|
||||||
@@ -81,7 +81,7 @@ services:
|
|||||||
|
|
||||||
# Same-origin gateway: Kratos-owned paths → kratos, everything else → web (e2e-tests/proxy.ts).
|
# Same-origin gateway: Kratos-owned paths → kratos, everything else → web (e2e-tests/proxy.ts).
|
||||||
proxy:
|
proxy:
|
||||||
image: node:26.13.0-alpine3.24
|
image: node:24.22.1-alpine3.24
|
||||||
command: ["node", "/proxy.ts"]
|
command: ["node", "/proxy.ts"]
|
||||||
depends_on:
|
depends_on:
|
||||||
web:
|
web:
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ export default defineConfig({
|
|||||||
testDir: ".",
|
testDir: ".",
|
||||||
outputDir: "artifacts/test-output",
|
outputDir: "artifacts/test-output",
|
||||||
fullyParallel: true,
|
fullyParallel: true,
|
||||||
workers: 4, // Playwright's default is half the cores, so a run's parallelism would depend on the machine
|
|
||||||
forbidOnly: true,
|
forbidOnly: true,
|
||||||
reporter: [["list"], ["html", { open: "never", outputFolder: "artifacts/report" }]],
|
reporter: [["list"], ["html", { open: "never", outputFolder: "artifacts/report" }]],
|
||||||
use: {
|
use: {
|
||||||
|
|||||||
@@ -148,21 +148,6 @@ test("mobile layout hides the sidebar off-canvas behind the hamburger", async ({
|
|||||||
expect(offCanvas).toBe(true);
|
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 }) => {
|
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");
|
await page.goto("/dashboard");
|
||||||
// The page issues a CSRF cookie and embeds the same token in the Sign-out form (double-submit).
|
// 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");
|
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 }) => {
|
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
|
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");
|
const res = await page.goto("/no-such-page");
|
||||||
|
|||||||
@@ -4,9 +4,3 @@
|
|||||||
.scheduling-page .table-wrap {
|
.scheduling-page .table-wrap {
|
||||||
margin-top: var(--space-3, 0.75rem);
|
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);
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
Scheduling · Shifts list (reference plugin). The handler fetched the rows from the upstream
|
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
|
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,
|
(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?
|
Data: chrome, title, breadcrumbs, count, filterBar, table, canWrite, newHref, error?
|
||||||
%><%
|
%><%
|
||||||
const navHtml = include("partials/nav-tree", { nodes: chrome.nav });
|
const navHtml = include("partials/nav-tree", { nodes: chrome.nav });
|
||||||
|
|||||||
Generated
+16
-16
@@ -7,13 +7,13 @@
|
|||||||
"name": "plainpages",
|
"name": "plainpages",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@larvit/log": "2.3.0",
|
"@larvit/log": "2.3.0",
|
||||||
"ejs": "6.0.1",
|
"ejs": "7.0.2",
|
||||||
"lucide-static": "1.64.0",
|
"lucide-static": "1.62.0",
|
||||||
"postgres": "3.4.9"
|
"postgres": "3.4.9"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/ejs": "3.1.5",
|
"@types/ejs": "3.1.5",
|
||||||
"@types/node": "26.6.6",
|
"@types/node": "24.19.2",
|
||||||
"typescript": "7.0.2"
|
"typescript": "7.0.2"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
@@ -37,13 +37,13 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/@types/node": {
|
"node_modules/@types/node": {
|
||||||
"version": "26.6.6",
|
"version": "24.19.2",
|
||||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-26.6.6.tgz",
|
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.19.2.tgz",
|
||||||
"integrity": "sha512-MzQj+8Kx3XI40OmUAE6Qoh58ZMNstNNMwn2nfd3qWsfe7nDNSqJNzes4njhz6AHwrSHOffSLwXU6yb474Q4aog==",
|
"integrity": "sha512-93d49wCWJkCyN87MxLpNN6KXPCwFw7bVUg79mGUV1OS40JGWwVCr8n61DXCSqz7CV6/VfPkb7xec7uP2M+QHWQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"undici-types": "~8.9.0"
|
"undici-types": ">=7.24.0 <7.24.7"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@typescript/typescript-aix-ppc64": {
|
"node_modules/@typescript/typescript-aix-ppc64": {
|
||||||
@@ -387,9 +387,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/ejs": {
|
"node_modules/ejs": {
|
||||||
"version": "6.0.1",
|
"version": "7.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/ejs/-/ejs-6.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/ejs/-/ejs-7.0.2.tgz",
|
||||||
"integrity": "sha512-UaaM14yby8U3k02ihS1Bmj5Kz2d7CCQM1scxpgs4Mhkq8F1wR2gl3+Ts4h5Ne4Mnt7M9m4Dw7jsuMr3+xO4vZA==",
|
"integrity": "sha512-LcDSEP6Oclb9sTiSyG0MpG0RuKvLDRxCOOt+2sIk+kdk1rMkFU8SRnT0qaNt5zvU0LOV1YXXOvIoZL/sKwabvw==",
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"bin": {
|
"bin": {
|
||||||
"ejs": "bin/cli.js"
|
"ejs": "bin/cli.js"
|
||||||
@@ -399,9 +399,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/lucide-static": {
|
"node_modules/lucide-static": {
|
||||||
"version": "1.64.0",
|
"version": "1.62.0",
|
||||||
"resolved": "https://registry.npmjs.org/lucide-static/-/lucide-static-1.64.0.tgz",
|
"resolved": "https://registry.npmjs.org/lucide-static/-/lucide-static-1.62.0.tgz",
|
||||||
"integrity": "sha512-UxWmMteOXbKHI5E4f2qVtxCAAsI9gsInYK1lNw0nrOA0w4FkJeOVZ+UOV6aGA2377FZe3qyiFYq3lqjBKZ6OuA==",
|
"integrity": "sha512-WBZA/m2PvkFpMGHDtsBck5P2A9G4ehLlYu4GwwICcmEm1BvYVgj00lCDxKRWLlbMlOibLaiwdQ88G5pHkSWgwA==",
|
||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
"node_modules/postgres": {
|
"node_modules/postgres": {
|
||||||
@@ -453,9 +453,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/undici-types": {
|
"node_modules/undici-types": {
|
||||||
"version": "8.9.0",
|
"version": "7.24.6",
|
||||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.9.0.tgz",
|
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.24.6.tgz",
|
||||||
"integrity": "sha512-KTDyRTYX8sWmKXAikPHHSyc63CRPETMctyjKFupcC6OBLXT3xsN0e9aF7m+mIXutFWpUXuedtowG7iLOzp0kQg==",
|
"integrity": "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
}
|
}
|
||||||
|
|||||||
+3
-3
@@ -17,13 +17,13 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@larvit/log": "2.3.0",
|
"@larvit/log": "2.3.0",
|
||||||
"ejs": "6.0.1",
|
"ejs": "7.0.2",
|
||||||
"lucide-static": "1.64.0",
|
"lucide-static": "1.62.0",
|
||||||
"postgres": "3.4.9"
|
"postgres": "3.4.9"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/ejs": "3.1.5",
|
"@types/ejs": "3.1.5",
|
||||||
"@types/node": "26.6.6",
|
"@types/node": "24.19.2",
|
||||||
"typescript": "7.0.2"
|
"typescript": "7.0.2"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 {
|
.auth-stage {
|
||||||
min-height: 100dvh;
|
min-height: 100dvh;
|
||||||
display: grid; place-items: center;
|
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 { 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); }
|
.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 */
|
/* card */
|
||||||
.auth-card {
|
.auth-card {
|
||||||
background: var(--surface);
|
background: var(--surface);
|
||||||
@@ -108,6 +123,8 @@
|
|||||||
|
|
||||||
.field-hint { font-size: var(--fz-xs); color: var(--text-faint); }
|
.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) */
|
/* full-width primary button (extends .btn / .btn-primary from styles.css) */
|
||||||
.btn-block { width: 100%; justify-content: center; height: 40px; font-size: var(--fz); }
|
.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 { color: var(--accent); }
|
||||||
.auth-aside a:hover { text-decoration: underline; }
|
.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 {
|
.field-error {
|
||||||
display: none; align-items: center; gap: 6px; margin: 0;
|
display: none; align-items: center; gap: 6px; margin: 0;
|
||||||
font-size: var(--fz-xs); color: var(--neg);
|
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.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 > .ico { flex: 0 0 auto; color: var(--neg); }
|
||||||
.field-error a { color: var(--neg); font-weight: 600; text-decoration: underline; }
|
.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
@@ -2,6 +2,8 @@
|
|||||||
App Shell Template — pure HTML/CSS, no JavaScript
|
App Shell Template — pure HTML/CSS, no JavaScript
|
||||||
-------------------------------------------------------------
|
-------------------------------------------------------------
|
||||||
Architecture
|
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,
|
- Everything is a reusable "block": .nav-item, .filter,
|
||||||
.chip, .badge, .btn, .menu, .table — combine freely.
|
.chip, .badge, .btn, .menu, .table — combine freely.
|
||||||
============================================================= */
|
============================================================= */
|
||||||
@@ -19,35 +21,33 @@
|
|||||||
widgets (date picker, scrollbars, selects).
|
widgets (date picker, scrollbars, selects).
|
||||||
------------------------------------------------------------- */
|
------------------------------------------------------------- */
|
||||||
:root {
|
: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 ---- */
|
/* ---- raw LIGHT palette ---- */
|
||||||
--l-bg:#f3f3f4; --l-surface:#ffffff; --l-surface-2:#f6f6f7; --l-surface-3:#fbfbfc;
|
--l-bg:#f3f3f4; --l-surface:#ffffff; --l-surface-2:#f6f6f7; --l-surface-3:#fbfbfc;
|
||||||
--l-border:#e5e5e8; --l-border-2:#d6d6da;
|
--l-border:#e5e5e8; --l-border-2:#d6d6da;
|
||||||
--l-text:#18181b; --l-text-muted:#6b6b73; --l-text-faint:#9a9aa2;
|
--l-text:#18181b; --l-text-muted:#6b6b73; --l-text-faint:#9a9aa2;
|
||||||
--l-accent:#3669ac; --l-accent-bg:#e5f3ff;
|
--l-accent:oklch(0.52 0.12 256); --l-accent-bg:oklch(0.96 0.03 256);
|
||||||
--l-accent-bd:#a7c7f2; --l-focus:#2a68b7; --l-on-accent:#ffffff;
|
--l-accent-bd:oklch(0.82 0.07 256); --l-focus:oklch(0.52 0.14 256);
|
||||||
--l-pos:#1f7a3d; --l-pos-bg:#e1f5e4; --l-pos-bd:#a8d5b0;
|
--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:#c53637; --l-neg-bg:#ffebe8; --l-neg-bd:#febab4;
|
--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:#8f5d00; --l-warn-bg:#fef0d4; --l-warn-bd:#e3c78e;
|
--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:#1d6aa5;--l-info-bg:#e2f5ff;--l-info-bd:#a2cdf2;
|
--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 ---- */
|
/* ---- raw DARK palette ---- */
|
||||||
--d-bg:#0d0d0f; --d-surface:#161619; --d-surface-2:#1d1d22; --d-surface-3:#1a1a1e;
|
--d-bg:#0d0d0f; --d-surface:#161619; --d-surface-2:#1d1d22; --d-surface-3:#1a1a1e;
|
||||||
--d-border:#2a2a31; --d-border-2:#3a3a43;
|
--d-border:#2a2a31; --d-border-2:#3a3a43;
|
||||||
--d-text:#ededf0; --d-text-muted:#9b9ba6; --d-text-faint:#6a6a74;
|
--d-text:#ededf0; --d-text-muted:#9b9ba6; --d-text-faint:#6a6a74;
|
||||||
--d-accent:#6ba0e8; --d-accent-bg:#1d3351;
|
--d-accent:oklch(0.70 0.12 256); --d-accent-bg:oklch(0.32 0.06 256);
|
||||||
--d-accent-bd:#3f5f8a; --d-focus:#72adfb; --d-on-accent:#0d0d0f;
|
--d-accent-bd:oklch(0.48 0.08 256); --d-focus:oklch(0.74 0.13 256);
|
||||||
--d-pos:#62c37a; --d-pos-bg:#1a3520; --d-pos-bd:#376040;
|
--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:#ff7e76; --d-neg-bg:#4b1d1b; --d-neg-bd:#8a423d;
|
--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:#e4b750; --d-warn-bg:#3e2d10; --d-warn-bd:#72592c;
|
--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:#65b2f1;--d-info-bg:#12334c;--d-info-bd:#305f85;
|
--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 ---- */
|
/* ---- active mapping: default = LIGHT ---- */
|
||||||
--bg:var(--l-bg); --surface:var(--l-surface); --surface-2:var(--l-surface-2); --surface-3:var(--l-surface-3);
|
--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);
|
--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);
|
--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);
|
--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);
|
--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);
|
--warn:var(--l-warn); --warn-bg:var(--l-warn-bg); --warn-bd:var(--l-warn-bd);
|
||||||
@@ -58,7 +58,7 @@
|
|||||||
--radius: 5px;
|
--radius: 5px;
|
||||||
--nav-w: 264px;
|
--nav-w: 264px;
|
||||||
--row-h: 34px;
|
--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: 13px;
|
||||||
--fz-sm: 12px;
|
--fz-sm: 12px;
|
||||||
--fz-xs: 11px;
|
--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);
|
--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);
|
--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);
|
--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);
|
--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);
|
--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);
|
--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);
|
--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);
|
--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);
|
--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);
|
--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);
|
--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);
|
--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);
|
--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);
|
--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);
|
--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);
|
--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);
|
--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);
|
--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 {
|
.brand-mark {
|
||||||
width: 22px; height: 22px; border-radius: 5px; flex: 0 0 auto;
|
width: 22px; height: 22px; border-radius: 5px; flex: 0 0 auto;
|
||||||
background: var(--accent);
|
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-logo { width: 22px; height: 22px; border-radius: 5px; flex: 0 0 auto; object-fit: contain; }
|
||||||
.brand-name { font-weight: 650; letter-spacing: -.01em; }
|
.brand-name { font-weight: 650; letter-spacing: -.01em; }
|
||||||
.brand-sub { color: var(--text-faint); font-size: var(--fz-xs); margin-left: auto; }
|
.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.
|
• CLICKABLE = label is <a href> → navigates on click.
|
||||||
STATIC = label is <span> → not a navigation target.
|
STATIC = label is <span> → not a navigation target.
|
||||||
So "header" and "clickable" are independent: any combination works.
|
So "header" and "clickable" are independent: any combination works.
|
||||||
Children sit in a sibling <ul class="nav-children">. Where :has() works, a
|
Children sit in a sibling <ul class="nav-children">, revealed by
|
||||||
branch is folded unless THIS row's own <details open> (scoped with > so a node
|
:has() on THIS row's own <details open> (scoped with > so a node
|
||||||
only ever controls its own subtree — infinite depth, no JS). Without :has()
|
only ever controls its own subtree — infinite depth, no JS).
|
||||||
every branch stays open, since nothing could reopen one.
|
|
||||||
============================================================ */
|
============================================================ */
|
||||||
.nav-row { display: flex; align-items: center; gap: 2px; }
|
.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); }
|
.nav-self[aria-current="page"] .ico { color: var(--accent); }
|
||||||
|
|
||||||
/* children + infinite indentation with a guide line per level */
|
/* children + infinite indentation with a guide line per level */
|
||||||
@supports selector(:has(a)) {
|
.nav-node > .nav-children { display: none; }
|
||||||
.nav-node > .nav-children { display: none; }
|
.nav-node:has(> .nav-row > .nav-disc[open]) > .nav-children { display: block; }
|
||||||
.nav-node:has(> .nav-row > .nav-disc[open]) > .nav-children { display: block; }
|
|
||||||
}
|
|
||||||
.nav-children { padding-left: 22px; position: relative; }
|
.nav-children { padding-left: 22px; position: relative; }
|
||||||
.nav-children::before {
|
.nav-children::before {
|
||||||
content: ""; position: absolute; left: 10px; top: 2px; bottom: 2px;
|
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;
|
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; }
|
.footer-actions { display: flex; align-items: center; gap: 4px; }
|
||||||
.profile {
|
.profile {
|
||||||
display: flex; align-items: center; gap: 9px; flex: 1 1 auto;
|
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:hover { background: var(--surface-2); }
|
||||||
.btn-primary { background: var(--accent); border-color: var(--accent);
|
.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-primary:hover { filter: brightness(1.06); }
|
||||||
.btn-ghost { background: transparent; border-color: transparent; }
|
.btn-ghost { background: transparent; border-color: transparent; }
|
||||||
.btn-ghost:hover { background: var(--surface-2); }
|
.btn-ghost:hover { background: var(--surface-2); }
|
||||||
@@ -346,7 +336,7 @@ span.nav-self { cursor: default; } /* static / non-clickable */
|
|||||||
.topbar {
|
.topbar {
|
||||||
flex: 0 0 auto; height: 48px;
|
flex: 0 0 auto; height: 48px;
|
||||||
display: flex; align-items: center; gap: 12px;
|
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);
|
background: var(--surface);
|
||||||
}
|
}
|
||||||
.hamburger { display: none; } /* shown only on narrow */
|
.hamburger { display: none; } /* shown only on narrow */
|
||||||
@@ -362,7 +352,7 @@ span.nav-self { cursor: default; } /* static / non-clickable */
|
|||||||
.filters {
|
.filters {
|
||||||
flex: 0 0 auto; background: var(--surface);
|
flex: 0 0 auto; background: var(--surface);
|
||||||
border-bottom: 1px solid var(--border);
|
border-bottom: 1px solid var(--border);
|
||||||
padding: 10px var(--pad-x);
|
padding: 10px 16px;
|
||||||
display: flex; flex-direction: column; gap: 9px;
|
display: flex; flex-direction: column; gap: 9px;
|
||||||
}
|
}
|
||||||
.filter-row {
|
.filter-row {
|
||||||
@@ -594,7 +584,7 @@ table.table {
|
|||||||
.col-num { text-align: right; }
|
.col-num { text-align: right; }
|
||||||
.cell-strong { font-weight: 550; }
|
.cell-strong { font-weight: 550; }
|
||||||
.cell-muted { color: var(--text-muted); }
|
.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;
|
.cell-mono { font-variant-numeric: tabular-nums;
|
||||||
font-family: ui-monospace, "SF Mono", Menlo, monospace; font-size: var(--fz-sm); }
|
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 {
|
.pager {
|
||||||
flex: 0 0 auto;
|
flex: 0 0 auto;
|
||||||
display: flex; align-items: center; gap: 12px;
|
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);
|
background: var(--surface); font-size: var(--fz-sm); color: var(--text-muted);
|
||||||
}
|
}
|
||||||
.pager .spacer { flex: 1 1 auto; }
|
.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:hover { background: var(--surface-2); color: var(--text); }
|
||||||
.page-btn[aria-current="page"] { background: var(--accent); border-color: var(--accent);
|
.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; }
|
.page-btn:disabled { opacity: .4; cursor: not-allowed; }
|
||||||
|
|
||||||
/* ---------- 11. RESPONSIVE / HAMBURGER --------------------- */
|
/* ---------- 11. RESPONSIVE / HAMBURGER --------------------- */
|
||||||
.scrim { display: none; }
|
.scrim { display: none; }
|
||||||
|
|
||||||
@media (max-width: 860px) {
|
@media (max-width: 860px) {
|
||||||
.app { grid-template-columns: minmax(0, 1fr); grid-template-rows: auto 1fr; }
|
.app { grid-template-columns: minmax(0, 1fr); }
|
||||||
.content { grid-column: 1; grid-row: 2; }
|
.sidebar {
|
||||||
.search { min-width: 150px; flex: 1 1 auto; }
|
position: fixed; inset: 0 auto 0 0; width: min(86vw, var(--nav-w));
|
||||||
|
z-index: 60; transform: translateX(-100%);
|
||||||
/* Off-canvas only where :has() works: without it nothing can read #nav-toggle, so the sidebar
|
box-shadow: 2px 0 24px rgba(0,0,0,.18);
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
.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) {
|
@media (max-width: 560px) {
|
||||||
.crumbs, .page-sub { display: none; }
|
.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; }
|
#nav-toggle { position: fixed; top: 0; left: 0; opacity: 0; pointer-events: none; }
|
||||||
|
|
||||||
/* admin forms: create/edit user, account actions */
|
/* 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 {
|
.form-card {
|
||||||
display: flex; flex-direction: column; gap: 14px;
|
display: flex; flex-direction: column; gap: 14px;
|
||||||
padding: 18px; background: var(--surface);
|
padding: 18px; background: var(--surface);
|
||||||
|
|||||||
@@ -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+/);
|
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.
|
// Production caches compiled templates; rendering must stay correct across repeated requests.
|
||||||
test("renders correctly with template caching enabled", async () => {
|
test("renders correctly with template caching enabled", async () => {
|
||||||
const app = createApp({ cache: true });
|
const app = createApp({ cache: true });
|
||||||
|
|||||||
@@ -36,8 +36,6 @@ import { localPath } from "./safe-url.ts";
|
|||||||
import { routePublic, serveStatic } from "./static.ts";
|
import { routePublic, serveStatic } from "./static.ts";
|
||||||
import { renderPluginView } from "../plugin-host/view-resolver.ts";
|
import { renderPluginView } from "../plugin-host/view-resolver.ts";
|
||||||
|
|
||||||
const NO_STORE = "private, no-store";
|
|
||||||
|
|
||||||
const rootDir = join(dirname(fileURLToPath(import.meta.url)), "..", "..");
|
const rootDir = join(dirname(fileURLToPath(import.meta.url)), "..", "..");
|
||||||
|
|
||||||
export interface AppOptions {
|
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
|
// 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.
|
// the same bytes in every language, and a Vary there fragments its entry per raw header.
|
||||||
res.setHeader("vary", "accept-language");
|
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,
|
// 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
|
// the themed forms and the cross-origin Kratos POST share one cookie host — otherwise the
|
||||||
|
|||||||
@@ -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);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -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`);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
@@ -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 |
|
## Finnished work
|
||||||
|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
||||||
| 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 |
|
|
||||||
|
|
||||||
## Details
|
- [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.
|
||||||
### 26. Add a domain glossary and the project's intended horizon to the README.
|
- [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`).
|
||||||
Glossary: host, manifest, chrome, nav fragment, permission token, system plugin, denylist. Also the
|
- [x] The seeded admin is granted each permission once — `seedPermissions` dedupes and the grant PUT is idempotent.
|
||||||
expected plugin count, since per-plugin dependency isolation prices N dependency trees on disk and
|
- [x] Run the E2E runner as the invoking user so its artifacts aren't root-owned.
|
||||||
in RSS.
|
- [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.
|
||||||
### 24. Retire `src/ui/shell-context.ts` and render the dashboard from `ctx.chrome`.
|
- [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.
|
||||||
`ShellModel`/`buildShellContext` has one consumer, the dashboard, and duplicates `PageChrome` on
|
- [x] Skip the CI gate when only markdown changed.
|
||||||
almost every field. Keep `shellUser` as the shared primitive.
|
- [x] Replace the `<details>` popup menus with the Popover API so an outside click dismisses them.
|
||||||
|
- [x] Organize the files in src into folders.
|
||||||
### 17. Remove the icons no caller uses from `ICON_NAMES` and the sprite.
|
- [x] Move docs/plugin-contract.md into README.md and remove the docs folder.
|
||||||
|
- [x] Move the scheduling example out of `plugins/` into `examples/`.
|
||||||
`i-chart`, `i-copy` and `i-download` have no caller, and the sprite is inlined into every page.
|
- [x] Make `config/` an empty drop-in mount with the defaults as fallback.
|
||||||
AGENTS.md → "`ICON_NAMES` is a host-owned registry" already makes this routine cleanup.
|
- [x] Turn the built-in admin pages into a drop-in example plugin.
|
||||||
|
- [x] CI/CD — test on push to any branch except main.
|
||||||
### 21. Fail the gate on a function that exceeds a size or complexity limit.
|
- [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.
|
||||||
Only `tsc --strict` runs today. A new tool is a new dependency, so justify it.
|
- [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.
|
||||||
### 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.
|
- [x] CI/CD — sync released tags to Docker Hub.
|
||||||
|
- [x] Write README-dockerhub.md for the Docker Hub overview.
|
||||||
Section 1's TOKENS comment says the theme follows the OS by default and that `:has()` variable swaps
|
- [x] CI/CD — set up the Renovate bot.
|
||||||
are "rock-solid". On an engine without `:has()` (Chromium below 105, Firefox below 121) every theme
|
- [x] CI/CD — give Renovate a read-only `GITHUB_COM_TOKEN` so github.com lookups aren't rate-limited.
|
||||||
rule is inert, the OS-dark rule included, so those engines stay light, as the
|
- [x] CI/CD — auto-release on Renovate updates, versioned from the `Release-Bump:` trailer.
|
||||||
`@supports not selector(:has(a))` block further down says. A reader trusting the comment expects dark
|
- [x] Add an e2e test for the admin plugin's OAuth2-clients (Hydra) screen.
|
||||||
mode there.
|
- [x] Document the auth security model in the README.
|
||||||
|
- [x] Add i18n support.
|
||||||
### 32. Fail the gate on a stylesheet feature the browser floor lacks, unless it is guarded.
|
- [x] Settle the identity-vs-user vocabulary.
|
||||||
|
- [x] Use one uniform verb per action in the English UI (sign in / sign out / create account).
|
||||||
Nothing checks README → "Two browser floors", and no E2E engine is old enough to show a break.
|
- [x] Remove the dead "Profile" link from the sidebar profile menu.
|
||||||
Lint every stylesheet, the examples' included, against Chromium 87, Safari 15 and Firefox 115, and
|
- [x] Remove the unspecified "Settings"/"Preferences" cog from the sidebar footer.
|
||||||
allow a feature only inside an `@supports` that guards it.
|
- [x] **HIGH — Split `handleRequest` in `src/http/app.ts`** — extract the built-in endpoints into named handlers on an internal route table.
|
||||||
|
|
||||||
### 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.
|
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
<%#
|
<%#
|
||||||
Recursive nav tree. Each node: { label, href?, icon?, count?, current?, open?, children? }.
|
Recursive nav tree. Each node: { label, href?, icon?, count?, current?, open?, children? }.
|
||||||
Shape is orthogonal: header (children → disclosure toggle + nested <ul>) vs leaf (spacer),
|
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
|
and clickable (href → <a>) vs static (<span>). The CSS reveals children only when the
|
||||||
only when the sibling .nav-disc is [open]; elsewhere it shows them all. `root` picks the outer class.
|
sibling .nav-disc is [open]. `root` picks the outer class.
|
||||||
%><%
|
%><%
|
||||||
const nodes = locals.nodes || [];
|
const nodes = locals.nodes || [];
|
||||||
const root = locals.root !== false;
|
const root = locals.root !== false;
|
||||||
|
|||||||
Reference in New Issue
Block a user