diff --git a/README.md b/README.md
index f12903a..32d8a85 100644
--- a/README.md
+++ b/README.md
@@ -47,7 +47,7 @@ folder under `plugins/` goes live after a restart. Create `plugins/hello/plugin.
import { definePlugin } from "@plainpages/plugin-api";
export default definePlugin({
- apiVersion: "0.2.0",
+ apiVersion: "0.3.0",
nav: [{ href: "/hello", id: "hello", label: "Hello", public: true }],
routes: [
{ method: "GET", path: "/", public: true, handler: () => ({ html: "
Hello from my plugin
" }) },
@@ -349,7 +349,7 @@ import { definePlugin } from "@plainpages/plugin-api";
import { listThings, createThings } from "./handlers.ts";
export default definePlugin({
- apiVersion: "0.2.0", // semver string of the host contract this plugin was built against (see Versioning)
+ apiVersion: "0.3.0", // semver string of the host contract this plugin was built against (see Versioning)
// Nav fragment, merged into the global menu and permission-filtered per user.
// `icon` is a Lucide icon by its sprite id (src/ui/icons.ts).
@@ -470,7 +470,7 @@ import { definePlugin } from "@plainpages/plugin-api";
import { landing, board } from "./pages.ts";
export default definePlugin({
- apiVersion: "0.2.0",
+ apiVersion: "0.3.0",
home: landing, // owns "/" — the public front page
dashboard: board, // owns "/dashboard" — the post-login app home
});
@@ -747,7 +747,7 @@ camel humps both becoming underscores — so `upstream` on the `scheduling` plug
```ts
export default definePlugin({
- apiVersion: "0.2.0",
+ apiVersion: "0.3.0",
settings: [
{ key: "upstream", type: "url", required: true, description: "Base URL of the backend" },
{ key: "pageSize", type: "number", default: 25 },
@@ -801,7 +801,7 @@ import { definePlugin } from "@plainpages/plugin-api";
let sql: ReturnType;
export default definePlugin({
- apiVersion: "0.2.0",
+ apiVersion: "0.3.0",
storage: true,
hooks: {
onBoot: async (boot) => {
diff --git a/examples/plugins/admin/plugin.ts b/examples/plugins/admin/plugin.ts
index baf3f4a..d5ce0c6 100644
--- a/examples/plugins/admin/plugin.ts
+++ b/examples/plugins/admin/plugin.ts
@@ -28,7 +28,7 @@ const clients = on("oauth2-clients");
const pluginSettings = on("plugin-settings");
export default definePlugin({
- apiVersion: "0.2.0", // the host contract this was built against — a literal, never HOST_API_VERSION
+ apiVersion: "0.3.0", // the host contract this was built against — a literal, never HOST_API_VERSION
nav: [ADMIN_NAV],
diff --git a/examples/plugins/scheduling/plugin.ts b/examples/plugins/scheduling/plugin.ts
index 76f3972..eb2b30f 100644
--- a/examples/plugins/scheduling/plugin.ts
+++ b/examples/plugins/scheduling/plugin.ts
@@ -12,7 +12,7 @@ let upstreamUrl = "";
const upstream = createUpstream(() => upstreamUrl);
export default definePlugin({
- apiVersion: "0.2.0", // the host contract this was built against — a literal, never HOST_API_VERSION
+ apiVersion: "0.3.0", // the host contract this was built against — a literal, never HOST_API_VERSION
// onBoot runs after discovery, before the server listens — where a plugin receives its resolved
// settings. A malformed URL already failed the boot by then; the host validated the declared type.
diff --git a/package-lock.json b/package-lock.json
index 0137f04..6c85176 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -8,7 +8,7 @@
"dependencies": {
"@larvit/log": "2.3.0",
"ejs": "6.0.1",
- "lucide-static": "1.33.0",
+ "lucide-static": "1.34.0",
"postgres": "3.4.9"
},
"devDependencies": {
@@ -399,9 +399,9 @@
}
},
"node_modules/lucide-static": {
- "version": "1.33.0",
- "resolved": "https://registry.npmjs.org/lucide-static/-/lucide-static-1.33.0.tgz",
- "integrity": "sha512-jNGgvTNcLUfVRX4N9PH9pVVTJzoph/BmYmgU838bYBQodkUJL4nAThkuymFz1x3OUYMhJxPndC7rdg1sxOPYKg==",
+ "version": "1.34.0",
+ "resolved": "https://registry.npmjs.org/lucide-static/-/lucide-static-1.34.0.tgz",
+ "integrity": "sha512-pSUvFhfhvDnhXN1fXerZEMgKLQQ3DneKwFYlqB5ji8OEAN0iPi/qgwQvCkcL519QgMeR66IpS/pT342VyT/g4g==",
"license": "ISC"
},
"node_modules/postgres": {
diff --git a/package.json b/package.json
index e0cde0d..4c17708 100644
--- a/package.json
+++ b/package.json
@@ -18,7 +18,7 @@
"dependencies": {
"@larvit/log": "2.3.0",
"ejs": "6.0.1",
- "lucide-static": "1.33.0",
+ "lucide-static": "1.34.0",
"postgres": "3.4.9"
},
"devDependencies": {
diff --git a/public/css/styles.css b/public/css/styles.css
index d55a79f..9ec5091 100644
--- a/public/css/styles.css
+++ b/public/css/styles.css
@@ -316,6 +316,11 @@ span.nav-self { cursor: default; } /* static / non-clickable */
.btn-primary:hover { filter: brightness(1.06); }
.btn-ghost { background: transparent; border-color: transparent; }
.btn-ghost:hover { background: var(--surface-2); }
+.btn-menu::after {
+ content: ""; width: 7px; height: 7px; margin: -2px 1px 0 1px;
+ border-right: 1.5px solid var(--text-faint); border-bottom: 1.5px solid var(--text-faint);
+ transform: rotate(45deg);
+}
.icon-btn {
width: 30px; height: 30px; padding: 0; justify-content: center;
color: var(--text-muted);
@@ -494,7 +499,8 @@ span.nav-self { cursor: default; } /* static / non-clickable */
position-anchor: auto;
position-try-fallbacks: flip-block, flip-inline;
top: anchor(bottom); right: anchor(right);
- min-width: 210px; padding: 6px;
+ min-width: 210px; max-width: min(320px, 92vw); padding: 6px;
+ max-height: 60vh; overflow-y: auto; overflow-wrap: anywhere;
background: var(--surface); color: var(--text);
border: 1px solid var(--border-2); border-radius: var(--radius);
box-shadow: 0 8px 28px rgba(0,0,0,.16);
diff --git a/release-tooling/contract-version.test.ts b/release-tooling/contract-version.test.ts
index 356b4e9..65fec87 100644
--- a/release-tooling/contract-version.test.ts
+++ b/release-tooling/contract-version.test.ts
@@ -12,7 +12,7 @@ test("readHostApiVersion pulls the constant out of the real source, and returns
test("bumping HOST_API_VERSION is a deliberate act, so pin the shipped value", () => {
// Not a substitute for the release gate — this test cannot see a tag. It is the tripwire that
// makes an accidental edit fail here rather than at release time.
- assert.equal(readHostApiVersion(readFileSync("src/plugin-host/plugin.ts", "utf8")), "0.2.0");
+ assert.equal(readHostApiVersion(readFileSync("src/plugin-host/plugin.ts", "utf8")), "0.3.0");
});
test("every author-facing apiVersion sample matches the shipped contract", () => {
diff --git a/release-tooling/dockerhub-overview.md.tmpl b/release-tooling/dockerhub-overview.md.tmpl
index 816113c..988dac5 100644
--- a/release-tooling/dockerhub-overview.md.tmpl
+++ b/release-tooling/dockerhub-overview.md.tmpl
@@ -182,7 +182,7 @@ into the app. Create `plugins/hello/plugin.ts`:
import { definePlugin } from "@plainpages/plugin-api";
export default definePlugin({
- apiVersion: "0.2.0",
+ apiVersion: "0.3.0",
nav: [{ href: "/hello", id: "hello", label: "Hello", public: true }],
routes: [
{ method: "GET", path: "/", public: true, handler: () => ({ html: "Hello from my plugin
" }) },
diff --git a/src/i18n/locales/en-US.ts b/src/i18n/locales/en-US.ts
index a1e095b..471eb78 100644
--- a/src/i18n/locales/en-US.ts
+++ b/src/i18n/locales/en-US.ts
@@ -100,6 +100,7 @@ const messages = {
"filter.remove": "Remove {{label}} filter",
"filter.reset": "Reset",
"filter.search": "Search",
+ "filter.selected": "{{label}}, {{count}} selected",
"filter.to": "To",
"filter.toSeparator": "to",
diff --git a/src/i18n/locales/sv-SE.ts b/src/i18n/locales/sv-SE.ts
index ff6cdbe..85487eb 100644
--- a/src/i18n/locales/sv-SE.ts
+++ b/src/i18n/locales/sv-SE.ts
@@ -91,6 +91,7 @@ const messages: CoreMessages = {
"filter.remove": "Ta bort filtret {{label}}",
"filter.reset": "Återställ",
"filter.search": "Sök",
+ "filter.selected": "{{label}}, {{count}} valda",
"filter.to": "Till",
"filter.toSeparator": "till",
diff --git a/src/plugin-host/plugin.ts b/src/plugin-host/plugin.ts
index 7d45f13..313d667 100644
--- a/src/plugin-host/plugin.ts
+++ b/src/plugin-host/plugin.ts
@@ -10,7 +10,7 @@ import { envName, type SettingDecl, type SettingsOf } from "./settings.ts";
import type { StorageCredentials } from "./storage.ts";
// The Plainpages release this contract ships in — see README → Contract versioning.
-export const HOST_API_VERSION = "0.2.0";
+export const HOST_API_VERSION = "0.3.0";
export type HttpMethod = "DELETE" | "GET" | "HEAD" | "PATCH" | "POST" | "PUT";
diff --git a/src/ui/filter-bar.test.ts b/src/ui/filter-bar.test.ts
index 9594ba1..3a43855 100644
--- a/src/ui/filter-bar.test.ts
+++ b/src/ui/filter-bar.test.ts
@@ -37,6 +37,15 @@ const config = {
options: [{ value: "engineering", label: "Engineering" }, { value: "design", label: "Design" }, { value: "oncall", label: "On-call" }],
},
{ type: "daterange", legend: "Joined", from: { name: "joined_from", value: "2026-01-01", label: "Joined from" }, to: { name: "joined_to", value: "2026-06-14", label: "Joined to" } },
+ {
+ type: "multiselect",
+ name: "owner",
+ legend: "Owner",
+ note: "30 of 45",
+ value: ["ann"],
+ options: [{ value: "ann", label: "Ann Berg" }, { value: "bo", label: "Bo Falk" }],
+ },
+ { type: "multiselect", name: "room", legend: "Room", options: [{ value: "lab", label: "Lab" }] },
],
],
pills: [{ label: "Team", value: "Engineering", remove: "?tag=oncall" }],
@@ -64,6 +73,14 @@ test("filter-bar renders a GET form with every control type, reflecting current
assert.match(html, /On-call/);
assert.match(html, /Design/);
+ // multiselect — a disclosure: the trigger names the filter and counts what is chosen, the options
+ // are checkboxes in a fieldset the popover holds, so a long list costs one line of the bar.
+ assert.match(html, /