12 KiB
Changelog
The release version is the plugin contract version (HOST_API_VERSION), so a minor is a
contract break: a plugin's apiVersion must match the host's major.minor or discovery refuses it
at boot. Entries start at 0.3.0.
Unreleased
A secret plugin setting's bad value stays out of the boot error
When a secret setting's value fails validation, the boot error no longer prints it, so a malformed
DSN doesn't put its password in the log.
The admin's recovery-code notice links to the recovery flow the code belongs to
After an admin generates a recovery code for a user, the notice asks them to send the user both the code and its link. The link opens the recovery flow the code belongs to, where the user enters it. Before, the notice linked a new recovery flow at its email step, which doesn't accept the code.
A verification or recovery code pasted with whitespace around it is accepted
The code field on the verification and recovery pages takes a code with spaces or tabs around it.
Pasting a code from an email often adds them, and before, the browser refused the code. Those two
forms now post to Plainpages, which trims the code and passes the form on to Kratos at
KRATOS_PUBLIC_URL. A visitor who picked a language now keeps it through both flows.
A filter shows its selected and focused choice in browsers without :has()
On Chromium below 105, Firefox below 121 and Safari below 15.4, a filter's segments and chips now show their native radio or checkbox, which marks the selected choice and takes the keyboard focus ring. Before, they showed neither. Newer browsers look as before.
The tests fail on a stylesheet feature the browser floor lacks
npm test checks every stylesheet in public/, examples/ and plugins/ against Chromium 87,
Safari 15 and Firefox 115: a feature one of them lacks fails it unless an @supports around it
names that feature. Mount a plugin in plugins/<id>/ to check its stylesheets. Pages render as
before in every browser.
Every release reaches Docker Hub, and the Hub page has a short description
Releases 0.4.1 to 0.4.20 were tagged in git but never published to Docker Hub or as semver tags in the Gitea registry; they are now, and every later release is. The Hub page gains a one-line description.
The admin example stops an administrator locking themselves out through a group
On the Groups screen, unticking a permission on a group you are in, removing yourself (or a nested group you are in) from its members, or deleting the group is refused when it would take away a permission you don't hold directly. The page names the permissions, and another administrator can make the change. The check errs towards refusing: it also refuses when you would keep the permission through another group, or keep your membership another way.
The admin example titles a read-only user page "View user"
A holder of users:read without users:write sees the user page titled "View user", with a "View"
breadcrumb, instead of "Edit user".
Browsers cache stylesheets instead of downloading them on every page
Every /public/ URL the host renders carries ?v=<hash of the file> and is cached for a year as
immutable: the shell's stylesheets, favicon and logo, the styles a plugin passes to the shell, and
the stylesheets of the standalone error and consent pages. A changed file gets a new hash, so
browsers fetch it at once. A plugin names its stylesheets exactly as before. Any other /public/ URL, such as a script
a plugin's own markup links, is cached for five minutes, then revalidated with ETag or
Last-Modified. A cache or CDN in front of Plainpages may now store /public/ responses.
A language chosen on a sign-in link no longer drops an absolute return target
/login?return_to=<absolute URL>&locale=sv-SE passed the visitor to the dashboard after sign-in and
dropped the OAuth2 login challenge in return_to. The target now reaches Kratos, and the chosen
language is lost across that round-trip instead.
The admin example refuses a permission save made from an out-of-date page
Two administrators editing one user's or group's permissions no longer overwrite each other. The
form carries a token for the grants it showed. A save that would change something, made after those
grants changed, is refused with "These permissions changed while you had the page open", and the page
shows the current grants. A POST with no token is refused the same way, so a script posting to
/admin/users/:id/permissions or /admin/groups/:name/permissions must first read the token from the
page's baseline field.
The admin example confirms a permission save and reports a partial one
A save now lands on the page with "Permissions saved." If the permission service fails midway, the page says the save stopped part-way instead of showing the generic error page. On a user, the changes that landed still reach the user's live sessions at once when the revocation denylist is on.
A read-only holder sees View, not Edit, on the admin example's users list
A holder of users:read without users:write gets a "View" row action with an eye icon. The admin
example's catalogs gain admin.grants.partial, admin.grants.saved, admin.grants.stale and
admin.users.view, so a translated catalog mounted under locales/plugins/admin/ must add them.
Every page carries a smaller icon sprite
i-chart, i-copy and i-download are gone from the inlined icon sprite, so a plugin that
referenced one now renders a blank icon. Nothing in the host or the example plugins used them. Pick
another id from views/partials/icons.ejs, or inline your own SVG.
The README defines its terms and states its intended scale
A new Glossary defines host, plugin, manifest, chrome, nav fragment, permission name, system plugin and denylist. Plugin dependencies also states the scale Plainpages is sized for: a handful of plugins per deployment.
The mobile nav stays reachable on engines without :has()
At 860px and narrower, an engine without :has() (Chromium below 105, such as webOS TV 22 and 23,
and Firefox below 121) hid the sidebar off-canvas with no way to open it, so the pages the nav links
to could not be reached. Now the sidebar moves off-canvas, behind the hamburger, only where :has()
works. Elsewhere it stays in the flow above the content and the hamburger is hidden.
The whole nav tree shows on engines without :has()
On an engine without :has() (Chromium below 105, such as webOS TV 22 and 23, and Firefox below 121)
every nested nav entry was hidden with nothing to open it. Now the tree folds only where :has()
works and stays fully open elsewhere, with its toggles hidden. The theme switch, which also needs
:has(), is hidden there.
Button and badge text meets 4.5:1 contrast
The primary button and the current page button take their text colour from the new --on-accent
token: white in light mode, near-black in dark mode, where white on the accent was 2.7:1. The
light-mode success, warning and info badge text is darker. A plugin that overrides --accent or
--l-accent should set --l-on-accent and --d-on-accent to match.
Unused auth stylesheet rules are gone
public/css/auth.css no longer holds the .auth-view screen switch, .tpl-* preview controls,
.forgot-alert, .register-alert, .remember and .auth-foot; no core view used them. A plugin
view that did must carry its own rules.
Colours render on engines without oklch()
The theme palette in public/css/styles.css is hex, not oklch(). On an engine older than
Chromium 111 (Tizen 2024, webOS TV 24) var(--accent) was invalid, so the primary button was white
text on no background. A plugin that sets a colour custom property, such as --accent or --l-accent,
should use hex, rgb() or hsl() too.
One horizontal inset across the content pane
The topbar, filter bar, pager, form page and empty-table message inset their content by --pad-x
(12px), as table cells do, instead of 16px. A plugin that overrides --pad-x now moves all of them
together.
Pages are not kept by a shared cache
Every page and JSON response now carries Cache-Control: private, no-store, so a cache or reverse
proxy in front of Plainpages can't store a signed-in page and serve it to the next visitor. A route
whose page is the same for everyone can still set its own cache-control through
RouteResult.headers; assets are unchanged.
0.4.0
Breaking. Set apiVersion: "0.4.0". The app shell no longer bounds the content column, so a page
that relied on filling it scrolls the document instead.
The document scrolls, and the chrome scrolls with it
.app was a 100dvh box with overflow: hidden, so a page was only reachable below the fold if its
own wrapper was a flex child with overflow-y: auto. .table-wrap and .shell-auth were; nothing
else was, and a long page in .form-page clipped everything past the window in every engine.
Now the shell is min-height: 100dvh and nothing bounds the viewport. The sidebar and topbar scroll
with the page, and keyboard paging, back/forward scroll restoration and find-in-page work without a
page doing anything.
The sticky thead on data-table goes with it: a header only sticks to a scrollport that moves, and
there is no longer one. A plugin that wants a full-height pane owns that in its own stylesheet; the
shell offers no opt-out, per the simplicity priority in AGENTS.md.
Upgrading a plugin
- Set
apiVersion: "0.4.0". - A page that scrolled the whole window needs no change — it now scrolls the document.
- A page holding a region that filled the content column (
flex: 1 1 auto; min-height: 0with its ownoverflow) no longer gets a bounded column to fill, so that region grows and the page scrolls. Either let it, or give the region its own height in the plugin's stylesheet. - A
data-tableno longer scrolls its rows in a bounded region: the page scrolls, and the header scrolls with it.
The sidebar stretches the whole document, so on a long page its footer — theme, language, profile and Sign out — sits at the end of that page rather than the bottom of the screen.
0.3.0
Breaking. Set apiVersion: "0.3.0", and name a gate on every route and nav node.
A session is a gate of its own
session: true takes any signed-in user, with no grant to hold — for a page whose data is the
visitor's own (their upstream account, their own tokens), where there is no distinction a permission
could name. An anonymous visitor is bounced to /login with the page as return_to, exactly as a
permission gate does.
Every route and nav node now names exactly one of public: true, session: true or
permission: "<resource>:<action>", and a gate is spelled true:
- Naming none is refused. It used to mean public, so a forgotten gate published a page; it now fails the boot instead.
- Naming two is refused, as before.
- Spelling one anything but
trueis refused —public: falseandsession: "yes"both set no gate while reading as if they set one.
A section header gates nothing itself, so it takes public: true and lets each child decide; the
host still drops a header whose children all filtered out.
Gate is exported from @plainpages/plugin-api, and Route and NavNode extend it.
Filter bars take a multi-select
The filter-bar partial gains a multiselect control — the same checkboxes on the same query
parameter as chips, but behind a button once the list is too long to lay on the bar. Config is
{ name, legend?, note?, value?, options }, and the panel says what a capped list left out.
Fixed
- An identity carrying no email no longer yields a session at all. Login used to mint a JWT for one, which every later request then rejected as anonymous — leaving the browser holding a dead cookie and no way to tell why.
Dependencies
- Node 24.20.0.
Upgrading a plugin
- Set
apiVersion: "0.3.0". - Give every route and nav node a gate. Anything that relied on omitting one was public — say
public: trueoutright.
A page that scopes rows to the signed-in visitor should join on ctx.user.id. An email address is
user-changeable and can be reassigned to someone else, who would then inherit the previous holder's
rows. The reference plugin's new /scheduling/mine page shows the shape.