diff --git a/AGENTS.md b/AGENTS.md index d87c6b8..3f7731f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -259,6 +259,10 @@ Revisit only if the stated reason stops holding. it means disclosure rather than popup: the nav tree. `shell.ejs` hand-rolls the same block for the profile menu (its trigger composes escaped user values and its one item is a CSRF POST form) — keep the two in step. +- **Two multi-selects, chosen by list length.** `chips` lays every option on the bar, which is right + for a handful and a wall past that — `multiselect` hides the same checkboxes in a popover and puts + the chosen count on the trigger. Both submit the same repeated parameter, so swapping one for the + other is a view-model edit. - **`ICON_NAMES` (`src/ui/icons.ts`) is a host-owned registry, not a frozen plugin contract**, so it is deliberately not re-exported from `@plainpages/plugin-api`. The palette may narrow when the last reference to an id goes, and a plugin needing one gets it re-registered in the same change. Accepted cost: an 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/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..ee496bb 100644 --- a/src/ui/filter-bar.test.ts +++ b/src/ui/filter-bar.test.ts @@ -37,6 +37,14 @@ 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", + 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 +72,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, /