');
+ await fs.writeFile(path.join(ui, 'assets', 'app-7c1f4b2e.js'), 'export const x = 1;');
+ await fs.writeFile(path.join(ui, 'assets', 'main-B2kf9Xz1.js'), 'export const z = 3;');
+ await fs.writeFile(path.join(ui, 'assets', 'app.js'), 'export const y = 2;');
+ await fs.writeFile(path.join(ui, 'assets', 'app-bootstrap.js'), 'export const boot = true;');
+ await fs.writeFile(path.join(ui, 'assets', 'vendor-polyfills.js'), 'export const polyfills = true;');
+ await fs.writeFile(path.join(ui, 'assets', 'logo.svg'), '');
+ await fs.writeFile(path.join(ui, 'assets', 'notes.css'), 'body{color:red}');
+ // A secret NEXT to the bundle: the thing traversal would be aiming at.
+ await fs.writeFile(path.join(packageRoot, 'manifest.yaml'), 'schema_version: "1"\n');
+
+ app = express();
+ app.use(
+ '/p',
+ createPluginUiStaticRouter({
+ resolvePackageRoot: (id) => (id === PLUGIN_ID ? packageRoot : undefined),
+ }),
+ );
+ // Anything the static router passes through must be visibly distinct from
+ // what it answers, or "falls through" would be untestable.
+ app.use('/p', (_req, res) => {
+ res.status(418).json({ error: 'fell_through' });
+ });
+});
+
+after(async () => {
+ await fs.rm(root, { recursive: true, force: true });
+});
+
+describe('plugin UI static serving — happy path', () => {
+ it('serves index.html at the bundle root', async () => {
+ const res = await invoke(app, 'GET', `/p/${PLUGIN_ID}/ui/`);
+ assert.equal(res.status, 200);
+ assert.equal(res.headers['content-type'], 'text/html; charset=utf-8');
+ assert.match(res.text, /doctype html/);
+ });
+
+ it('serves index.html at the bundle root without a trailing slash', async () => {
+ const res = await invoke(app, 'GET', `/p/${PLUGIN_ID}/ui`);
+ assert.equal(res.status, 200);
+ assert.equal(res.headers['content-type'], 'text/html; charset=utf-8');
+ });
+
+ it('serves a nested hashed asset with a JS content-type', async () => {
+ const res = await invoke(app, 'GET', `/p/${PLUGIN_ID}/ui/assets/app-7c1f4b2e.js`);
+ assert.equal(res.status, 200);
+ assert.equal(res.headers['content-type'], 'text/javascript; charset=utf-8');
+ assert.equal(res.text, 'export const x = 1;');
+ });
+
+ it('serves svg with its own type, never sniffed', async () => {
+ const res = await invoke(app, 'GET', `/p/${PLUGIN_ID}/ui/assets/logo.svg`);
+ assert.equal(res.status, 200);
+ assert.equal(res.headers['content-type'], 'image/svg+xml');
+ assert.equal(res.headers['x-content-type-options'], 'nosniff');
+ });
+
+ it('passes non-ui paths through to the plugin router', async () => {
+ const res = await invoke(app, 'GET', `/p/${PLUGIN_ID}/webhook`);
+ assert.equal(res.status, 418);
+ });
+
+ it('404s an unknown plugin id', async () => {
+ const res = await invoke(app, 'GET', '/p/not-installed/ui/index.html');
+ assert.equal(res.status, 404);
+ });
+});
+
+describe('plugin UI static serving — caching', () => {
+ it('marks a hash-named file immutable for a year', async () => {
+ const res = await invoke(app, 'GET', `/p/${PLUGIN_ID}/ui/assets/app-7c1f4b2e.js`);
+ assert.equal(res.headers['cache-control'], 'public, max-age=31536000, immutable');
+ });
+
+ it('does NOT mark an unhashed file immutable — an upgrade must be visible', async () => {
+ const res = await invoke(app, 'GET', `/p/${PLUGIN_ID}/ui/assets/app.js`);
+ assert.equal(res.headers['cache-control'], 'no-cache');
+ });
+
+ it('does NOT freeze app-bootstrap.js for a year — the trailing word is not a hash', async () => {
+ const res = await invoke(app, 'GET', `/p/${PLUGIN_ID}/ui/assets/app-bootstrap.js`);
+ assert.equal(res.headers['cache-control'], 'no-cache');
+ });
+
+ it('does NOT freeze vendor-polyfills.js for a year — ordinary bundle names must revalidate', async () => {
+ const res = await invoke(app, 'GET', `/p/${PLUGIN_ID}/ui/assets/vendor-polyfills.js`);
+ assert.equal(res.headers['cache-control'], 'no-cache');
+ });
+
+ it('never marks index.html immutable', async () => {
+ const res = await invoke(app, 'GET', `/p/${PLUGIN_ID}/ui/index.html`);
+ assert.equal(res.headers['cache-control'], 'no-cache');
+ });
+
+ it('keeps a hex-style content hash immutable', async () => {
+ const res = await invoke(app, 'GET', `/p/${PLUGIN_ID}/ui/assets/app-7c1f4b2e.js`);
+ assert.equal(res.headers['cache-control'], 'public, max-age=31536000, immutable');
+ });
+
+ it('keeps a base36-style content hash immutable', async () => {
+ const res = await invoke(app, 'GET', `/p/${PLUGIN_ID}/ui/assets/main-B2kf9Xz1.js`);
+ assert.equal(res.headers['cache-control'], 'public, max-age=31536000, immutable');
+ });
+
+ it('answers 304 for a matching ETag', async () => {
+ const first = await invoke(app, 'GET', `/p/${PLUGIN_ID}/ui/index.html`);
+ const etag = first.headers['etag'];
+ assert.ok(typeof etag === 'string' && etag.length > 2);
+ const second = await invoke(app, 'GET', `/p/${PLUGIN_ID}/ui/index.html`, {
+ headers: { 'if-none-match': etag },
+ });
+ assert.equal(second.status, 304);
+ });
+});
+
+describe('plugin UI static serving — the security boundary', () => {
+ it('refuses a traversal out of the bundle', async () => {
+ const res = await invoke(app, 'GET', `/p/${PLUGIN_ID}/ui/../manifest.yaml`);
+ assert.notEqual(res.status, 200);
+ assert.ok(!res.text.includes('schema_version'));
+ });
+
+ it('refuses a percent-encoded traversal', async () => {
+ const res = await invoke(app, 'GET', `/p/${PLUGIN_ID}/ui/%2e%2e/manifest.yaml`);
+ assert.notEqual(res.status, 200);
+ assert.ok(!res.text.includes('schema_version'));
+ });
+
+ it('refuses a double-encoded traversal', async () => {
+ const res = await invoke(app, 'GET', `/p/${PLUGIN_ID}/ui/%252e%252e/manifest.yaml`);
+ assert.notEqual(res.status, 200);
+ assert.ok(!res.text.includes('schema_version'));
+ });
+
+ it('refuses a deep traversal aimed at the host filesystem', async () => {
+ const res = await invoke(
+ app,
+ 'GET',
+ `/p/${PLUGIN_ID}/ui/../../../../../../etc/passwd`,
+ );
+ assert.notEqual(res.status, 200);
+ assert.ok(!res.text.includes('root:'));
+ });
+
+ it('refuses backslash separators', async () => {
+ const res = await invoke(app, 'GET', `/p/${PLUGIN_ID}/ui/..%5Cmanifest.yaml`);
+ assert.notEqual(res.status, 200);
+ });
+
+ it('never serves a directory, and never lists one', async () => {
+ const res = await invoke(app, 'GET', `/p/${PLUGIN_ID}/ui/assets`);
+ assert.equal(res.status, 404);
+ assert.ok(!res.text.includes('app-7c1f4b2e.js'));
+ });
+
+ it('never lists an empty directory either', async () => {
+ const res = await invoke(app, 'GET', `/p/${PLUGIN_ID}/ui/empty-dir/`);
+ assert.equal(res.status, 404);
+ });
+
+ it('refuses .css even when the file is physically present', async () => {
+ // Belt and braces: the extractor already rejects `.css`, but if a bundle
+ // ever carried one, serving it would end "plugins inherit the design
+ // system by construction" on the spot.
+ const res = await invoke(app, 'GET', `/p/${PLUGIN_ID}/ui/assets/notes.css`);
+ assert.equal(res.status, 404);
+ assert.ok(!res.text.includes('color:red'));
+ });
+
+ it('sets a confining CSP on the HTML document', async () => {
+ const res = await invoke(app, 'GET', `/p/${PLUGIN_ID}/ui/index.html`);
+ const csp = String(res.headers['content-security-policy'] ?? '');
+ assert.match(csp, /default-src 'none'/);
+ assert.match(csp, /frame-ancestors 'self'/);
+ assert.match(csp, /base-uri 'none'/);
+ assert.ok(!csp.includes("script-src 'unsafe-inline'"));
+ });
+
+ it('sets a sandboxing CSP on SVG so direct navigation cannot execute in origin', async () => {
+ const res = await invoke(app, 'GET', `/p/${PLUGIN_ID}/ui/assets/logo.svg`);
+ const csp = String(res.headers['content-security-policy'] ?? '');
+ assert.match(csp, /default-src 'none'/);
+ assert.match(csp, /\bsandbox\b/);
+ });
+
+ it('also sends CSP on JS assets so the handler cannot regress by branch omission', async () => {
+ const res = await invoke(app, 'GET', `/p/${PLUGIN_ID}/ui/assets/app-7c1f4b2e.js`);
+ const csp = String(res.headers['content-security-policy'] ?? '');
+ assert.match(csp, /default-src 'none'/);
+ });
+
+ it('does not leak the referrer to plugin-side navigations', async () => {
+ const res = await invoke(app, 'GET', `/p/${PLUGIN_ID}/ui/index.html`);
+ assert.equal(res.headers['referrer-policy'], 'no-referrer');
+ });
+});
+
+describe('safeRelativePath', () => {
+ const rejected = [
+ '../secrets',
+ 'a/../../b',
+ '%2e%2e/x',
+ '/etc/passwd',
+ 'a\\..\\b',
+ 'a\0b',
+ ];
+ for (const input of rejected) {
+ it(`rejects ${JSON.stringify(input)}`, () => {
+ assert.equal(safeRelativePath(input), null);
+ });
+ }
+
+ it('normalises redundant segments without allowing escape', () => {
+ assert.equal(safeRelativePath('a/./b//c.js'), 'a/b/c.js');
+ });
+
+ it('accepts an ordinary nested asset path', () => {
+ assert.equal(safeRelativePath('assets/app-7c1f4b2e.js'), 'assets/app-7c1f4b2e.js');
+ });
+
+ it('rejects a malformed percent escape rather than guessing', () => {
+ assert.equal(safeRelativePath('%zz'), null);
+ });
+});
diff --git a/middleware/test/tailwindArbitraryValueScan.test.ts b/middleware/test/tailwindArbitraryValueScan.test.ts
new file mode 100644
index 00000000..ba463cd2
--- /dev/null
+++ b/middleware/test/tailwindArbitraryValueScan.test.ts
@@ -0,0 +1,152 @@
+/**
+ * The ingest gate for Tailwind arbitrary values (epic #470 C8 / §4.3a).
+ *
+ * The scanner is the enforcement half of "plugins ship no CSS": the stylesheet
+ * core serves carries a finite vocabulary, so a class outside it renders
+ * unstyled with no error anywhere. These cases pin both directions — what must
+ * be rejected, and what must NOT be, because a scanner that cries wolf on
+ * ordinary bundle text gets switched off and then enforces nothing.
+ */
+
+import { describe, it } from 'node:test';
+import { strict as assert } from 'node:assert';
+
+import {
+ formatArbitraryValueOffenders,
+ scanForArbitraryTailwindValues,
+} from '../src/plugins/tailwindArbitraryValueScan.js';
+
+function scan(content: string, file = 'ui/assets/app-1234abcd.js') {
+ return scanForArbitraryTailwindValues([{ path: file, content }]);
+}
+
+describe('scanForArbitraryTailwindValues — rejects', () => {
+ it('an arbitrary length', () => {
+ const found = scan('const c = "flex w-[137px] p-4";');
+ assert.equal(found.length, 1);
+ assert.equal(found[0]?.token, 'w-[137px]');
+ assert.equal(found[0]?.kind, 'arbitrary-value');
+ });
+
+ it('an arbitrary colour — the case the vocabulary exists to prevent', () => {
+ const found = scan('e.className = "bg-[#abc] text-fg";');
+ assert.equal(found.length, 1);
+ assert.equal(found[0]?.token, 'bg-[#abc]');
+ });
+
+ it('a dashed utility head with an arbitrary track list', () => {
+ const found = scan('"grid grid-cols-[1fr_2fr] gap-4"');
+ assert.equal(found[0]?.token, 'grid-cols-[1fr_2fr]');
+ });
+
+ it('an arbitrary value behind variant prefixes', () => {
+ const found = scan('"md:hover:w-[42rem]"');
+ assert.equal(found[0]?.token, 'md:hover:w-[42rem]');
+ });
+
+ it('a dashed variant prefix form', () => {
+ const found = scan('"group-hover:w-[137px]"');
+ assert.equal(found[0]?.token, 'group-hover:w-[137px]');
+ });
+
+ it('a peer variant prefix form', () => {
+ const found = scan('"peer-focus:bg-[#abc]"');
+ assert.equal(found[0]?.token, 'peer-focus:bg-[#abc]');
+ });
+
+ it('a numeric breakpoint prefix form', () => {
+ const found = scan('"2xl:w-[137px]"');
+ assert.equal(found[0]?.token, '2xl:w-[137px]');
+ });
+
+ it('a negative utility', () => {
+ const found = scan('"-mt-[3px]"');
+ assert.equal(found[0]?.token, '-mt-[3px]');
+ });
+
+ it('a negative utility behind a variant prefix', () => {
+ const found = scan('"lg:-mt-[3px]"');
+ assert.equal(found[0]?.token, 'lg:-mt-[3px]');
+ });
+
+ it('an arbitrary variant', () => {
+ const found = scan('"[&>tr]:border-border"');
+ assert.equal(found.length, 1);
+ assert.equal(found[0]?.kind, 'arbitrary-variant');
+ });
+
+ it('reports the 1-based line and the file', () => {
+ const found = scan('a\nb\nconst c = "p-[3px]";\n', 'ui/main-99887766.js');
+ assert.equal(found[0]?.line, 3);
+ assert.equal(found[0]?.file, 'ui/main-99887766.js');
+ });
+
+ it('caps the offender list so a hostile bundle cannot become the payload', () => {
+ const line = Array.from({ length: 200 }, (_, i) => `w-[${String(i)}px]`).join(' ');
+ const found = scan(line);
+ assert.ok(found.length <= 25, `expected <= 25 offenders, got ${String(found.length)}`);
+ });
+
+ it('deduplicates the same token on the same line', () => {
+ const found = scan('"w-[1px] w-[1px] w-[1px]"');
+ assert.equal(found.length, 1);
+ });
+});
+
+describe('scanForArbitraryTailwindValues — accepts', () => {
+ it('the whole proof bundle vocabulary', () => {
+ const bundle = [
+ 'const CARD = "rounded-md border border-border bg-bg-elevated p-4 shadow-sm";',
+ 'const H = "text-lg font-semibold text-fg-strong";',
+ 'const B = "hover:bg-accent-hover disabled:opacity-50 md:grid-cols-2";',
+ 'grid.className = "grid grid-cols-1 gap-4 max-w-4xl mx-auto";',
+ ].join('\n');
+ assert.deepEqual(scan(bundle), []);
+ });
+
+ it('array indexing — the obvious false positive', () => {
+ assert.deepEqual(scan('const x = arr[0] + items[i] + m[key];'), []);
+ });
+
+ it('property access on a dashed-looking identifier', () => {
+ assert.deepEqual(scan('const v = obj["data-theme"]; const w = a[b];'), []);
+ });
+
+ it('a regex character class', () => {
+ assert.deepEqual(scan('const re = /^[a-z0-9]+$/;'), []);
+ });
+
+ it('a destructured import with brackets', () => {
+ assert.deepEqual(scan('const [state, setState] = useState(0);'), []);
+ });
+
+ it('an empty bundle list', () => {
+ assert.deepEqual(scanForArbitraryTailwindValues([]), []);
+ });
+});
+
+describe('formatArbitraryValueOffenders', () => {
+ it('renders one line per offender with file, line and token', () => {
+ const rendered = formatArbitraryValueOffenders(scan('"w-[137px]"'));
+ assert.match(rendered, /ui\/assets\/app-1234abcd\.js:1 — w-\[137px\] \(arbitrary-value\)/);
+ });
+});
+
+describe('documented limits — pinned so the next reader is not misled', () => {
+ it('cannot see a class assembled at runtime (accepted false negative)', () => {
+ assert.deepEqual(scan('const c = "w-[" + n + "px]";'), []);
+ });
+
+ it('cannot see unicode-escaped brackets (accepted false negative)', () => {
+ assert.deepEqual(scan('const c = "w-\\u005b10px\\u005d";'), []);
+ });
+
+ it('matches bracket text that is not a class (accepted false positive)', () => {
+ // Reported on purpose: the offender line carries file+line+token so an
+ // author can see at a glance that the hit is not a class name. Widening
+ // the regex to exclude this would also start missing real offenders.
+ const found = scan('const msg = "see step-[2] of the guide";');
+ assert.equal(found.length, 1);
+ assert.equal(found[0]?.token, 'step-[2]');
+ });
+});
diff --git a/specs/470-dev-platform-plugin/README.md b/specs/470-dev-platform-plugin/README.md
index c55f0540..f11fb8f9 100644
--- a/specs/470-dev-platform-plugin/README.md
+++ b/specs/470-dev-platform-plugin/README.md
@@ -19,7 +19,8 @@ it at all.
| **`implementation.md`** | *In what order, and what did the detailed design change?* Six design passes synthesised: the five corrected decisions, six verified live bugs, the C1→C13 / P0→P6 PR sequence, and the six blocking decisions | Before starting a phase |
| **`core-decoupling-checklist.md`** | *What is still coupled?* 276 items across 18 zones, ~49,100 LOC / ~200 files, with `file:line` and DELETE / MOVE / GENERICISE per item | While doing the removal |
| **`acceptance.md`** | *Did every capability survive, and does it install?* 35 endpoints, 3 chat tools, 3 live background loops, 4 UI screens, CLI — each with a probe, plus FIVE capabilities marked unreachable. Plus install/uninstall/upgrade criteria | Before claiming a phase is done |
-| **`plugin-tailwind-subset.probe.css`** | *Can a distributed plugin ship a UI without shipping CSS?* Measured reference artifact (7.7 KB gzip) — not built, not shipped | When implementing P3b |
+| **`plugin-tailwind-subset.probe.css`** | *Can a distributed plugin ship a UI without shipping CSS?* Measured reference artifact (7.7 KB gzip) — not built, not shipped. **Superseded by the real build in C8**; kept as the sizing reference it was | For the sizing argument only |
+| **`plugin-ui-vocabulary.md`** | *What may a plugin UI actually use?* The shipped C8 contract: the utility vocabulary, the no-arbitrary-values rule and its enforcement, the ZIP layout, the iframe boundary | Before writing or reviewing any plugin UI |
| **`decoupling-baseline.json`** | The committed reference count the CI ratchet enforces | Never by hand — use `--update` |
---
@@ -85,6 +86,49 @@ it at all.
contract still holds, so a snapshot updated without a bump is the same silent break C1 exists
to stop. Full table in `middleware/packages/plugin-api/README.md`.
+### C8 — the abandonment checkpoint (G7 / P3b)
+
+**Shipped.** The plugin UI mechanism, end to end. Everything before this point was
+core capability work; this is the piece the whole extraction was blocked on, and it is
+where the epic can honestly stop with a net-positive result.
+
+- **Token bridge extracted.** The `@theme inline` block left `globals.css` for
+ `web-ui/app/_lib/tailwind-bridge.css`; the shell imports it, the generated plugin
+ stylesheet imports it, and the drift the C8 work exists to end is now structurally
+ impossible rather than asked for in a comment.
+- **Plugin stylesheet generated, not hand-written.**
+ `web-ui/scripts/build-plugin-ui-css.mjs` compiles a finite Tailwind v4 vocabulary
+ (`@import 'tailwindcss' source(none)` + `@source inline(...)`) from the same Lume
+ tokens the shell uses, into the committed
+ `middleware/assets/plugin-ui/plugin-ui.css`. CI regenerates and diffs it inside the
+ existing web-ui job. Vocabulary documented in `plugin-ui-vocabulary.md`.
+- **`admin-ui.css` retired as source.** `src/admin-ui/harness-admin-css.ts` — 345
+ hand-maintained lines whose own header asked the next maintainer to keep two
+ palettes "roughly in sync" — is deleted. `/api/_harness/admin-ui.css` is now an
+ alias for the generated sheet and still carries the `.harness-*` helpers, so no
+ shipped plugin admin UI is restyled by the upgrade.
+- **Static serving for SPA bundles.** A plugin ships `ui/` (multi-file, hashed
+ assets) and core serves it at `/p//ui/…` — traversal-checked (lexical +
+ realpath, root realpath'd too), extension-allowlisted, no directory listing,
+ immutable caching for hashed files, CSP on the document. `.woff2` was added to the
+ ZIP allowlist **scoped to `ui/`**. `.css` was NOT added and must not be — that
+ absence is the enforcement.
+- **Host page.** `/plugin-ui/` in web-ui embeds the bundle in a sandboxed
+ iframe and passes `?theme=&palette=&locale=`, closing both §2.3 regressions
+ (`next/font` and `data-theme` do not cross an iframe). Nav entries come from the
+ existing `ctx.uiRoutes.registerNav`.
+- **Ingest check.** Arbitrary Tailwind values in `ui/**/*.js` are rejected at package
+ ingest with file, line and token. Its false-positive and false-negative limits are
+ documented rather than implied.
+- **Proved, not asserted.** `middleware/test/fixtures/plugin-ui-proof/` is a throwaway
+ SPA driven through the real ingest path and the real routers, including the two
+ negative cases: a `.css` inside `ui/` is rejected at extraction, an arbitrary value
+ is rejected at ingest.
+
+Artifact: 69.5 KB raw / **11.8 KB gzip** / 9.0 KB brotli. The measured probe was 7.7 KB
+gzip against a narrower vocabulary and without the baseline + `.harness-*` layer that
+replaces the separately-served `admin-ui.css`.
+
### Still held back
- **DynamicAgentRuntime rollback** — two attempts rejected. The current one does not cover
@@ -99,9 +143,10 @@ Two are genuinely blocking and belong to the maintainer, not the implementer:
renders a core-compiled React card. An iframe per tool call is not acceptable. Either a
declarative card schema, or an accepted degradation to a plain `ToolRow` for
out-of-repo plugins. This is the one place "no hardcoding" and "no downgrade" conflict.
-2. **G7 fallback.** If fixing the plugin asset pipeline proves too costly, option E is an
- npm-published UI package that web-ui optionally installs — which weakens "no hardcoding"
- to "no source in core". Worth deciding deliberately rather than drifting into.
+2. ~~**G7 fallback.**~~ **Resolved by C8.** Option B is built and proved: a plugin ships a
+ compiled SPA, core serves it and the stylesheet it links. Option E (an npm-published UI
+ package web-ui optionally installs) is no longer needed and should not be revived — it
+ only ever weakened "no hardcoding" to "no source in core".
Then P3's extension points (H1 public paths + prefix ownership, H2 conductor step-kind
registry, G2/G3/G4), which everything else waits on.
diff --git a/specs/470-dev-platform-plugin/plan.md b/specs/470-dev-platform-plugin/plan.md
index 7749828e..a4505215 100644
--- a/specs/470-dev-platform-plugin/plan.md
+++ b/specs/470-dev-platform-plugin/plan.md
@@ -292,6 +292,21 @@ page) is exactly one hand-written HTML file.
### 4.3a Plugins use Tailwind — so they ship no CSS at all
+> **Status: SHIPPED (C8).** Built as described, with three corrections the
+> implementation forced. (1) The artifact is **11.8 KB gzip**, not 7.7 — the probe's
+> vocabulary was too narrow for a real SPA, and the shipped sheet also absorbs the
+> baseline element styling and the `.harness-*` helpers that `harness-admin-css.ts`
+> used to serve separately. (2) "Reject `[` in class attributes at package ingest" was
+> under-specified, as §2.5 already noted: ingest sees compiled Vite JS, so the check is
+> a two-pattern textual scan over `ui/**/*.js` with its false-positive and
+> false-negative limits written down (`tailwindArbitraryValueScan.ts`). (3) The font
+> problem is worse than "the plugin renders in the fallback stack": `theme.css`
+> composes `--font-sans: var(--font-geist), …`, and an undefined var invalidates the
+> whole declaration, so the plugin drops to the browser's serif default. The generated
+> sheet therefore always binds those three variables. The contract is documented in
+> `plugin-ui-vocabulary.md`.
+
+
The `.css` gap above is real but it is the **wrong thing to fix**. web-ui is a Tailwind v4
project; if plugin markup is required to use Tailwind utilities, a plugin never needs to
ship a stylesheet — it links one that core serves.
diff --git a/specs/470-dev-platform-plugin/plugin-ui-vocabulary.md b/specs/470-dev-platform-plugin/plugin-ui-vocabulary.md
new file mode 100644
index 00000000..d6f7bf13
--- /dev/null
+++ b/specs/470-dev-platform-plugin/plugin-ui-vocabulary.md
@@ -0,0 +1,268 @@
+# The plugin UI vocabulary
+
+**The contract a distributed plugin's user interface is built against.**
+Shipped by C8 (`plan.md` §4.3a). Read this before writing a plugin UI, and
+before widening the vocabulary.
+
+---
+
+## The one-paragraph version
+
+A plugin ships **no stylesheet**. `.css` is absent from the plugin-ZIP
+extension allowlist, will stay absent, and that absence *is* the enforcement.
+A plugin's HTML links `/bot-api/_harness/plugin-ui.css`, which core generates
+from web-ui's own Lume design tokens. Everything a plugin can express is
+therefore a token, which is the point: it follows the operator's active
+palette and light/dark mode automatically, and it cannot hardcode a hex and
+drift. In exchange, a plugin may only use the utility classes listed below,
+because Tailwind emits only what it has seen at build time and a plugin
+installed at runtime from another repository is never seen.
+
+---
+
+## Where everything lives
+
+| Thing | Path |
+|---|---|
+| Token bridge (shared with the shell — never copy it) | `web-ui/app/_lib/tailwind-bridge.css` |
+| Design tokens | `web-ui/app/_lib/theme.css` |
+| Vocabulary source | `web-ui/scripts/plugin-ui.source.css` |
+| Build script | `web-ui/scripts/build-plugin-ui-css.mjs` |
+| Committed artifact | `middleware/assets/plugin-ui/plugin-ui.css` |
+| Served at | `GET /api/_harness/plugin-ui.css` (browser: `/bot-api/…`) |
+| Legacy alias | `GET /api/_harness/admin-ui.css` — same bytes |
+| Bundle served at | `GET /p//ui/…` |
+| Host page | `/plugin-ui/` in web-ui |
+| Ingest check | `middleware/src/plugins/tailwindArbitraryValueScan.ts` |
+| Worked example | `middleware/test/fixtures/plugin-ui-proof/` |
+
+---
+
+## The hard constraint: no arbitrary values
+
+```
+✗ w-[137px] ✗ bg-[#abc] ✗ grid-cols-[1fr_2fr]
+✗ md:hover:w-[42rem] ✗ [&>tr]:border-border
+```
+
+An *exact* arbitrary value **can** be pre-generated — `@source
+inline("w-[137px]")` emits precisely that class. The **unbounded universe** of
+them cannot. So an arbitrary value core has not been told about renders
+unstyled, silently, on the operator's screen and nowhere else. That is the
+worst failure mode available, which is why it is rejected at package ingest
+rather than left to discover in production.
+
+The ingest scanner reads the **compiled bundle**, not JSX. By the time a
+package arrives, `className={cn('p-4', wide && 'w-[137px]')}` has become the
+string literals `"p-4"` and `"w-[137px]"` in `ui/**/*.js`, and both are plain
+substrings. Two patterns run:
+
+| Pattern | Matches |
+|---|---|
+| `(?-]+)?)` | `[&>tr]:border`, `[&_p]:mt-2` |
+
+### Known limits, stated rather than papered over
+
+- **False positives are possible.** The scan is textual, and text in a bundle
+ is not only class names. Prose like `"see step-[2] of the guide"` is
+ reported. The mitigation is the report, not the regex: every offender
+ carries file, 1-based line and the matched token, so an author sees in one
+ glance that the hit is not a class. Two narrowings keep the rate low — the
+ utility head must be lower-case-and-dashes (so `arr[i]` and `getFoo[0]`
+ never match), and the bracket body may not contain whitespace, quotes or
+ backticks (so most prose and most expressions drop out).
+ *This is not theoretical: the first run of the proof fixture rejected its
+ own explanatory comment.*
+- **False negatives are possible**, and that is the safer direction. A bundle
+ that assembles a class at runtime defeats any static check. Nothing here
+ claims otherwise — the vocabulary is the contract, this is its cheap
+ enforcement, and a plugin that routes around it merely ends up unstyled.
+- **Only `ui/**/*.js` and `ui/**/*.mjs` are scanned**, capped at 200 files /
+ 8 MB. A bundle elsewhere is neither scanned nor served.
+
+---
+
+## The vocabulary
+
+Canonical source is `web-ui/scripts/plugin-ui.source.css`; this table is the
+human-readable form of it. Brace ranges expand — `p-{0..12}` means `p-0`
+through `p-12`.
+
+### Layout
+
+| Group | Classes |
+|---|---|
+| Display | `flex` `inline-flex` `grid` `block` `inline-block` `inline` `contents` `hidden` |
+| Flex | `flex-row` `flex-row-reverse` `flex-col` `flex-wrap` `flex-nowrap` `flex-1` `flex-auto` `flex-initial` `flex-none` · `shrink-0/1` `grow-0/1` |
+| Alignment | `items-{start,center,end,baseline,stretch}` · `justify-{start,center,end,between,around,evenly}` · `self-{auto,start,center,end,stretch}` |
+| Grid | `grid-cols-{1..6}` · `col-span-{1..6}` |
+| Gap | `gap-{0..8}` `gap-x-{0..8}` `gap-y-{0..8}` |
+| Size | `w-/h-{full,auto,fit,screen}` · `w-/h-{0,1,2,3,4,5,6,8,10,12,16,20,24,32}` · `min-w-/min-h-{0,full}` · `max-w-{none,full,xs…7xl}` |
+| Overflow | `overflow-{auto,hidden,visible,x-auto,y-auto}` |
+| Position | `relative` `absolute` `fixed` `sticky` `static` · `inset-/top-/right-/bottom-/left-{0,auto}` · `z-{0,10,20,30,40,50}` |
+| Centering | `mx-auto` |
+
+### Spacing
+
+`p-/px-/py-/pt-/pr-/pb-/pl-{0..12}` · `m-/mx-/my-/mt-/mr-/mb-/ml-{0..12}` ·
+`space-x-/space-y-{0..6}`
+
+### Typography
+
+| Group | Classes |
+|---|---|
+| Size | `text-{xs,sm,base,lg,xl,2xl,3xl,4xl}` |
+| Weight | `font-{normal,medium,semibold,bold}` |
+| Family | `font-{sans,mono,serif}` |
+| Align | `text-{left,center,right,justify}` |
+| Transform | `truncate` `uppercase` `lowercase` `capitalize` `normal-case` |
+| Decoration | `underline` `no-underline` `line-through` `italic` `not-italic` |
+| Leading | `leading-{none,tight,snug,normal,relaxed,loose}` |
+| Tracking | `tracking-{tighter,tight,normal,wide,wider}` |
+| Wrapping | `whitespace-{normal,nowrap,pre,pre-wrap}` · `break-{words,all,keep}` |
+| Lists | `list-none` `list-disc` `list-decimal` `list-inside` |
+| Numerals | `tabular-nums` |
+| Vertical | `align-{top,middle,bottom,baseline}` |
+
+### Colour — the Lume tokens, and only those
+
+There is **no Tailwind palette here**. `bg-blue-500` does not exist and will
+not be added. The available colour names are the design system's semantic
+roles, each wired to the runtime CSS variable:
+
+| Prefix | Values | Variants |
+|---|---|---|
+| `bg-` | `bg` `bg-soft` `bg-elevated` `surface` `accent` `accent-hover` `accent-subtle` `danger` `success` `warning` `transparent` | `hover:` `focus:` |
+| `text-` | `fg` `fg-strong` `fg-muted` `fg-subtle` `accent` `accent-hover` `danger` `success` `warning` `bg` | `hover:` `focus:` |
+| `border-` | `border` `border-strong` `accent` `danger` `success` `warning` `transparent` | `hover:` `focus:` |
+| `decoration-` | `accent` `fg-muted` | `hover:` |
+
+### Borders and shape
+
+`border` `border-{0,2,4}` · `border-{t,r,b,l}` ·
+`border-{solid,dashed,dotted,none}` ·
+`rounded` `rounded-{none,sm,md,lg,xl,full}` ·
+`shadow` `shadow-{none,sm,md,lg}` · `divide-y` `divide-x`
+
+### Interaction and state
+
+`opacity-{0,25,50,60,75,100}` (+ `hover:` `focus:`) ·
+`cursor-{pointer,default,not-allowed,wait,text}` ·
+`select-{none,text,all}` · `pointer-events-{none,auto}` ·
+`transition` `transition-{none,all,colors,opacity,transform}` ·
+`duration-{75,100,150,200,300,500}` · `ease-{linear,in,out,in-out}` ·
+`animate-{none,spin,pulse}` ·
+`disabled:{opacity-50,cursor-not-allowed,pointer-events-none}` ·
+`focus-visible:outline-none` · `sr-only` `not-sr-only`
+
+### Responsive
+
+Breakpoints `sm:` `md:` `lg:` `xl:` are available on:
+`flex` `grid` `block` `inline-block` `hidden` · `grid-cols-{1..4}` ·
+`flex-{row,col}` (sm/md/lg) · `p-/px-/py-{0,2,4,6,8}` (sm/md/lg) ·
+`text-{sm,base,lg,xl,2xl}` (sm/md/lg) ·
+`max-w-{sm,md,lg,xl,2xl,4xl}` (sm/md/lg)
+
+### Baseline element styling
+
+Plugins get sensible defaults for `body`, headings, `p`, `a`, `code`, `pre`,
+`hr`, `table`/`th`/`td`, `input`/`select`/`textarea`/`button` — all
+token-driven, all in `@layer base` so any utility class above wins over them.
+
+### `.harness-*` compatibility helpers — frozen
+
+`.harness-admin` `.harness-subtitle` `.harness-empty` `.harness-btn`
+`.harness-btn--primary` `.harness-input` `.harness-table`
+`.harness-banner-error` `.harness-banner-info`
+
+These exist because shipped plugin admin UIs already link them via
+`admin-ui.css`. They are kept so an upgrade does not restyle every installed
+plugin, and they are now generated from the same tokens as everything else
+rather than hand-mirrored. **New UIs should use the utilities above.** The
+helper set is frozen: it will not be extended.
+
+---
+
+## What a plugin ships
+
+```
+my-plugin.zip
+├── manifest.yaml
+├── package.json
+├── dist/plugin.js
+└── ui/
+ ├── index.html ← links /bot-api/_harness/plugin-ui.css
+ └── assets/
+ ├── app-7c1f4b2e.js ← hashed → cached immutably
+ └── logo.svg
+```
+
+Allowed inside `ui/`: `.html` `.js` `.mjs` `.map` `.json` `.svg` `.png`
+`.jpg`/`.jpeg` `.woff2` `.txt`. **Not** `.css` — and `.woff2` is allowed
+*only* under `ui/`, since nothing else in a package has business shipping a
+font.
+
+### The iframe boundary — two things that do not cross it
+
+An iframe is a separate document. Two silent regressions follow
+(`implementation.md` §2.3), and both are handled:
+
+1. **`next/font` does not cross.** The shell's faces are injected into
+ web-ui's document only. The generated stylesheet therefore re-binds
+ `--font-geist`, `--font-geist-mono` and `--font-source-serif`. This is not
+ cosmetic: `theme.css` composes `--font-sans: var(--font-geist), system-ui,
+ …`, and an *undefined* var invalidates the whole declaration, dropping the
+ UI to the browser's serif default.
+2. **`data-theme` / `data-palette` do not cross.** Without them a plugin
+ renders light inside a shell the operator forced dark — a bug that looks
+ like the plugin's fault. The host page passes `?theme=&palette=&locale=`
+ and the plugin mirrors them onto its own `` before first paint:
+
+```html
+
+
+```
+
+### Appearing in the shell navigation
+
+Use the existing nav contribution API (PR #536) from `activate()`:
+
+```ts
+ctx.uiRoutes.registerNav({
+ navId: 'main',
+ href: `/plugin-ui/${pluginId}`,
+ cluster: 'adminCluster',
+ label: { en: 'My Plugin', de: 'Mein Plugin' },
+});
+```
+
+`href` is validated as an in-app single-slash path, so `/plugin-ui/`
+resolves and `//evil.example` does not.
+
+---
+
+## Widening the vocabulary
+
+Widen it from what a real ported page needs — never speculatively. Every line
+is a promise to plugin authors and a cost in bytes on every plugin UI load.
+
+1. Edit `web-ui/scripts/plugin-ui.source.css`.
+2. `cd web-ui && npm run plugin-ui:css`.
+3. Commit the regenerated `middleware/assets/plugin-ui/plugin-ui.css`.
+4. Update this document.
+
+CI regenerates and diffs (`npm run plugin-ui:css:check`, inside the existing
+web-ui job), so an edit to the source, the tokens or the bridge that was not
+regenerated fails loudly rather than shipping a stylesheet that disagrees with
+the shell.
diff --git a/web-ui/app/_lib/tailwind-bridge.css b/web-ui/app/_lib/tailwind-bridge.css
new file mode 100644
index 00000000..df75b12f
--- /dev/null
+++ b/web-ui/app/_lib/tailwind-bridge.css
@@ -0,0 +1,52 @@
+/* -------------------------------------------------------------------------- */
+/* Tailwind token bridge (Lume) — SHARED SOURCE, never inline a second copy. */
+/* */
+/* Extracted out of `globals.css` for epic #470 C8 (plan.md §4.3a): the shell */
+/* stylesheet and the generated plugin stylesheet */
+/* (`middleware/assets/plugin-ui/plugin-ui.css`, built by */
+/* `web-ui/scripts/build-plugin-ui-css.mjs`) must map the Lume tokens onto */
+/* Tailwind's `--color-*` / `--font-*` / `--radius-*` / `--shadow-*` namespace */
+/* from ONE file. Two copies of this block are the exact drift C8 exists to */
+/* end: a plugin whose `bg-accent` resolved against a stale palette would be */
+/* indistinguishable from a working one until someone re-themed the shell. */
+/* */
+/* `inline` keeps these referencing the runtime CSS vars in `_lib/theme.css`, */
+/* so utilities like `bg-accent` / `text-fg-muted` follow the active palette */
+/* and light/dark mode instead of freezing a value. */
+/* This is the §2 / item-4 mapping: the design-system swap stays a single-file */
+/* change at the token tier. */
+/* -------------------------------------------------------------------------- */
+@theme inline {
+ --color-bg: var(--bg);
+ --color-bg-soft: var(--bg-soft);
+ --color-bg-elevated: var(--bg-elevated);
+ --color-surface: var(--surface);
+ --color-fg: var(--fg);
+ --color-fg-strong: var(--fg-strong);
+ --color-fg-muted: var(--fg-muted);
+ --color-fg-subtle: var(--fg-subtle);
+ --color-accent: var(--accent);
+ --color-accent-hover: var(--accent-hover);
+ --color-accent-subtle: var(--accent-subtle);
+ --color-border: var(--border);
+ --color-border-strong: var(--border-strong);
+ --color-danger: var(--danger);
+ --color-success: var(--success);
+ --color-warning: var(--warning);
+
+ --font-sans: var(--font-sans);
+ --font-mono: var(--font-mono);
+ --font-serif: var(--font-serif);
+
+ --radius-sm: var(--radius-sm);
+ --radius-md: var(--radius-md);
+ --radius-lg: var(--radius-lg);
+
+ /* Elevation (§2.10): route the stock shadow-sm/md/lg utilities through the
+ Lume elevation tokens, so every existing `shadow-md` popover and
+ `shadow-lg` modal in the codebase gets elev.popover / elev.modal
+ (including the accent-glow components) without per-component edits. */
+ --shadow-sm: var(--shadow-sm);
+ --shadow-md: var(--shadow-md);
+ --shadow-lg: var(--shadow-lg);
+}
diff --git a/web-ui/app/globals.css b/web-ui/app/globals.css
index ab803202..fdb6ca4f 100644
--- a/web-ui/app/globals.css
+++ b/web-ui/app/globals.css
@@ -1,53 +1,14 @@
@import 'tailwindcss';
@import './_lib/theme.css';
+/* The Lume → Tailwind token bridge. Shared verbatim with the generated plugin
+ stylesheet (epic #470 C8) so the two cannot drift — see the file header. */
+@import './_lib/tailwind-bridge.css';
/* Custom breakpoint above Tailwind's 2xl (1536px) for ultrawide / 4K screens. */
@theme {
--breakpoint-3xl: 120rem; /* 1920px */
}
-/* -------------------------------------------------------------------------- */
-/* Tailwind token bridge (Lume). `inline` keeps these referencing the runtime */
-/* CSS vars in _lib/theme.css, so utilities like `bg-accent` / `text-fg-muted` */
-/* follow the active palette + light/dark mode instead of freezing a value. */
-/* This is the §2 / item-4 mapping: the design-system swap stays a single-file */
-/* change at the token tier. */
-/* -------------------------------------------------------------------------- */
-@theme inline {
- --color-bg: var(--bg);
- --color-bg-soft: var(--bg-soft);
- --color-bg-elevated: var(--bg-elevated);
- --color-surface: var(--surface);
- --color-fg: var(--fg);
- --color-fg-strong: var(--fg-strong);
- --color-fg-muted: var(--fg-muted);
- --color-fg-subtle: var(--fg-subtle);
- --color-accent: var(--accent);
- --color-accent-hover: var(--accent-hover);
- --color-accent-subtle: var(--accent-subtle);
- --color-border: var(--border);
- --color-border-strong: var(--border-strong);
- --color-danger: var(--danger);
- --color-success: var(--success);
- --color-warning: var(--warning);
-
- --font-sans: var(--font-sans);
- --font-mono: var(--font-mono);
- --font-serif: var(--font-serif);
-
- --radius-sm: var(--radius-sm);
- --radius-md: var(--radius-md);
- --radius-lg: var(--radius-lg);
-
- /* Elevation (§2.10): route the stock shadow-sm/md/lg utilities through the
- Lume elevation tokens, so every existing `shadow-md` popover and
- `shadow-lg` modal in the codebase gets elev.popover / elev.modal
- (including the accent-glow components) without per-component edits. */
- --shadow-sm: var(--shadow-sm);
- --shadow-md: var(--shadow-md);
- --shadow-lg: var(--shadow-lg);
-}
-
/* -------------------------------------------------------------------------- */
/* Global, token-driven baseline. Palette + typography live in _lib/theme.css */
/* so the design-system swap is a single-file change at the token tier. */
diff --git a/web-ui/app/plugin-ui/[pluginId]/_components/PluginUiFrame.tsx b/web-ui/app/plugin-ui/[pluginId]/_components/PluginUiFrame.tsx
new file mode 100644
index 00000000..dc5844ad
--- /dev/null
+++ b/web-ui/app/plugin-ui/[pluginId]/_components/PluginUiFrame.tsx
@@ -0,0 +1,106 @@
+'use client';
+
+import { useEffect, useMemo, useState } from 'react';
+import { useLocale, useTranslations } from 'next-intl';
+
+/**
+ * The iframe that hosts a plugin's compiled SPA (epic #470 C8).
+ *
+ * WHY THE PARAMS EXIST AT ALL. An iframe is a separate document, so two
+ * things the shell takes for granted silently do not cross the boundary
+ * (`implementation.md` §2.3 in the epic #470 spec directory):
+ *
+ * - `next/font` injects its faces into web-ui's own document only. The
+ * generated plugin stylesheet re-binds the font variables for exactly
+ * this reason.
+ * - `data-theme` / `data-palette` sit on the shell's ``. Without them
+ * the plugin renders in light mode inside a shell the operator forced
+ * dark — a bug that looks like the plugin's fault.
+ *
+ * So the host passes `?theme=&palette=&locale=` and the plugin's `index.html`
+ * mirrors them onto its own `` element. Everything else — the actual
+ * colours — then resolves through the one stylesheet core serves.
+ *
+ * The theme is read from the live DOM rather than from the cookie, and a
+ * MutationObserver re-reads it, so flipping the appearance in the header
+ * updates the embedded UI without a reload.
+ *
+ * SANDBOX. `allow-scripts allow-forms allow-popups` and NOT
+ * `allow-same-origin`: the bundle is third-party code and this keeps it out
+ * of the operator's cookies and localStorage on our origin. A plugin needing
+ * authenticated calls does them from its own backend router, which is where
+ * its authentication lives anyway.
+ */
+
+type Theme = 'light' | 'dark';
+
+function readTheme(): Theme {
+ if (typeof document === 'undefined') return 'light';
+ const forced = document.documentElement.getAttribute('data-theme');
+ if (forced === 'dark' || forced === 'light') return forced;
+ return typeof window !== 'undefined' &&
+ window.matchMedia('(prefers-color-scheme: dark)').matches
+ ? 'dark'
+ : 'light';
+}
+
+function readPalette(): string {
+ if (typeof document === 'undefined') return 'lagoon';
+ return document.documentElement.getAttribute('data-palette') ?? 'lagoon';
+}
+
+export function PluginUiFrame({ pluginId }: { pluginId: string }): React.ReactElement {
+ const t = useTranslations('pluginUi');
+ const locale = useLocale();
+ const [theme, setTheme] = useState('light');
+ const [palette, setPalette] = useState('lagoon');
+ const [mounted, setMounted] = useState(false);
+
+ useEffect(() => {
+ const sync = (): void => {
+ setTheme(readTheme());
+ setPalette(readPalette());
+ };
+ sync();
+ setMounted(true);
+
+ const observer = new MutationObserver(sync);
+ observer.observe(document.documentElement, {
+ attributes: true,
+ attributeFilter: ['data-theme', 'data-palette'],
+ });
+ const media = window.matchMedia('(prefers-color-scheme: dark)');
+ media.addEventListener('change', sync);
+ return () => {
+ observer.disconnect();
+ media.removeEventListener('change', sync);
+ };
+ }, []);
+
+ const src = useMemo(() => {
+ const params = new URLSearchParams({ theme, palette, locale });
+ return `/p/${encodeURIComponent(pluginId)}/ui/index.html?${params.toString()}`;
+ }, [pluginId, theme, palette, locale]);
+
+ // Rendering the iframe before the theme is known would load the bundle once
+ // in the wrong appearance and again on correction. One paint, one load.
+ if (!mounted) {
+ return (
+
+ {t('loading')}
+
+ );
+ }
+
+ return (
+
+ );
+}
diff --git a/web-ui/app/plugin-ui/[pluginId]/page.tsx b/web-ui/app/plugin-ui/[pluginId]/page.tsx
new file mode 100644
index 00000000..17ba635f
--- /dev/null
+++ b/web-ui/app/plugin-ui/[pluginId]/page.tsx
@@ -0,0 +1,68 @@
+import type { Metadata } from 'next';
+import { notFound } from 'next/navigation';
+import { getTranslations } from 'next-intl/server';
+
+import { PluginUiFrame } from './_components/PluginUiFrame';
+
+/**
+ * Host page for a plugin-supplied SPA (epic #470 C8 / G7).
+ *
+ * A plugin distributed as a package cannot compile pages into web-ui — that
+ * would be a hardcoded core reference, which the epic's constraint 2 forbids.
+ * It ships a compiled bundle inside its ZIP instead, core serves it at
+ * `/p//ui/`, and this route embeds it.
+ *
+ * A plugin puts its entry in the shell's navigation with the existing nav
+ * contribution API (PR #536):
+ *
+ * ctx.uiRoutes.registerNav({
+ * navId: 'main',
+ * href: `/plugin-ui/${pluginId}`,
+ * cluster: 'adminCluster',
+ * label: { en: 'My Plugin', de: 'Mein Plugin' },
+ * });
+ *
+ * `href` is validated as an in-app single-slash path, so this route is
+ * reachable from a nav contribution while `//evil.example` is not.
+ */
+
+/** Mirrors the plugin-id charset gate in `manifestLoader`. */
+const PLUGIN_ID = /^[a-z0-9](?:[a-z0-9._-]{0,62}[a-z0-9])?$/;
+
+export async function generateMetadata({
+ params,
+}: {
+ params: Promise<{ pluginId: string }>;
+}): Promise {
+ const { pluginId } = await params;
+ const t = await getTranslations('pluginUi');
+ return { title: t('metaTitle', { pluginId }) };
+}
+
+export default async function PluginUiPage({
+ params,
+}: {
+ params: Promise<{ pluginId: string }>;
+}): Promise {
+ const { pluginId } = await params;
+ // Rejected here rather than passed on: an id outside the charset can never
+ // resolve to a package, and refusing it keeps a malformed value out of the
+ // iframe URL entirely.
+ if (!PLUGIN_ID.test(pluginId)) notFound();
+
+ const t = await getTranslations('pluginUi');
+
+ return (
+
+
+
+ {t('title', { pluginId })}
+
+
{t('subtitle')}
+
+
+
+
+
+ );
+}
diff --git a/web-ui/messages/de.json b/web-ui/messages/de.json
index 58afddb8..a6b5ce8b 100644
--- a/web-ui/messages/de.json
+++ b/web-ui/messages/de.json
@@ -4951,5 +4951,12 @@
"heading": "Build",
"version": "Web-UI-Version {version}"
}
+ },
+ "pluginUi": {
+ "metaTitle": "{pluginId} — Plugin-Oberfläche",
+ "title": "{pluginId}",
+ "subtitle": "Diese Ansicht stammt aus dem Plugin und läuft in einem abgeschotteten Frame.",
+ "frameTitle": "Oberfläche des Plugins {pluginId}",
+ "loading": "Plugin-Oberfläche wird geladen…"
}
}
diff --git a/web-ui/messages/en.json b/web-ui/messages/en.json
index 9db0545a..e15403a2 100644
--- a/web-ui/messages/en.json
+++ b/web-ui/messages/en.json
@@ -4951,5 +4951,12 @@
"heading": "Build",
"version": "Web UI version {version}"
}
+ },
+ "pluginUi": {
+ "metaTitle": "{pluginId} — Plugin UI",
+ "title": "{pluginId}",
+ "subtitle": "This screen is supplied by the plugin and runs in a sandboxed frame.",
+ "frameTitle": "User interface of the plugin {pluginId}",
+ "loading": "Loading the plugin interface…"
}
}
diff --git a/web-ui/package-lock.json b/web-ui/package-lock.json
index 358aa9d1..80b86b39 100644
--- a/web-ui/package-lock.json
+++ b/web-ui/package-lock.json
@@ -45,6 +45,7 @@
"eslint": "^9.39.5",
"eslint-config-next": "^16.3.1",
"jsdom": "^30.0.1",
+ "postcss": "8.5.23",
"tailwindcss": "^4.0.0",
"typescript": "^5.7.3",
"vitest": "^4.1.10"
diff --git a/web-ui/package.json b/web-ui/package.json
index 1c51364b..767db205 100644
--- a/web-ui/package.json
+++ b/web-ui/package.json
@@ -25,6 +25,8 @@
"test": "vitest run",
"test:watch": "vitest",
"i18n:check": "node scripts/i18n-validate.mjs",
+ "plugin-ui:css": "node scripts/build-plugin-ui-css.mjs",
+ "plugin-ui:css:check": "node scripts/build-plugin-ui-css.mjs --check",
"i18n:literals": "node scripts/i18n-literal-scan.mjs"
},
"dependencies": {
@@ -64,6 +66,7 @@
"eslint": "^9.39.5",
"eslint-config-next": "^16.3.1",
"jsdom": "^30.0.1",
+ "postcss": "8.5.23",
"tailwindcss": "^4.0.0",
"typescript": "^5.7.3",
"vitest": "^4.1.10"
diff --git a/web-ui/scripts/build-plugin-ui-css.mjs b/web-ui/scripts/build-plugin-ui-css.mjs
new file mode 100644
index 00000000..2773612e
--- /dev/null
+++ b/web-ui/scripts/build-plugin-ui-css.mjs
@@ -0,0 +1,194 @@
+#!/usr/bin/env node
+/**
+ * Generates the stylesheet core serves to plugin UIs (epic #470 C8 / §4.3a).
+ *
+ * source : web-ui/scripts/plugin-ui.source.css
+ * (+ app/_lib/theme.css and app/_lib/tailwind-bridge.css, imported)
+ * output : middleware/assets/plugin-ui/plugin-ui.css
+ *
+ * Usage:
+ * node scripts/build-plugin-ui-css.mjs # write the artifact
+ * node scripts/build-plugin-ui-css.mjs --check # fail if it would change
+ *
+ * The artifact is COMMITTED. That is deliberate: middleware serves it at
+ * runtime and must not depend on web-ui's toolchain being present in the
+ * image. `--check` runs in the web-ui CI job so an edit to the source, the
+ * theme tokens, or the bridge that was not regenerated fails loudly instead
+ * of shipping a stylesheet that disagrees with the shell.
+ *
+ * FONTS (implementation.md §2.3). `next/font` injects its faces into web-ui's
+ * document only, and an iframe is a separate document — so `--font-geist`,
+ * `--font-geist-mono` and `--font-source-serif` are UNDEFINED inside a plugin
+ * UI. That is not cosmetic: `theme.css` builds `--font-sans` as
+ * `var(--font-geist), system-ui, …`, and an undefined var makes the whole
+ * declaration invalid at computed-value time, so the plugin would fall back
+ * to the browser's serif default rather than to the intended stack. This
+ * script therefore always emits a font epilogue that binds those three
+ * variables. Drop `.woff2` files into `middleware/assets/plugin-ui/fonts/`
+ * and each one additionally gets an `@font-face` served from
+ * `/bot-api/_harness/plugin-ui/fonts/`; with the directory empty the
+ * epilogue binds the platform fallback stacks instead.
+ */
+
+import { existsSync, readdirSync, readFileSync, writeFileSync, mkdirSync } from 'node:fs';
+import path from 'node:path';
+import { fileURLToPath } from 'node:url';
+
+import postcss from 'postcss';
+import tailwindcss from '@tailwindcss/postcss';
+
+const HERE = path.dirname(fileURLToPath(import.meta.url));
+const WEB_UI_ROOT = path.resolve(HERE, '..');
+const REPO_ROOT = path.resolve(WEB_UI_ROOT, '..');
+
+const SOURCE = path.join(HERE, 'plugin-ui.source.css');
+const OUT_DIR = path.join(REPO_ROOT, 'middleware', 'assets', 'plugin-ui');
+const OUT_FILE = path.join(OUT_DIR, 'plugin-ui.css');
+const FONT_DIR = path.join(OUT_DIR, 'fonts');
+
+/** Public URL prefix the browser uses for the served font files. */
+const FONT_URL_PREFIX = '/bot-api/_harness/plugin-ui/fonts';
+
+/**
+ * next/font family → the CSS variable `theme.css` reads, the `@font-face`
+ * family name to declare, and the fallback stack used when no file ships.
+ * Keep in step with `web-ui/app/layout.tsx`.
+ */
+const FONT_FACES = [
+ {
+ variable: '--font-geist',
+ family: 'Geist',
+ filePrefix: 'geist',
+ fallback: "system-ui, -apple-system, 'Segoe UI', sans-serif",
+ },
+ {
+ variable: '--font-geist-mono',
+ family: 'Geist Mono',
+ filePrefix: 'geist-mono',
+ fallback: "ui-monospace, 'SF Mono', Menlo, Consolas, monospace",
+ },
+ {
+ variable: '--font-source-serif',
+ family: 'Source Serif 4',
+ filePrefix: 'source-serif',
+ fallback: "Charter, 'Iowan Old Style', Georgia, serif",
+ },
+];
+
+function listFontFiles() {
+ if (!existsSync(FONT_DIR)) return [];
+ return readdirSync(FONT_DIR)
+ .filter((name) => name.toLowerCase().endsWith('.woff2'))
+ .sort();
+}
+
+/**
+ * The font epilogue. Appended AFTER the compiled output and deliberately
+ * unlayered, so these `:root` bindings win over `theme.css`'s own (same
+ * specificity, later wins) without touching the shell's copy.
+ */
+function buildFontEpilogue(fontFiles) {
+ const lines = [
+ '/* --------------------------------------------------------------------------',
+ ' Font epilogue — generated by web-ui/scripts/build-plugin-ui-css.mjs.',
+ ' next/font does not cross the iframe boundary (implementation.md §2.3), so',
+ " theme.css's --font-geist / --font-geist-mono / --font-source-serif are",
+ ' undefined here. Left undefined they would invalidate --font-sans entirely',
+ ' and drop the plugin UI to the browser serif default.',
+ ' -------------------------------------------------------------------------- */',
+ ];
+
+ const bindings = [];
+ for (const face of FONT_FACES) {
+ const file = fontFiles.find(
+ (name) => name.toLowerCase() === `${face.filePrefix}.woff2`,
+ );
+ if (file) {
+ lines.push(
+ '@font-face {',
+ ` font-family: '${face.family}';`,
+ ` src: url('${FONT_URL_PREFIX}/${file}') format('woff2');`,
+ ' font-weight: 100 900;',
+ ' font-style: normal;',
+ ' font-display: swap;',
+ '}',
+ );
+ bindings.push(` ${face.variable}: '${face.family}';`);
+ } else {
+ bindings.push(` ${face.variable}: ${face.fallback};`);
+ }
+ }
+
+ lines.push(':root {', ...bindings, '}');
+ return `${lines.join('\n')}\n`;
+}
+
+function buildHeader(fontFiles) {
+ return [
+ '/* ==========================================================================',
+ ' GENERATED FILE — DO NOT EDIT.',
+ '',
+ ' Source : web-ui/scripts/plugin-ui.source.css',
+ ' Build : cd web-ui && npm run plugin-ui:css',
+ ' Served : GET /api/_harness/plugin-ui.css (browser: /bot-api/...)',
+ '',
+ ' Epic #470 C8 — the finite Tailwind vocabulary a distributed plugin may',
+ ' use. Plugins ship no CSS of their own; `.css` is absent from the plugin',
+ ' ZIP allowlist and that absence is the enforcement. Arbitrary values',
+ ' (`w-[137px]`) are rejected at package ingest because the vocabulary is',
+ ' finite by construction and an undeclared class renders unstyled.',
+ '',
+ ` Fonts embedded: ${fontFiles.length === 0 ? 'none (fallback stacks bound)' : fontFiles.join(', ')}`,
+ ' ========================================================================== */',
+ '',
+ ].join('\n');
+}
+
+async function generate() {
+ const fontFiles = listFontFiles();
+ const css = readFileSync(SOURCE, 'utf-8');
+ const result = await postcss([tailwindcss()]).process(css, {
+ from: SOURCE,
+ to: OUT_FILE,
+ });
+ return `${buildHeader(fontFiles)}${result.css.trimEnd()}\n\n${buildFontEpilogue(fontFiles)}`;
+}
+
+async function main() {
+ const check = process.argv.includes('--check');
+ const next = await generate();
+
+ if (check) {
+ const current = existsSync(OUT_FILE) ? readFileSync(OUT_FILE, 'utf-8') : '';
+ if (current === next) {
+ console.log(
+ `[plugin-ui-css] up to date (${Buffer.byteLength(next)} bytes) — ` +
+ path.relative(REPO_ROOT, OUT_FILE),
+ );
+ return;
+ }
+ console.error(
+ '[plugin-ui-css] DRIFT: the committed artifact does not match the source.\n' +
+ ` artifact : ${path.relative(REPO_ROOT, OUT_FILE)} (${Buffer.byteLength(current)} bytes)\n` +
+ ` generated: ${Buffer.byteLength(next)} bytes\n` +
+ ' Fix with: cd web-ui && npm run plugin-ui:css && git add ../middleware/assets/plugin-ui/plugin-ui.css',
+ );
+ process.exitCode = 1;
+ return;
+ }
+
+ mkdirSync(OUT_DIR, { recursive: true });
+ writeFileSync(OUT_FILE, next, 'utf-8');
+ const { gzipSync, brotliCompressSync } = await import('node:zlib');
+ const raw = Buffer.from(next, 'utf-8');
+ console.log(
+ `[plugin-ui-css] wrote ${path.relative(REPO_ROOT, OUT_FILE)} — ` +
+ `${raw.length} B raw / ${gzipSync(raw).length} B gzip / ` +
+ `${brotliCompressSync(raw).length} B brotli`,
+ );
+}
+
+main().catch((err) => {
+ console.error('[plugin-ui-css] failed:', err);
+ process.exit(1);
+});
diff --git a/web-ui/scripts/plugin-ui.source.css b/web-ui/scripts/plugin-ui.source.css
new file mode 100644
index 00000000..1e710b46
--- /dev/null
+++ b/web-ui/scripts/plugin-ui.source.css
@@ -0,0 +1,367 @@
+/* ==========================================================================
+ plugin-ui.source.css — SOURCE for the plugin stylesheet core serves.
+ --------------------------------------------------------------------------
+ Built by `web-ui/scripts/build-plugin-ui-css.mjs` into
+ `middleware/assets/plugin-ui/plugin-ui.css`, which core serves at
+ `/api/_harness/plugin-ui.css` (browser: `/bot-api/_harness/plugin-ui.css`).
+
+ WHY THIS FILE EXISTS (epic #470 §4.3a). A plugin installed at runtime from
+ another repository is never scanned by Tailwind, and it may not ship a
+ stylesheet — `.css` is deliberately absent from the plugin-ZIP extension
+ allowlist, and that absence IS the enforcement. So a plugin links this
+ sheet and is limited to the vocabulary declared below. Everything it can
+ express is a Lume token, which is the point: a plugin cannot hardcode a
+ hex and drift from the operator's active palette.
+
+ THE HARD CONSTRAINT: no arbitrary values (`w-[137px]`, `bg-[#abc]`,
+ `[&>tr]:border`). An exact arbitrary value *can* be pre-generated —
+ `@source inline("w-[137px]")` emits it — but the universe of them cannot,
+ so an undeclared one renders unstyled, which is the worst failure mode.
+ `middleware/src/plugins/tailwindArbitraryValueScan.ts` rejects them at
+ package ingest.
+
+ TO WIDEN THE VOCABULARY: edit this file, run `npm run plugin-ui:css`,
+ commit the regenerated artifact, and update
+ `specs/470-dev-platform-plugin/plugin-ui-vocabulary.md`. CI regenerates
+ and diffs, so an un-regenerated edit fails the web-ui job.
+ ========================================================================== */
+
+@import 'tailwindcss' source(none);
+@import '../app/_lib/theme.css';
+@import '../app/_lib/tailwind-bridge.css';
+
+/* --------------------------------------------------------------------------
+ 1. BASELINE — the element-level styling every plugin surface inherits.
+
+ This layer replaces `middleware/src/admin-ui/harness-admin-css.ts`, 345
+ hand-written lines whose own header asked the next maintainer to "keep the
+ two roughly in sync when the design system changes". They are not mirrored
+ here — they are generated from the same `theme.css` the shell uses, which
+ removes the sync obligation instead of restating it.
+
+ In `@layer base` so a utility class always wins over the element default.
+ -------------------------------------------------------------------------- */
+@layer base {
+ html,
+ body {
+ height: 100%;
+ }
+
+ body {
+ margin: 0;
+ background: var(--bg);
+ color: var(--fg);
+ font-family: var(--font-sans);
+ font-size: 15px;
+ line-height: 1.55;
+ text-rendering: optimizeLegibility;
+ -webkit-font-smoothing: antialiased;
+ -moz-osx-font-smoothing: grayscale;
+ }
+
+ h1,
+ h2,
+ h3,
+ h4,
+ h5,
+ h6 {
+ margin: 0 0 0.6rem;
+ color: var(--fg-strong);
+ font-weight: 600;
+ line-height: 1.25;
+ }
+
+ h1 {
+ font-size: 1.5rem;
+ }
+ h2 {
+ font-size: 1.25rem;
+ }
+ h3 {
+ font-size: 1.05rem;
+ }
+
+ p {
+ margin: 0 0 0.75rem;
+ }
+
+ a {
+ color: var(--accent);
+ text-decoration: none;
+ }
+
+ a:hover {
+ color: var(--accent-hover);
+ text-decoration: underline;
+ }
+
+ code,
+ pre,
+ kbd,
+ samp {
+ font-family: var(--font-mono);
+ font-size: 0.9em;
+ }
+
+ code {
+ padding: 0.1rem 0.3rem;
+ border-radius: var(--radius-sm);
+ background: var(--bg-soft);
+ }
+
+ pre {
+ overflow: auto;
+ padding: 0.8rem;
+ border: 1px solid var(--border);
+ border-radius: var(--radius-md);
+ background: var(--bg-soft);
+ }
+
+ hr {
+ height: 0;
+ margin: 1.2rem 0;
+ border: 0;
+ border-top: 1px solid var(--border);
+ }
+
+ table {
+ width: 100%;
+ border-collapse: collapse;
+ }
+
+ th,
+ td {
+ padding: 0.5rem 0.65rem;
+ border-bottom: 1px solid var(--border);
+ text-align: left;
+ vertical-align: top;
+ }
+
+ th {
+ color: var(--fg-muted);
+ font-size: 0.82rem;
+ font-weight: 600;
+ letter-spacing: 0.02em;
+ text-transform: uppercase;
+ }
+
+ input,
+ select,
+ textarea,
+ button {
+ font: inherit;
+ }
+
+ input,
+ select,
+ textarea {
+ padding: 0.4rem 0.55rem;
+ border: 1px solid var(--border);
+ border-radius: var(--radius-sm);
+ background: var(--bg-elevated);
+ color: var(--fg);
+ }
+
+ input:focus-visible,
+ select:focus-visible,
+ textarea:focus-visible,
+ button:focus-visible {
+ outline: 2px solid var(--accent);
+ outline-offset: 1px;
+ }
+
+ button {
+ padding: 0.4rem 0.8rem;
+ border: 1px solid var(--border);
+ border-radius: var(--radius-sm);
+ background: var(--bg-elevated);
+ color: var(--fg);
+ cursor: pointer;
+ }
+
+ button:hover:not(:disabled) {
+ border-color: var(--border-strong);
+ background: var(--bg-soft);
+ }
+
+ button:disabled {
+ cursor: not-allowed;
+ opacity: 0.55;
+ }
+}
+
+/* --------------------------------------------------------------------------
+ 2. COMPAT HELPERS — the `.harness-*` classes already linked by shipped
+ plugin admin UIs (`/bot-api/_harness/admin-ui.css`). Kept, because deleting
+ them would restyle every installed plugin's admin page on upgrade; now
+ token-driven rather than hand-mirrored hexes. New plugin UIs should use the
+ utility vocabulary in §3 instead — these are frozen, not extended.
+ -------------------------------------------------------------------------- */
+@layer components {
+ .harness-admin {
+ max-width: var(--harness-admin-max-width, 72rem);
+ margin: 0 auto;
+ padding: var(--harness-admin-padding, 1.25rem);
+ }
+
+ .harness-subtitle {
+ margin: -0.3rem 0 1rem;
+ color: var(--fg-muted);
+ font-size: 0.92rem;
+ }
+
+ .harness-empty {
+ padding: 1.2rem;
+ border: 1px dashed var(--border);
+ border-radius: var(--radius-md);
+ color: var(--fg-muted);
+ text-align: center;
+ }
+
+ .harness-btn {
+ display: inline-flex;
+ align-items: center;
+ gap: 0.35rem;
+ padding: 0.4rem 0.8rem;
+ border: 1px solid var(--border);
+ border-radius: var(--radius-sm);
+ background: var(--bg-elevated);
+ color: var(--fg);
+ cursor: pointer;
+ }
+
+ .harness-btn:hover {
+ border-color: var(--border-strong);
+ background: var(--bg-soft);
+ }
+
+ .harness-btn--primary {
+ border-color: var(--accent);
+ background: var(--accent);
+ color: var(--bg);
+ }
+
+ .harness-btn--primary:hover {
+ border-color: var(--accent-hover);
+ background: var(--accent-hover);
+ }
+
+ .harness-input {
+ width: 100%;
+ }
+
+ .harness-table {
+ width: 100%;
+ border-collapse: collapse;
+ }
+
+ .harness-banner-error,
+ .harness-banner-info {
+ margin-bottom: 0.85rem;
+ padding: 0.65rem 0.85rem;
+ border: 1px solid transparent;
+ border-radius: var(--radius-sm);
+ font-size: 0.92rem;
+ }
+
+ .harness-banner-error {
+ border-color: color-mix(in srgb, var(--danger) 30%, transparent);
+ background: color-mix(in srgb, var(--danger) 10%, transparent);
+ color: var(--danger);
+ }
+
+ .harness-banner-info {
+ border-color: color-mix(in srgb, var(--accent) 30%, transparent);
+ background: color-mix(in srgb, var(--accent) 10%, transparent);
+ color: var(--accent);
+ }
+}
+
+/* ==========================================================================
+ 3. THE VOCABULARY.
+
+ `@source inline(...)` is v4's replacement for v3's `safelist`; combined
+ with `source(none)` above it emits exactly this set and nothing else.
+ Brace ranges (`{0..12}`) and alternations expand.
+
+ Every line here is a promise to plugin authors and a cost in bytes. Widen
+ it from what a ported page actually needs, never speculatively.
+ ========================================================================== */
+
+/* --- Layout ------------------------------------------------------------- */
+@source inline("{flex,inline-flex,grid,block,inline-block,inline,contents,hidden}");
+@source inline("flex-{row,row-reverse,col,wrap,nowrap,1,auto,initial,none}");
+@source inline("{shrink,grow}-{0,1}");
+@source inline("items-{start,center,end,baseline,stretch}");
+@source inline("justify-{start,center,end,between,around,evenly}");
+@source inline("self-{auto,start,center,end,stretch}");
+@source inline("grid-cols-{1..6}");
+@source inline("col-span-{1..6}");
+@source inline("{gap,gap-x,gap-y}-{0..8}");
+@source inline("{w,h}-{full,auto,fit,screen}");
+@source inline("{w,h}-{0,1,2,3,4,5,6,8,10,12,16,20,24,32}");
+@source inline("{min-w,min-h}-{0,full}");
+@source inline("max-w-{none,full,xs,sm,md,lg,xl,2xl,3xl,4xl,5xl,6xl,7xl}");
+@source inline("overflow-{auto,hidden,visible,x-auto,y-auto}");
+@source inline("{relative,absolute,fixed,sticky,static}");
+@source inline("{inset,top,right,bottom,left}-{0,auto}");
+@source inline("z-{0,10,20,30,40,50}");
+@source inline("mx-auto");
+
+/* --- Spacing ------------------------------------------------------------ */
+@source inline("{p,px,py,pt,pr,pb,pl}-{0..12}");
+@source inline("{m,mx,my,mt,mr,mb,ml}-{0..12}");
+@source inline("space-{x,y}-{0..6}");
+
+/* --- Typography --------------------------------------------------------- */
+@source inline("text-{xs,sm,base,lg,xl,2xl,3xl,4xl}");
+@source inline("font-{normal,medium,semibold,bold}");
+@source inline("font-{sans,mono,serif}");
+@source inline("text-{left,center,right,justify}");
+@source inline("{truncate,uppercase,lowercase,capitalize,normal-case}");
+@source inline("{underline,no-underline,line-through,italic,not-italic}");
+@source inline("leading-{none,tight,snug,normal,relaxed,loose}");
+@source inline("tracking-{tighter,tight,normal,wide,wider}");
+@source inline("whitespace-{normal,nowrap,pre,pre-wrap}");
+@source inline("break-{words,all,keep}");
+@source inline("{list-none,list-disc,list-decimal,list-inside}");
+@source inline("tabular-nums");
+@source inline("align-{top,middle,bottom,baseline}");
+
+/* --- Colour: the Lume tokens only ---------------------------------------
+ No Tailwind palette, no arbitrary hexes. This is what makes "plugins
+ inherit the design system by construction" true rather than aspirational. */
+@source inline("{hover:,focus:,}bg-{bg,bg-soft,bg-elevated,surface,accent,accent-hover,accent-subtle,danger,success,warning}");
+@source inline("bg-transparent");
+@source inline("{hover:,focus:,}text-{fg,fg-strong,fg-muted,fg-subtle,accent,accent-hover,danger,success,warning,bg}");
+@source inline("{hover:,focus:,}border-{border,border-strong,accent,danger,success,warning,transparent}");
+@source inline("{hover:,}decoration-{accent,fg-muted}");
+
+/* --- Borders / shape ---------------------------------------------------- */
+@source inline("border,border-{0,2,4}");
+@source inline("border-{t,r,b,l}");
+@source inline("border-{solid,dashed,dotted,none}");
+@source inline("rounded{,-none,-sm,-md,-lg,-xl,-full}");
+@source inline("shadow{,-none,-sm,-md,-lg}");
+@source inline("divide-y,divide-x");
+
+/* --- Interaction / state ------------------------------------------------ */
+@source inline("{hover:,focus:,}opacity-{0,25,50,60,75,100}");
+@source inline("cursor-{pointer,default,not-allowed,wait,text}");
+@source inline("select-{none,text,all}");
+@source inline("pointer-events-{none,auto}");
+@source inline("transition,transition-{none,all,colors,opacity,transform}");
+@source inline("duration-{75,100,150,200,300,500}");
+@source inline("ease-{linear,in,out,in-out}");
+@source inline("animate-{none,spin,pulse}");
+@source inline("disabled:{opacity-50,cursor-not-allowed,pointer-events-none}");
+@source inline("focus-visible:outline-none");
+@source inline("{sr-only,not-sr-only}");
+
+/* --- Responsive --------------------------------------------------------- */
+@source inline("{sm:,md:,lg:,xl:}{flex,grid,block,inline-block,hidden}");
+@source inline("{sm:,md:,lg:,xl:}grid-cols-{1..4}");
+@source inline("{sm:,md:,lg:}flex-{row,col}");
+@source inline("{sm:,md:,lg:}{p,px,py}-{0,2,4,6,8}");
+@source inline("{sm:,md:,lg:}text-{sm,base,lg,xl,2xl}");
+@source inline("{sm:,md:,lg:}max-w-{sm,md,lg,xl,2xl,4xl}");