diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2f9101f4..923141e6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -294,6 +294,16 @@ jobs: - name: Test (vitest) run: npm run test + - name: Plugin UI stylesheet drift check + # Epic #470 C8 — middleware/assets/plugin-ui/plugin-ui.css is generated + # from this workspace's theme tokens + tailwind bridge and committed so + # the runtime image needs no web-ui toolchain. Regenerate and diff here + # so an edit to the tokens, the bridge or the vocabulary that was not + # regenerated fails loudly instead of shipping a stylesheet that + # disagrees with the shell. Runs inside the existing web-ui job, not as + # a new required check. + run: npm run plugin-ui:css:check + # ------------------------------------------------------------------ # Schema smoke: apply every migration in the repo against pgvector. # Catches SQL-only regressions before they hit prod-or-OSS-demo diff --git a/Dockerfile b/Dockerfile index 235ee09a..936a7114 100644 --- a/Dockerfile +++ b/Dockerfile @@ -162,6 +162,12 @@ ENV BUILDER_BOILERPLATE_DIR=/app/boilerplate # path that doesn't ship in the public release. COPY middleware/assets/entity-registry.v1.yaml ./entity-registry.v1.yaml ENV BUILDER_ENTITY_REGISTRY_PATH=/app/entity-registry.v1.yaml +# Plugin-UI assets — the generated design-system stylesheet plugin UIs link +# (`/api/_harness/plugin-ui.css`) plus any @font-face sources. Generated by +# `web-ui/scripts/build-plugin-ui-css.mjs` and committed; a missing directory +# aborts boot via verifyAssetBundles rather than 404ing at runtime. +COPY middleware/assets/plugin-ui ./plugin-ui +ENV PLUGIN_UI_ASSETS_DIR=/app/plugin-ui COPY docker-entrypoint.sh /usr/local/bin/docker-entrypoint.sh RUN chmod +x /usr/local/bin/docker-entrypoint.sh diff --git a/middleware/assets/boilerplate/agent-integration/assets/admin-ui/CLAUDE.md b/middleware/assets/boilerplate/agent-integration/assets/admin-ui/CLAUDE.md index 9bb7902b..6823a754 100644 --- a/middleware/assets/boilerplate/agent-integration/assets/admin-ui/CLAUDE.md +++ b/middleware/assets/boilerplate/agent-integration/assets/admin-ui/CLAUDE.md @@ -3,8 +3,10 @@ Dieses Dokument beschreibt die **harten Constraints** und das gewünschte Pattern für den `admin-ui-body`-Slot in `assets/admin-ui/index.html`. Quelle der Wahrheit für das Baseline-Stylesheet: -`middleware/src/admin-ui/harness-admin-css.ts` (im Browser served via -`/bot-api/_harness/admin-ui.css`). +`web-ui/scripts/build-plugin-ui-css.mjs`, das nach +`middleware/assets/plugin-ui/plugin-ui.css` baut und im Browser unter +`/bot-api/_harness/plugin-ui.css` ausgeliefert wird +(`admin-ui.css` bleibt als Alias auf dieselben Bytes bestehen). ## Wann dieser Guide greift diff --git a/middleware/assets/boilerplate/agent-integration/assets/admin-ui/index.html b/middleware/assets/boilerplate/agent-integration/assets/admin-ui/index.html index 4746d3b9..79dabe53 100644 --- a/middleware/assets/boilerplate/agent-integration/assets/admin-ui/index.html +++ b/middleware/assets/boilerplate/agent-integration/assets/admin-ui/index.html @@ -9,7 +9,10 @@ element styling, .harness-* helper classes, and CSS-vars for the host iframe constraints (--harness-iframe-{max,min,height}). Same-origin via the web-dev /bot-api rewrite → middleware /api/_harness/admin-ui.css. - Source of truth: middleware/src/admin-ui/harness-admin-css.ts. + Generated by web-ui/scripts/build-plugin-ui-css.mjs into + middleware/assets/plugin-ui/plugin-ui.css and served at + /bot-api/_harness/plugin-ui.css; /bot-api/_harness/admin-ui.css stays + as the legacy same-bytes alias, so this link remains correct. --> diff --git a/middleware/assets/plugin-ui/fonts/README.md b/middleware/assets/plugin-ui/fonts/README.md new file mode 100644 index 00000000..0f07e383 --- /dev/null +++ b/middleware/assets/plugin-ui/fonts/README.md @@ -0,0 +1,34 @@ +# plugin-ui fonts + +Drop `.woff2` files here to have them served to plugin UIs at +`/api/_harness/plugin-ui/fonts/` (browser: `/bot-api/...`) and declared +as `@font-face` inside the generated `plugin-ui.css`. + +`next/font` injects the shell's faces into web-ui's own document only, and an +iframe is a separate document — so a plugin UI never inherits them +(`implementation.md` §2.3, in the epic #470 spec directory). The generated +stylesheet therefore always binds `--font-geist`, `--font-geist-mono` and +`--font-source-serif`: with this directory empty it binds the platform +fallback stacks, with a file present it binds the real family. Leaving them +unbound would not merely lose the face — `theme.css` composes +`--font-sans: var(--font-geist), system-ui, …`, and an undefined var +invalidates the whole declaration, dropping the plugin UI to the browser's +serif default. + +Recognised names (matched case-insensitively by +`web-ui/scripts/build-plugin-ui-css.mjs`): + +| file | family | bound variable | +|---|---|---| +| `geist.woff2` | `Geist` | `--font-geist` | +| `geist-mono.woff2` | `Geist Mono` | `--font-geist-mono` | +| `source-serif.woff2` | `Source Serif 4` | `--font-source-serif` | + +After adding or removing a file, regenerate the stylesheet: + +``` +cd web-ui && npm run plugin-ui:css +``` + +No face ships today: the shell's own are downloaded by `next/font/google` at +build time and are committed nowhere in this repo. diff --git a/middleware/assets/plugin-ui/plugin-ui.css b/middleware/assets/plugin-ui/plugin-ui.css new file mode 100644 index 00000000..869e1088 --- /dev/null +++ b/middleware/assets/plugin-ui/plugin-ui.css @@ -0,0 +1,2999 @@ +/* ========================================================================== + 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: none (fallback stacks bound) + ========================================================================== */ +/*! tailwindcss v4.3.3 | MIT License | https://tailwindcss.com */ +@layer properties; +@layer theme, base, components, utilities; +@layer theme { + :root, :host { + --font-sans: var(--font-sans); + --font-serif: var(--font-serif); + --font-mono: var(--font-mono); + --spacing: 0.25rem; + --container-xs: 20rem; + --container-sm: 24rem; + --container-md: 28rem; + --container-lg: 32rem; + --container-xl: 36rem; + --container-2xl: 42rem; + --container-3xl: 48rem; + --container-4xl: 56rem; + --container-5xl: 64rem; + --container-6xl: 72rem; + --container-7xl: 80rem; + --text-xs: 0.75rem; + --text-xs--line-height: calc(1 / 0.75); + --text-sm: 0.875rem; + --text-sm--line-height: calc(1.25 / 0.875); + --text-base: 1rem; + --text-base--line-height: calc(1.5 / 1); + --text-lg: 1.125rem; + --text-lg--line-height: calc(1.75 / 1.125); + --text-xl: 1.25rem; + --text-xl--line-height: calc(1.75 / 1.25); + --text-2xl: 1.5rem; + --text-2xl--line-height: calc(2 / 1.5); + --text-3xl: 1.875rem; + --text-3xl--line-height: calc(2.25 / 1.875); + --text-4xl: 2.25rem; + --text-4xl--line-height: calc(2.5 / 2.25); + --font-weight-normal: 400; + --font-weight-medium: 500; + --font-weight-semibold: 600; + --font-weight-bold: 700; + --tracking-tighter: -0.05em; + --tracking-tight: -0.025em; + --tracking-normal: 0em; + --tracking-wide: 0.025em; + --tracking-wider: 0.05em; + --leading-tight: 1.25; + --leading-snug: 1.375; + --leading-normal: 1.5; + --leading-relaxed: 1.625; + --leading-loose: 2; + --radius-sm: var(--radius-sm); + --radius-md: var(--radius-md); + --radius-lg: var(--radius-lg); + --radius-xl: 0.75rem; + --shadow-sm: var(--shadow-sm); + --shadow-md: var(--shadow-md); + --shadow-lg: var(--shadow-lg); + --ease-in: cubic-bezier(0.4, 0, 1, 1); + --ease-out: cubic-bezier(0, 0, 0.2, 1); + --ease-in-out: cubic-bezier(0.4, 0, 0.2, 1); + --animate-spin: spin 1s linear infinite; + --animate-pulse: pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite; + --default-font-family: var(--font-sans); + --default-mono-font-family: var(--font-mono); + } +} +@layer base { + *, ::after, ::before, ::backdrop, ::file-selector-button { + box-sizing: border-box; + margin: 0; + padding: 0; + border: 0 solid; + } + html, :host { + line-height: 1.5; + -webkit-text-size-adjust: 100%; + tab-size: 4; + font-family: var(--default-font-family, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", "Noto Sans", Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji"); + font-feature-settings: var(--default-font-feature-settings, normal); + font-variation-settings: var(--default-font-variation-settings, normal); + -webkit-tap-highlight-color: transparent; + } + hr { + height: 0; + color: inherit; + border-top-width: 1px; + } + abbr:where([title]) { + -webkit-text-decoration: underline dotted; + text-decoration: underline dotted; + } + h1, h2, h3, h4, h5, h6 { + font-size: inherit; + font-weight: inherit; + } + a { + color: inherit; + -webkit-text-decoration: inherit; + text-decoration: inherit; + } + b, strong { + font-weight: bolder; + } + code, kbd, samp, pre { + font-family: var(--default-mono-font-family, ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace); + font-feature-settings: var(--default-mono-font-feature-settings, normal); + font-variation-settings: var(--default-mono-font-variation-settings, normal); + font-size: 1em; + } + small { + font-size: 80%; + } + sub, sup { + font-size: 75%; + line-height: 0; + position: relative; + vertical-align: baseline; + } + sub { + bottom: -0.25em; + } + sup { + top: -0.5em; + } + table { + text-indent: 0; + border-color: inherit; + border-collapse: collapse; + } + :-moz-focusring:where(:not(iframe)) { + outline: auto; + } + progress { + vertical-align: baseline; + } + summary { + display: list-item; + } + ol, ul, menu { + list-style: none; + } + img, svg, video, canvas, audio, iframe, embed, object { + display: block; + vertical-align: middle; + } + img, video { + max-width: 100%; + height: auto; + } + button, input, select, optgroup, textarea, ::file-selector-button { + font: inherit; + font-feature-settings: inherit; + font-variation-settings: inherit; + letter-spacing: inherit; + color: inherit; + border-radius: 0; + background-color: transparent; + opacity: 1; + } + :where(select:is([multiple], [size])) optgroup { + font-weight: bolder; + } + :where(select:is([multiple], [size])) optgroup option { + padding-inline-start: 20px; + } + ::file-selector-button { + margin-inline-end: 4px; + } + ::placeholder { + opacity: 1; + } + @supports (not (-webkit-appearance: -apple-pay-button)) or (contain-intrinsic-size: 1px) { + ::placeholder { + color: currentcolor; + @supports (color: color-mix(in lab, red, red)) { + color: color-mix(in oklab, currentcolor 50%, transparent); + } + } + } + textarea { + resize: vertical; + } + ::-webkit-search-decoration { + -webkit-appearance: none; + } + ::-webkit-date-and-time-value { + min-height: 1lh; + text-align: inherit; + } + ::-webkit-datetime-edit { + display: inline-flex; + } + ::-webkit-datetime-edit-fields-wrapper { + padding: 0; + } + ::-webkit-datetime-edit, ::-webkit-datetime-edit-year-field, ::-webkit-datetime-edit-month-field, ::-webkit-datetime-edit-day-field, ::-webkit-datetime-edit-hour-field, ::-webkit-datetime-edit-minute-field, ::-webkit-datetime-edit-second-field, ::-webkit-datetime-edit-millisecond-field, ::-webkit-datetime-edit-meridiem-field { + padding-block: 0; + } + ::-webkit-calendar-picker-indicator { + line-height: 1; + } + :-moz-ui-invalid { + box-shadow: none; + } + button, input:where([type="button"], [type="reset"], [type="submit"]), ::file-selector-button { + appearance: button; + } + ::-webkit-inner-spin-button, ::-webkit-outer-spin-button { + height: auto; + } + [hidden]:where(:not([hidden="until-found"])) { + display: none !important; + } +} +@layer utilities { + .pointer-events-auto { + pointer-events: auto; + } + .pointer-events-none { + pointer-events: none; + } + .sr-only { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip-path: inset(50%); + white-space: nowrap; + border-width: 0; + } + .not-sr-only { + position: static; + width: auto; + height: auto; + padding: 0; + margin: 0; + overflow: visible; + clip-path: none; + white-space: normal; + } + .absolute { + position: absolute; + } + .fixed { + position: fixed; + } + .relative { + position: relative; + } + .static { + position: static; + } + .sticky { + position: sticky; + } + .inset-0 { + inset: 0px; + } + .inset-auto { + inset: auto; + } + .top-0 { + top: 0px; + } + .top-auto { + top: auto; + } + .right-0 { + right: 0px; + } + .right-auto { + right: auto; + } + .bottom-0 { + bottom: 0px; + } + .bottom-auto { + bottom: auto; + } + .left-0 { + left: 0px; + } + .left-auto { + left: auto; + } + .z-0 { + z-index: 0; + } + .z-10 { + z-index: 10; + } + .z-20 { + z-index: 20; + } + .z-30 { + z-index: 30; + } + .z-40 { + z-index: 40; + } + .z-50 { + z-index: 50; + } + .col-span-1 { + grid-column: span 1 / span 1; + } + .col-span-2 { + grid-column: span 2 / span 2; + } + .col-span-3 { + grid-column: span 3 / span 3; + } + .col-span-4 { + grid-column: span 4 / span 4; + } + .col-span-5 { + grid-column: span 5 / span 5; + } + .col-span-6 { + grid-column: span 6 / span 6; + } + .m-0 { + margin: 0px; + } + .m-1 { + margin: var(--spacing); + } + .m-2 { + margin: calc(var(--spacing) * 2); + } + .m-3 { + margin: calc(var(--spacing) * 3); + } + .m-4 { + margin: calc(var(--spacing) * 4); + } + .m-5 { + margin: calc(var(--spacing) * 5); + } + .m-6 { + margin: calc(var(--spacing) * 6); + } + .m-7 { + margin: calc(var(--spacing) * 7); + } + .m-8 { + margin: calc(var(--spacing) * 8); + } + .m-9 { + margin: calc(var(--spacing) * 9); + } + .m-10 { + margin: calc(var(--spacing) * 10); + } + .m-11 { + margin: calc(var(--spacing) * 11); + } + .m-12 { + margin: calc(var(--spacing) * 12); + } + .mx-0 { + margin-inline: 0px; + } + .mx-1 { + margin-inline: var(--spacing); + } + .mx-2 { + margin-inline: calc(var(--spacing) * 2); + } + .mx-3 { + margin-inline: calc(var(--spacing) * 3); + } + .mx-4 { + margin-inline: calc(var(--spacing) * 4); + } + .mx-5 { + margin-inline: calc(var(--spacing) * 5); + } + .mx-6 { + margin-inline: calc(var(--spacing) * 6); + } + .mx-7 { + margin-inline: calc(var(--spacing) * 7); + } + .mx-8 { + margin-inline: calc(var(--spacing) * 8); + } + .mx-9 { + margin-inline: calc(var(--spacing) * 9); + } + .mx-10 { + margin-inline: calc(var(--spacing) * 10); + } + .mx-11 { + margin-inline: calc(var(--spacing) * 11); + } + .mx-12 { + margin-inline: calc(var(--spacing) * 12); + } + .mx-auto { + margin-inline: auto; + } + .my-0 { + margin-block: 0px; + } + .my-1 { + margin-block: var(--spacing); + } + .my-2 { + margin-block: calc(var(--spacing) * 2); + } + .my-3 { + margin-block: calc(var(--spacing) * 3); + } + .my-4 { + margin-block: calc(var(--spacing) * 4); + } + .my-5 { + margin-block: calc(var(--spacing) * 5); + } + .my-6 { + margin-block: calc(var(--spacing) * 6); + } + .my-7 { + margin-block: calc(var(--spacing) * 7); + } + .my-8 { + margin-block: calc(var(--spacing) * 8); + } + .my-9 { + margin-block: calc(var(--spacing) * 9); + } + .my-10 { + margin-block: calc(var(--spacing) * 10); + } + .my-11 { + margin-block: calc(var(--spacing) * 11); + } + .my-12 { + margin-block: calc(var(--spacing) * 12); + } + .mt-0 { + margin-top: 0px; + } + .mt-1 { + margin-top: var(--spacing); + } + .mt-2 { + margin-top: calc(var(--spacing) * 2); + } + .mt-3 { + margin-top: calc(var(--spacing) * 3); + } + .mt-4 { + margin-top: calc(var(--spacing) * 4); + } + .mt-5 { + margin-top: calc(var(--spacing) * 5); + } + .mt-6 { + margin-top: calc(var(--spacing) * 6); + } + .mt-7 { + margin-top: calc(var(--spacing) * 7); + } + .mt-8 { + margin-top: calc(var(--spacing) * 8); + } + .mt-9 { + margin-top: calc(var(--spacing) * 9); + } + .mt-10 { + margin-top: calc(var(--spacing) * 10); + } + .mt-11 { + margin-top: calc(var(--spacing) * 11); + } + .mt-12 { + margin-top: calc(var(--spacing) * 12); + } + .mr-0 { + margin-right: 0px; + } + .mr-1 { + margin-right: var(--spacing); + } + .mr-2 { + margin-right: calc(var(--spacing) * 2); + } + .mr-3 { + margin-right: calc(var(--spacing) * 3); + } + .mr-4 { + margin-right: calc(var(--spacing) * 4); + } + .mr-5 { + margin-right: calc(var(--spacing) * 5); + } + .mr-6 { + margin-right: calc(var(--spacing) * 6); + } + .mr-7 { + margin-right: calc(var(--spacing) * 7); + } + .mr-8 { + margin-right: calc(var(--spacing) * 8); + } + .mr-9 { + margin-right: calc(var(--spacing) * 9); + } + .mr-10 { + margin-right: calc(var(--spacing) * 10); + } + .mr-11 { + margin-right: calc(var(--spacing) * 11); + } + .mr-12 { + margin-right: calc(var(--spacing) * 12); + } + .mb-0 { + margin-bottom: 0px; + } + .mb-1 { + margin-bottom: var(--spacing); + } + .mb-2 { + margin-bottom: calc(var(--spacing) * 2); + } + .mb-3 { + margin-bottom: calc(var(--spacing) * 3); + } + .mb-4 { + margin-bottom: calc(var(--spacing) * 4); + } + .mb-5 { + margin-bottom: calc(var(--spacing) * 5); + } + .mb-6 { + margin-bottom: calc(var(--spacing) * 6); + } + .mb-7 { + margin-bottom: calc(var(--spacing) * 7); + } + .mb-8 { + margin-bottom: calc(var(--spacing) * 8); + } + .mb-9 { + margin-bottom: calc(var(--spacing) * 9); + } + .mb-10 { + margin-bottom: calc(var(--spacing) * 10); + } + .mb-11 { + margin-bottom: calc(var(--spacing) * 11); + } + .mb-12 { + margin-bottom: calc(var(--spacing) * 12); + } + .ml-0 { + margin-left: 0px; + } + .ml-1 { + margin-left: var(--spacing); + } + .ml-2 { + margin-left: calc(var(--spacing) * 2); + } + .ml-3 { + margin-left: calc(var(--spacing) * 3); + } + .ml-4 { + margin-left: calc(var(--spacing) * 4); + } + .ml-5 { + margin-left: calc(var(--spacing) * 5); + } + .ml-6 { + margin-left: calc(var(--spacing) * 6); + } + .ml-7 { + margin-left: calc(var(--spacing) * 7); + } + .ml-8 { + margin-left: calc(var(--spacing) * 8); + } + .ml-9 { + margin-left: calc(var(--spacing) * 9); + } + .ml-10 { + margin-left: calc(var(--spacing) * 10); + } + .ml-11 { + margin-left: calc(var(--spacing) * 11); + } + .ml-12 { + margin-left: calc(var(--spacing) * 12); + } + .block { + display: block; + } + .contents { + display: contents; + } + .flex { + display: flex; + } + .grid { + display: grid; + } + .hidden { + display: none; + } + .inline { + display: inline; + } + .inline-block { + display: inline-block; + } + .inline-flex { + display: inline-flex; + } + .h-0 { + height: 0px; + } + .h-1 { + height: var(--spacing); + } + .h-2 { + height: calc(var(--spacing) * 2); + } + .h-3 { + height: calc(var(--spacing) * 3); + } + .h-4 { + height: calc(var(--spacing) * 4); + } + .h-5 { + height: calc(var(--spacing) * 5); + } + .h-6 { + height: calc(var(--spacing) * 6); + } + .h-8 { + height: calc(var(--spacing) * 8); + } + .h-10 { + height: calc(var(--spacing) * 10); + } + .h-12 { + height: calc(var(--spacing) * 12); + } + .h-16 { + height: calc(var(--spacing) * 16); + } + .h-20 { + height: calc(var(--spacing) * 20); + } + .h-24 { + height: calc(var(--spacing) * 24); + } + .h-32 { + height: calc(var(--spacing) * 32); + } + .h-auto { + height: auto; + } + .h-fit { + height: fit-content; + } + .h-full { + height: 100%; + } + .h-screen { + height: 100vh; + } + .min-h-0 { + min-height: 0px; + } + .min-h-full { + min-height: 100%; + } + .w-0 { + width: 0px; + } + .w-1 { + width: var(--spacing); + } + .w-2 { + width: calc(var(--spacing) * 2); + } + .w-3 { + width: calc(var(--spacing) * 3); + } + .w-4 { + width: calc(var(--spacing) * 4); + } + .w-5 { + width: calc(var(--spacing) * 5); + } + .w-6 { + width: calc(var(--spacing) * 6); + } + .w-8 { + width: calc(var(--spacing) * 8); + } + .w-10 { + width: calc(var(--spacing) * 10); + } + .w-12 { + width: calc(var(--spacing) * 12); + } + .w-16 { + width: calc(var(--spacing) * 16); + } + .w-20 { + width: calc(var(--spacing) * 20); + } + .w-24 { + width: calc(var(--spacing) * 24); + } + .w-32 { + width: calc(var(--spacing) * 32); + } + .w-auto { + width: auto; + } + .w-fit { + width: fit-content; + } + .w-full { + width: 100%; + } + .w-screen { + width: 100vw; + } + .max-w-2xl { + max-width: var(--container-2xl); + } + .max-w-3xl { + max-width: var(--container-3xl); + } + .max-w-4xl { + max-width: var(--container-4xl); + } + .max-w-5xl { + max-width: var(--container-5xl); + } + .max-w-6xl { + max-width: var(--container-6xl); + } + .max-w-7xl { + max-width: var(--container-7xl); + } + .max-w-full { + max-width: 100%; + } + .max-w-lg { + max-width: var(--container-lg); + } + .max-w-md { + max-width: var(--container-md); + } + .max-w-none { + max-width: none; + } + .max-w-sm { + max-width: var(--container-sm); + } + .max-w-xl { + max-width: var(--container-xl); + } + .max-w-xs { + max-width: var(--container-xs); + } + .min-w-0 { + min-width: 0px; + } + .min-w-full { + min-width: 100%; + } + .flex-1 { + flex: 1; + } + .flex-auto { + flex: auto; + } + .flex-initial { + flex: 0 auto; + } + .flex-none { + flex: none; + } + .shrink-0 { + flex-shrink: 0; + } + .shrink-1 { + flex-shrink: 1; + } + .grow-0 { + flex-grow: 0; + } + .grow-1 { + flex-grow: 1; + } + .animate-none { + animation: none; + } + .animate-pulse { + animation: var(--animate-pulse); + } + .animate-spin { + animation: var(--animate-spin); + } + .cursor-default { + cursor: default; + } + .cursor-not-allowed { + cursor: not-allowed; + } + .cursor-pointer { + cursor: pointer; + } + .cursor-text { + cursor: text; + } + .cursor-wait { + cursor: wait; + } + .list-inside { + list-style-position: inside; + } + .list-decimal { + list-style-type: decimal; + } + .list-disc { + list-style-type: disc; + } + .list-none { + list-style-type: none; + } + .grid-cols-1 { + grid-template-columns: repeat(1, minmax(0, 1fr)); + } + .grid-cols-2 { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + .grid-cols-3 { + grid-template-columns: repeat(3, minmax(0, 1fr)); + } + .grid-cols-4 { + grid-template-columns: repeat(4, minmax(0, 1fr)); + } + .grid-cols-5 { + grid-template-columns: repeat(5, minmax(0, 1fr)); + } + .grid-cols-6 { + grid-template-columns: repeat(6, minmax(0, 1fr)); + } + .flex-col { + flex-direction: column; + } + .flex-row { + flex-direction: row; + } + .flex-row-reverse { + flex-direction: row-reverse; + } + .flex-nowrap { + flex-wrap: nowrap; + } + .flex-wrap { + flex-wrap: wrap; + } + .items-baseline { + align-items: baseline; + } + .items-center { + align-items: center; + } + .items-end { + align-items: flex-end; + } + .items-start { + align-items: flex-start; + } + .items-stretch { + align-items: stretch; + } + .justify-around { + justify-content: space-around; + } + .justify-between { + justify-content: space-between; + } + .justify-center { + justify-content: center; + } + .justify-end { + justify-content: flex-end; + } + .justify-evenly { + justify-content: space-evenly; + } + .justify-start { + justify-content: flex-start; + } + .gap-0 { + gap: 0px; + } + .gap-1 { + gap: var(--spacing); + } + .gap-2 { + gap: calc(var(--spacing) * 2); + } + .gap-3 { + gap: calc(var(--spacing) * 3); + } + .gap-4 { + gap: calc(var(--spacing) * 4); + } + .gap-5 { + gap: calc(var(--spacing) * 5); + } + .gap-6 { + gap: calc(var(--spacing) * 6); + } + .gap-7 { + gap: calc(var(--spacing) * 7); + } + .gap-8 { + gap: calc(var(--spacing) * 8); + } + :where(.space-y-0 > :not(:last-child)) { + --tw-space-y-reverse: 0; + margin-block-start: 0; + margin-block-end: 0; + } + :where(.space-y-1 > :not(:last-child)) { + --tw-space-y-reverse: 0; + margin-block-start: calc(var(--spacing) * var(--tw-space-y-reverse)); + margin-block-end: calc(var(--spacing) * calc(1 - var(--tw-space-y-reverse))); + } + :where(.space-y-2 > :not(:last-child)) { + --tw-space-y-reverse: 0; + margin-block-start: calc(calc(var(--spacing) * 2) * var(--tw-space-y-reverse)); + margin-block-end: calc(calc(var(--spacing) * 2) * calc(1 - var(--tw-space-y-reverse))); + } + :where(.space-y-3 > :not(:last-child)) { + --tw-space-y-reverse: 0; + margin-block-start: calc(calc(var(--spacing) * 3) * var(--tw-space-y-reverse)); + margin-block-end: calc(calc(var(--spacing) * 3) * calc(1 - var(--tw-space-y-reverse))); + } + :where(.space-y-4 > :not(:last-child)) { + --tw-space-y-reverse: 0; + margin-block-start: calc(calc(var(--spacing) * 4) * var(--tw-space-y-reverse)); + margin-block-end: calc(calc(var(--spacing) * 4) * calc(1 - var(--tw-space-y-reverse))); + } + :where(.space-y-5 > :not(:last-child)) { + --tw-space-y-reverse: 0; + margin-block-start: calc(calc(var(--spacing) * 5) * var(--tw-space-y-reverse)); + margin-block-end: calc(calc(var(--spacing) * 5) * calc(1 - var(--tw-space-y-reverse))); + } + :where(.space-y-6 > :not(:last-child)) { + --tw-space-y-reverse: 0; + margin-block-start: calc(calc(var(--spacing) * 6) * var(--tw-space-y-reverse)); + margin-block-end: calc(calc(var(--spacing) * 6) * calc(1 - var(--tw-space-y-reverse))); + } + .gap-x-0 { + column-gap: 0px; + } + .gap-x-1 { + column-gap: var(--spacing); + } + .gap-x-2 { + column-gap: calc(var(--spacing) * 2); + } + .gap-x-3 { + column-gap: calc(var(--spacing) * 3); + } + .gap-x-4 { + column-gap: calc(var(--spacing) * 4); + } + .gap-x-5 { + column-gap: calc(var(--spacing) * 5); + } + .gap-x-6 { + column-gap: calc(var(--spacing) * 6); + } + .gap-x-7 { + column-gap: calc(var(--spacing) * 7); + } + .gap-x-8 { + column-gap: calc(var(--spacing) * 8); + } + :where(.space-x-0 > :not(:last-child)) { + --tw-space-x-reverse: 0; + margin-inline-start: 0; + margin-inline-end: 0; + } + :where(.space-x-1 > :not(:last-child)) { + --tw-space-x-reverse: 0; + margin-inline-start: calc(var(--spacing) * var(--tw-space-x-reverse)); + margin-inline-end: calc(var(--spacing) * calc(1 - var(--tw-space-x-reverse))); + } + :where(.space-x-2 > :not(:last-child)) { + --tw-space-x-reverse: 0; + margin-inline-start: calc(calc(var(--spacing) * 2) * var(--tw-space-x-reverse)); + margin-inline-end: calc(calc(var(--spacing) * 2) * calc(1 - var(--tw-space-x-reverse))); + } + :where(.space-x-3 > :not(:last-child)) { + --tw-space-x-reverse: 0; + margin-inline-start: calc(calc(var(--spacing) * 3) * var(--tw-space-x-reverse)); + margin-inline-end: calc(calc(var(--spacing) * 3) * calc(1 - var(--tw-space-x-reverse))); + } + :where(.space-x-4 > :not(:last-child)) { + --tw-space-x-reverse: 0; + margin-inline-start: calc(calc(var(--spacing) * 4) * var(--tw-space-x-reverse)); + margin-inline-end: calc(calc(var(--spacing) * 4) * calc(1 - var(--tw-space-x-reverse))); + } + :where(.space-x-5 > :not(:last-child)) { + --tw-space-x-reverse: 0; + margin-inline-start: calc(calc(var(--spacing) * 5) * var(--tw-space-x-reverse)); + margin-inline-end: calc(calc(var(--spacing) * 5) * calc(1 - var(--tw-space-x-reverse))); + } + :where(.space-x-6 > :not(:last-child)) { + --tw-space-x-reverse: 0; + margin-inline-start: calc(calc(var(--spacing) * 6) * var(--tw-space-x-reverse)); + margin-inline-end: calc(calc(var(--spacing) * 6) * calc(1 - var(--tw-space-x-reverse))); + } + .gap-y-0 { + row-gap: 0px; + } + .gap-y-1 { + row-gap: var(--spacing); + } + .gap-y-2 { + row-gap: calc(var(--spacing) * 2); + } + .gap-y-3 { + row-gap: calc(var(--spacing) * 3); + } + .gap-y-4 { + row-gap: calc(var(--spacing) * 4); + } + .gap-y-5 { + row-gap: calc(var(--spacing) * 5); + } + .gap-y-6 { + row-gap: calc(var(--spacing) * 6); + } + .gap-y-7 { + row-gap: calc(var(--spacing) * 7); + } + .gap-y-8 { + row-gap: calc(var(--spacing) * 8); + } + .self-auto { + align-self: auto; + } + .self-center { + align-self: center; + } + .self-end { + align-self: flex-end; + } + .self-start { + align-self: flex-start; + } + .self-stretch { + align-self: stretch; + } + .truncate { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + .overflow-auto { + overflow: auto; + } + .overflow-hidden { + overflow: hidden; + } + .overflow-visible { + overflow: visible; + } + .overflow-x-auto { + overflow-x: auto; + } + .overflow-y-auto { + overflow-y: auto; + } + .rounded { + border-radius: 0.25rem; + } + .rounded-full { + border-radius: calc(infinity * 1px); + } + .rounded-lg { + border-radius: var(--radius-lg); + } + .rounded-md { + border-radius: var(--radius-md); + } + .rounded-none { + border-radius: 0; + } + .rounded-sm { + border-radius: var(--radius-sm); + } + .rounded-xl { + border-radius: var(--radius-xl); + } + .border-t { + border-top-style: var(--tw-border-style); + border-top-width: 1px; + } + .border-r { + border-right-style: var(--tw-border-style); + border-right-width: 1px; + } + .border-b { + border-bottom-style: var(--tw-border-style); + border-bottom-width: 1px; + } + .border-l { + border-left-style: var(--tw-border-style); + border-left-width: 1px; + } + .border-dashed { + --tw-border-style: dashed; + border-style: dashed; + } + .border-dotted { + --tw-border-style: dotted; + border-style: dotted; + } + .border-none { + --tw-border-style: none; + border-style: none; + } + .border-solid { + --tw-border-style: solid; + border-style: solid; + } + .border-accent { + border-color: var(--accent); + } + .border-border { + border-color: var(--border); + } + .border-border-strong { + border-color: var(--border-strong); + } + .border-danger { + border-color: var(--danger); + } + .border-success { + border-color: var(--success); + } + .border-transparent { + border-color: transparent; + } + .border-warning { + border-color: var(--warning); + } + .bg-accent { + background-color: var(--accent); + } + .bg-accent-hover { + background-color: var(--accent-hover); + } + .bg-accent-subtle { + background-color: var(--accent-subtle); + } + .bg-bg { + background-color: var(--bg); + } + .bg-bg-elevated { + background-color: var(--bg-elevated); + } + .bg-bg-soft { + background-color: var(--bg-soft); + } + .bg-danger { + background-color: var(--danger); + } + .bg-success { + background-color: var(--success); + } + .bg-surface { + background-color: var(--surface); + } + .bg-transparent { + background-color: transparent; + } + .bg-warning { + background-color: var(--warning); + } + .p-0 { + padding: 0px; + } + .p-1 { + padding: var(--spacing); + } + .p-2 { + padding: calc(var(--spacing) * 2); + } + .p-3 { + padding: calc(var(--spacing) * 3); + } + .p-4 { + padding: calc(var(--spacing) * 4); + } + .p-5 { + padding: calc(var(--spacing) * 5); + } + .p-6 { + padding: calc(var(--spacing) * 6); + } + .p-7 { + padding: calc(var(--spacing) * 7); + } + .p-8 { + padding: calc(var(--spacing) * 8); + } + .p-9 { + padding: calc(var(--spacing) * 9); + } + .p-10 { + padding: calc(var(--spacing) * 10); + } + .p-11 { + padding: calc(var(--spacing) * 11); + } + .p-12 { + padding: calc(var(--spacing) * 12); + } + .px-0 { + padding-inline: 0px; + } + .px-1 { + padding-inline: var(--spacing); + } + .px-2 { + padding-inline: calc(var(--spacing) * 2); + } + .px-3 { + padding-inline: calc(var(--spacing) * 3); + } + .px-4 { + padding-inline: calc(var(--spacing) * 4); + } + .px-5 { + padding-inline: calc(var(--spacing) * 5); + } + .px-6 { + padding-inline: calc(var(--spacing) * 6); + } + .px-7 { + padding-inline: calc(var(--spacing) * 7); + } + .px-8 { + padding-inline: calc(var(--spacing) * 8); + } + .px-9 { + padding-inline: calc(var(--spacing) * 9); + } + .px-10 { + padding-inline: calc(var(--spacing) * 10); + } + .px-11 { + padding-inline: calc(var(--spacing) * 11); + } + .px-12 { + padding-inline: calc(var(--spacing) * 12); + } + .py-0 { + padding-block: 0px; + } + .py-1 { + padding-block: var(--spacing); + } + .py-2 { + padding-block: calc(var(--spacing) * 2); + } + .py-3 { + padding-block: calc(var(--spacing) * 3); + } + .py-4 { + padding-block: calc(var(--spacing) * 4); + } + .py-5 { + padding-block: calc(var(--spacing) * 5); + } + .py-6 { + padding-block: calc(var(--spacing) * 6); + } + .py-7 { + padding-block: calc(var(--spacing) * 7); + } + .py-8 { + padding-block: calc(var(--spacing) * 8); + } + .py-9 { + padding-block: calc(var(--spacing) * 9); + } + .py-10 { + padding-block: calc(var(--spacing) * 10); + } + .py-11 { + padding-block: calc(var(--spacing) * 11); + } + .py-12 { + padding-block: calc(var(--spacing) * 12); + } + .pt-0 { + padding-top: 0px; + } + .pt-1 { + padding-top: var(--spacing); + } + .pt-2 { + padding-top: calc(var(--spacing) * 2); + } + .pt-3 { + padding-top: calc(var(--spacing) * 3); + } + .pt-4 { + padding-top: calc(var(--spacing) * 4); + } + .pt-5 { + padding-top: calc(var(--spacing) * 5); + } + .pt-6 { + padding-top: calc(var(--spacing) * 6); + } + .pt-7 { + padding-top: calc(var(--spacing) * 7); + } + .pt-8 { + padding-top: calc(var(--spacing) * 8); + } + .pt-9 { + padding-top: calc(var(--spacing) * 9); + } + .pt-10 { + padding-top: calc(var(--spacing) * 10); + } + .pt-11 { + padding-top: calc(var(--spacing) * 11); + } + .pt-12 { + padding-top: calc(var(--spacing) * 12); + } + .pr-0 { + padding-right: 0px; + } + .pr-1 { + padding-right: var(--spacing); + } + .pr-2 { + padding-right: calc(var(--spacing) * 2); + } + .pr-3 { + padding-right: calc(var(--spacing) * 3); + } + .pr-4 { + padding-right: calc(var(--spacing) * 4); + } + .pr-5 { + padding-right: calc(var(--spacing) * 5); + } + .pr-6 { + padding-right: calc(var(--spacing) * 6); + } + .pr-7 { + padding-right: calc(var(--spacing) * 7); + } + .pr-8 { + padding-right: calc(var(--spacing) * 8); + } + .pr-9 { + padding-right: calc(var(--spacing) * 9); + } + .pr-10 { + padding-right: calc(var(--spacing) * 10); + } + .pr-11 { + padding-right: calc(var(--spacing) * 11); + } + .pr-12 { + padding-right: calc(var(--spacing) * 12); + } + .pb-0 { + padding-bottom: 0px; + } + .pb-1 { + padding-bottom: var(--spacing); + } + .pb-2 { + padding-bottom: calc(var(--spacing) * 2); + } + .pb-3 { + padding-bottom: calc(var(--spacing) * 3); + } + .pb-4 { + padding-bottom: calc(var(--spacing) * 4); + } + .pb-5 { + padding-bottom: calc(var(--spacing) * 5); + } + .pb-6 { + padding-bottom: calc(var(--spacing) * 6); + } + .pb-7 { + padding-bottom: calc(var(--spacing) * 7); + } + .pb-8 { + padding-bottom: calc(var(--spacing) * 8); + } + .pb-9 { + padding-bottom: calc(var(--spacing) * 9); + } + .pb-10 { + padding-bottom: calc(var(--spacing) * 10); + } + .pb-11 { + padding-bottom: calc(var(--spacing) * 11); + } + .pb-12 { + padding-bottom: calc(var(--spacing) * 12); + } + .pl-0 { + padding-left: 0px; + } + .pl-1 { + padding-left: var(--spacing); + } + .pl-2 { + padding-left: calc(var(--spacing) * 2); + } + .pl-3 { + padding-left: calc(var(--spacing) * 3); + } + .pl-4 { + padding-left: calc(var(--spacing) * 4); + } + .pl-5 { + padding-left: calc(var(--spacing) * 5); + } + .pl-6 { + padding-left: calc(var(--spacing) * 6); + } + .pl-7 { + padding-left: calc(var(--spacing) * 7); + } + .pl-8 { + padding-left: calc(var(--spacing) * 8); + } + .pl-9 { + padding-left: calc(var(--spacing) * 9); + } + .pl-10 { + padding-left: calc(var(--spacing) * 10); + } + .pl-11 { + padding-left: calc(var(--spacing) * 11); + } + .pl-12 { + padding-left: calc(var(--spacing) * 12); + } + .text-center { + text-align: center; + } + .text-justify { + text-align: justify; + } + .text-left { + text-align: left; + } + .text-right { + text-align: right; + } + .align-baseline { + vertical-align: baseline; + } + .align-bottom { + vertical-align: bottom; + } + .align-middle { + vertical-align: middle; + } + .align-top { + vertical-align: top; + } + .font-mono { + font-family: var(--font-mono); + } + .font-sans { + font-family: var(--font-sans); + } + .font-serif { + font-family: var(--font-serif); + } + .text-2xl { + font-size: var(--text-2xl); + line-height: var(--tw-leading, var(--text-2xl--line-height)); + } + .text-3xl { + font-size: var(--text-3xl); + line-height: var(--tw-leading, var(--text-3xl--line-height)); + } + .text-4xl { + font-size: var(--text-4xl); + line-height: var(--tw-leading, var(--text-4xl--line-height)); + } + .text-base { + font-size: var(--text-base); + line-height: var(--tw-leading, var(--text-base--line-height)); + } + .text-lg { + font-size: var(--text-lg); + line-height: var(--tw-leading, var(--text-lg--line-height)); + } + .text-sm { + font-size: var(--text-sm); + line-height: var(--tw-leading, var(--text-sm--line-height)); + } + .text-xl { + font-size: var(--text-xl); + line-height: var(--tw-leading, var(--text-xl--line-height)); + } + .text-xs { + font-size: var(--text-xs); + line-height: var(--tw-leading, var(--text-xs--line-height)); + } + .leading-loose { + --tw-leading: var(--leading-loose); + line-height: var(--leading-loose); + } + .leading-none { + --tw-leading: 1; + line-height: 1; + } + .leading-normal { + --tw-leading: var(--leading-normal); + line-height: var(--leading-normal); + } + .leading-relaxed { + --tw-leading: var(--leading-relaxed); + line-height: var(--leading-relaxed); + } + .leading-snug { + --tw-leading: var(--leading-snug); + line-height: var(--leading-snug); + } + .leading-tight { + --tw-leading: var(--leading-tight); + line-height: var(--leading-tight); + } + .font-bold { + --tw-font-weight: var(--font-weight-bold); + font-weight: var(--font-weight-bold); + } + .font-medium { + --tw-font-weight: var(--font-weight-medium); + font-weight: var(--font-weight-medium); + } + .font-normal { + --tw-font-weight: var(--font-weight-normal); + font-weight: var(--font-weight-normal); + } + .font-semibold { + --tw-font-weight: var(--font-weight-semibold); + font-weight: var(--font-weight-semibold); + } + .tracking-normal { + --tw-tracking: var(--tracking-normal); + letter-spacing: var(--tracking-normal); + } + .tracking-tight { + --tw-tracking: var(--tracking-tight); + letter-spacing: var(--tracking-tight); + } + .tracking-tighter { + --tw-tracking: var(--tracking-tighter); + letter-spacing: var(--tracking-tighter); + } + .tracking-wide { + --tw-tracking: var(--tracking-wide); + letter-spacing: var(--tracking-wide); + } + .tracking-wider { + --tw-tracking: var(--tracking-wider); + letter-spacing: var(--tracking-wider); + } + .break-words { + overflow-wrap: break-word; + } + .break-all { + word-break: break-all; + } + .break-keep { + word-break: keep-all; + } + .whitespace-normal { + white-space: normal; + } + .whitespace-nowrap { + white-space: nowrap; + } + .whitespace-pre { + white-space: pre; + } + .whitespace-pre-wrap { + white-space: pre-wrap; + } + .text-accent { + color: var(--accent); + } + .text-accent-hover { + color: var(--accent-hover); + } + .text-bg { + color: var(--bg); + } + .text-danger { + color: var(--danger); + } + .text-fg { + color: var(--fg); + } + .text-fg-muted { + color: var(--fg-muted); + } + .text-fg-strong { + color: var(--fg-strong); + } + .text-fg-subtle { + color: var(--fg-subtle); + } + .text-success { + color: var(--success); + } + .text-warning { + color: var(--warning); + } + .capitalize { + text-transform: capitalize; + } + .lowercase { + text-transform: lowercase; + } + .normal-case { + text-transform: none; + } + .uppercase { + text-transform: uppercase; + } + .italic { + font-style: italic; + } + .not-italic { + font-style: normal; + } + .tabular-nums { + --tw-numeric-spacing: tabular-nums; + font-variant-numeric: var(--tw-ordinal,) var(--tw-slashed-zero,) var(--tw-numeric-figure,) var(--tw-numeric-spacing,) var(--tw-numeric-fraction,); + } + .line-through { + text-decoration-line: line-through; + } + .no-underline { + text-decoration-line: none; + } + .underline { + text-decoration-line: underline; + } + .decoration-accent { + text-decoration-color: var(--accent); + } + .decoration-fg-muted { + text-decoration-color: var(--fg-muted); + } + .opacity-0 { + opacity: 0%; + } + .opacity-25 { + opacity: 25%; + } + .opacity-50 { + opacity: 50%; + } + .opacity-60 { + opacity: 60%; + } + .opacity-75 { + opacity: 75%; + } + .opacity-100 { + opacity: 100%; + } + .shadow { + --tw-shadow: 0 1px 3px 0 var(--tw-shadow-color, rgb(0 0 0 / 0.1)), 0 1px 2px -1px var(--tw-shadow-color, rgb(0 0 0 / 0.1)); + box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow); + } + .shadow-lg { + --tw-shadow: var(--shadow-lg); + box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow); + } + .shadow-md { + --tw-shadow: var(--shadow-md); + box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow); + } + .shadow-none { + --tw-shadow: 0 0 #0000; + box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow); + } + .shadow-sm { + --tw-shadow: var(--shadow-sm); + box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow); + } + .duration-75 { + --tw-duration: 75ms; + transition-duration: 75ms; + } + .duration-100 { + --tw-duration: 100ms; + transition-duration: 100ms; + } + .duration-150 { + --tw-duration: 150ms; + transition-duration: 150ms; + } + .duration-200 { + --tw-duration: 200ms; + transition-duration: 200ms; + } + .duration-300 { + --tw-duration: 300ms; + transition-duration: 300ms; + } + .duration-500 { + --tw-duration: 500ms; + transition-duration: 500ms; + } + .ease-in { + --tw-ease: var(--ease-in); + transition-timing-function: var(--ease-in); + } + .ease-in-out { + --tw-ease: var(--ease-in-out); + transition-timing-function: var(--ease-in-out); + } + .ease-linear { + --tw-ease: linear; + transition-timing-function: linear; + } + .ease-out { + --tw-ease: var(--ease-out); + transition-timing-function: var(--ease-out); + } + .select-all { + -webkit-user-select: all; + user-select: all; + } + .select-none { + -webkit-user-select: none; + user-select: none; + } + .select-text { + -webkit-user-select: text; + user-select: text; + } + @media (hover: hover) { + .hover\:border-accent:hover { + border-color: var(--accent); + } + .hover\:border-border:hover { + border-color: var(--border); + } + .hover\:border-border-strong:hover { + border-color: var(--border-strong); + } + .hover\:border-danger:hover { + border-color: var(--danger); + } + .hover\:border-success:hover { + border-color: var(--success); + } + .hover\:border-transparent:hover { + border-color: transparent; + } + .hover\:border-warning:hover { + border-color: var(--warning); + } + .hover\:bg-accent:hover { + background-color: var(--accent); + } + .hover\:bg-accent-hover:hover { + background-color: var(--accent-hover); + } + .hover\:bg-accent-subtle:hover { + background-color: var(--accent-subtle); + } + .hover\:bg-bg:hover { + background-color: var(--bg); + } + .hover\:bg-bg-elevated:hover { + background-color: var(--bg-elevated); + } + .hover\:bg-bg-soft:hover { + background-color: var(--bg-soft); + } + .hover\:bg-danger:hover { + background-color: var(--danger); + } + .hover\:bg-success:hover { + background-color: var(--success); + } + .hover\:bg-surface:hover { + background-color: var(--surface); + } + .hover\:bg-warning:hover { + background-color: var(--warning); + } + .hover\:text-accent:hover { + color: var(--accent); + } + .hover\:text-accent-hover:hover { + color: var(--accent-hover); + } + .hover\:text-bg:hover { + color: var(--bg); + } + .hover\:text-danger:hover { + color: var(--danger); + } + .hover\:text-fg:hover { + color: var(--fg); + } + .hover\:text-fg-muted:hover { + color: var(--fg-muted); + } + .hover\:text-fg-strong:hover { + color: var(--fg-strong); + } + .hover\:text-fg-subtle:hover { + color: var(--fg-subtle); + } + .hover\:text-success:hover { + color: var(--success); + } + .hover\:text-warning:hover { + color: var(--warning); + } + .hover\:decoration-accent:hover { + text-decoration-color: var(--accent); + } + .hover\:decoration-fg-muted:hover { + text-decoration-color: var(--fg-muted); + } + .hover\:opacity-0:hover { + opacity: 0%; + } + .hover\:opacity-25:hover { + opacity: 25%; + } + .hover\:opacity-50:hover { + opacity: 50%; + } + .hover\:opacity-60:hover { + opacity: 60%; + } + .hover\:opacity-75:hover { + opacity: 75%; + } + .hover\:opacity-100:hover { + opacity: 100%; + } + } + .focus\:border-accent:focus { + border-color: var(--accent); + } + .focus\:border-border:focus { + border-color: var(--border); + } + .focus\:border-border-strong:focus { + border-color: var(--border-strong); + } + .focus\:border-danger:focus { + border-color: var(--danger); + } + .focus\:border-success:focus { + border-color: var(--success); + } + .focus\:border-transparent:focus { + border-color: transparent; + } + .focus\:border-warning:focus { + border-color: var(--warning); + } + .focus\:bg-accent:focus { + background-color: var(--accent); + } + .focus\:bg-accent-hover:focus { + background-color: var(--accent-hover); + } + .focus\:bg-accent-subtle:focus { + background-color: var(--accent-subtle); + } + .focus\:bg-bg:focus { + background-color: var(--bg); + } + .focus\:bg-bg-elevated:focus { + background-color: var(--bg-elevated); + } + .focus\:bg-bg-soft:focus { + background-color: var(--bg-soft); + } + .focus\:bg-danger:focus { + background-color: var(--danger); + } + .focus\:bg-success:focus { + background-color: var(--success); + } + .focus\:bg-surface:focus { + background-color: var(--surface); + } + .focus\:bg-warning:focus { + background-color: var(--warning); + } + .focus\:text-accent:focus { + color: var(--accent); + } + .focus\:text-accent-hover:focus { + color: var(--accent-hover); + } + .focus\:text-bg:focus { + color: var(--bg); + } + .focus\:text-danger:focus { + color: var(--danger); + } + .focus\:text-fg:focus { + color: var(--fg); + } + .focus\:text-fg-muted:focus { + color: var(--fg-muted); + } + .focus\:text-fg-strong:focus { + color: var(--fg-strong); + } + .focus\:text-fg-subtle:focus { + color: var(--fg-subtle); + } + .focus\:text-success:focus { + color: var(--success); + } + .focus\:text-warning:focus { + color: var(--warning); + } + .focus\:opacity-0:focus { + opacity: 0%; + } + .focus\:opacity-25:focus { + opacity: 25%; + } + .focus\:opacity-50:focus { + opacity: 50%; + } + .focus\:opacity-60:focus { + opacity: 60%; + } + .focus\:opacity-75:focus { + opacity: 75%; + } + .focus\:opacity-100:focus { + opacity: 100%; + } + .focus-visible\:outline-none:focus-visible { + --tw-outline-style: none; + outline-style: none; + } + .disabled\:pointer-events-none:disabled { + pointer-events: none; + } + .disabled\:cursor-not-allowed:disabled { + cursor: not-allowed; + } + .disabled\:opacity-50:disabled { + opacity: 50%; + } + @media (width >= 40rem) { + .sm\:block { + display: block; + } + .sm\:flex { + display: flex; + } + .sm\:grid { + display: grid; + } + .sm\:hidden { + display: none; + } + .sm\:inline-block { + display: inline-block; + } + .sm\:max-w-2xl { + max-width: var(--container-2xl); + } + .sm\:max-w-4xl { + max-width: var(--container-4xl); + } + .sm\:max-w-lg { + max-width: var(--container-lg); + } + .sm\:max-w-md { + max-width: var(--container-md); + } + .sm\:max-w-sm { + max-width: var(--container-sm); + } + .sm\:max-w-xl { + max-width: var(--container-xl); + } + .sm\:grid-cols-1 { + grid-template-columns: repeat(1, minmax(0, 1fr)); + } + .sm\:grid-cols-2 { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + .sm\:grid-cols-3 { + grid-template-columns: repeat(3, minmax(0, 1fr)); + } + .sm\:grid-cols-4 { + grid-template-columns: repeat(4, minmax(0, 1fr)); + } + .sm\:flex-col { + flex-direction: column; + } + .sm\:flex-row { + flex-direction: row; + } + .sm\:p-0 { + padding: 0px; + } + .sm\:p-2 { + padding: calc(var(--spacing) * 2); + } + .sm\:p-4 { + padding: calc(var(--spacing) * 4); + } + .sm\:p-6 { + padding: calc(var(--spacing) * 6); + } + .sm\:p-8 { + padding: calc(var(--spacing) * 8); + } + .sm\:px-0 { + padding-inline: 0px; + } + .sm\:px-2 { + padding-inline: calc(var(--spacing) * 2); + } + .sm\:px-4 { + padding-inline: calc(var(--spacing) * 4); + } + .sm\:px-6 { + padding-inline: calc(var(--spacing) * 6); + } + .sm\:px-8 { + padding-inline: calc(var(--spacing) * 8); + } + .sm\:py-0 { + padding-block: 0px; + } + .sm\:py-2 { + padding-block: calc(var(--spacing) * 2); + } + .sm\:py-4 { + padding-block: calc(var(--spacing) * 4); + } + .sm\:py-6 { + padding-block: calc(var(--spacing) * 6); + } + .sm\:py-8 { + padding-block: calc(var(--spacing) * 8); + } + .sm\:text-2xl { + font-size: var(--text-2xl); + line-height: var(--tw-leading, var(--text-2xl--line-height)); + } + .sm\:text-base { + font-size: var(--text-base); + line-height: var(--tw-leading, var(--text-base--line-height)); + } + .sm\:text-lg { + font-size: var(--text-lg); + line-height: var(--tw-leading, var(--text-lg--line-height)); + } + .sm\:text-sm { + font-size: var(--text-sm); + line-height: var(--tw-leading, var(--text-sm--line-height)); + } + .sm\:text-xl { + font-size: var(--text-xl); + line-height: var(--tw-leading, var(--text-xl--line-height)); + } + } + @media (width >= 48rem) { + .md\:block { + display: block; + } + .md\:flex { + display: flex; + } + .md\:grid { + display: grid; + } + .md\:hidden { + display: none; + } + .md\:inline-block { + display: inline-block; + } + .md\:max-w-2xl { + max-width: var(--container-2xl); + } + .md\:max-w-4xl { + max-width: var(--container-4xl); + } + .md\:max-w-lg { + max-width: var(--container-lg); + } + .md\:max-w-md { + max-width: var(--container-md); + } + .md\:max-w-sm { + max-width: var(--container-sm); + } + .md\:max-w-xl { + max-width: var(--container-xl); + } + .md\:grid-cols-1 { + grid-template-columns: repeat(1, minmax(0, 1fr)); + } + .md\:grid-cols-2 { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + .md\:grid-cols-3 { + grid-template-columns: repeat(3, minmax(0, 1fr)); + } + .md\:grid-cols-4 { + grid-template-columns: repeat(4, minmax(0, 1fr)); + } + .md\:flex-col { + flex-direction: column; + } + .md\:flex-row { + flex-direction: row; + } + .md\:p-0 { + padding: 0px; + } + .md\:p-2 { + padding: calc(var(--spacing) * 2); + } + .md\:p-4 { + padding: calc(var(--spacing) * 4); + } + .md\:p-6 { + padding: calc(var(--spacing) * 6); + } + .md\:p-8 { + padding: calc(var(--spacing) * 8); + } + .md\:px-0 { + padding-inline: 0px; + } + .md\:px-2 { + padding-inline: calc(var(--spacing) * 2); + } + .md\:px-4 { + padding-inline: calc(var(--spacing) * 4); + } + .md\:px-6 { + padding-inline: calc(var(--spacing) * 6); + } + .md\:px-8 { + padding-inline: calc(var(--spacing) * 8); + } + .md\:py-0 { + padding-block: 0px; + } + .md\:py-2 { + padding-block: calc(var(--spacing) * 2); + } + .md\:py-4 { + padding-block: calc(var(--spacing) * 4); + } + .md\:py-6 { + padding-block: calc(var(--spacing) * 6); + } + .md\:py-8 { + padding-block: calc(var(--spacing) * 8); + } + .md\:text-2xl { + font-size: var(--text-2xl); + line-height: var(--tw-leading, var(--text-2xl--line-height)); + } + .md\:text-base { + font-size: var(--text-base); + line-height: var(--tw-leading, var(--text-base--line-height)); + } + .md\:text-lg { + font-size: var(--text-lg); + line-height: var(--tw-leading, var(--text-lg--line-height)); + } + .md\:text-sm { + font-size: var(--text-sm); + line-height: var(--tw-leading, var(--text-sm--line-height)); + } + .md\:text-xl { + font-size: var(--text-xl); + line-height: var(--tw-leading, var(--text-xl--line-height)); + } + } + @media (width >= 64rem) { + .lg\:block { + display: block; + } + .lg\:flex { + display: flex; + } + .lg\:grid { + display: grid; + } + .lg\:hidden { + display: none; + } + .lg\:inline-block { + display: inline-block; + } + .lg\:max-w-2xl { + max-width: var(--container-2xl); + } + .lg\:max-w-4xl { + max-width: var(--container-4xl); + } + .lg\:max-w-lg { + max-width: var(--container-lg); + } + .lg\:max-w-md { + max-width: var(--container-md); + } + .lg\:max-w-sm { + max-width: var(--container-sm); + } + .lg\:max-w-xl { + max-width: var(--container-xl); + } + .lg\:grid-cols-1 { + grid-template-columns: repeat(1, minmax(0, 1fr)); + } + .lg\:grid-cols-2 { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + .lg\:grid-cols-3 { + grid-template-columns: repeat(3, minmax(0, 1fr)); + } + .lg\:grid-cols-4 { + grid-template-columns: repeat(4, minmax(0, 1fr)); + } + .lg\:flex-col { + flex-direction: column; + } + .lg\:flex-row { + flex-direction: row; + } + .lg\:p-0 { + padding: 0px; + } + .lg\:p-2 { + padding: calc(var(--spacing) * 2); + } + .lg\:p-4 { + padding: calc(var(--spacing) * 4); + } + .lg\:p-6 { + padding: calc(var(--spacing) * 6); + } + .lg\:p-8 { + padding: calc(var(--spacing) * 8); + } + .lg\:px-0 { + padding-inline: 0px; + } + .lg\:px-2 { + padding-inline: calc(var(--spacing) * 2); + } + .lg\:px-4 { + padding-inline: calc(var(--spacing) * 4); + } + .lg\:px-6 { + padding-inline: calc(var(--spacing) * 6); + } + .lg\:px-8 { + padding-inline: calc(var(--spacing) * 8); + } + .lg\:py-0 { + padding-block: 0px; + } + .lg\:py-2 { + padding-block: calc(var(--spacing) * 2); + } + .lg\:py-4 { + padding-block: calc(var(--spacing) * 4); + } + .lg\:py-6 { + padding-block: calc(var(--spacing) * 6); + } + .lg\:py-8 { + padding-block: calc(var(--spacing) * 8); + } + .lg\:text-2xl { + font-size: var(--text-2xl); + line-height: var(--tw-leading, var(--text-2xl--line-height)); + } + .lg\:text-base { + font-size: var(--text-base); + line-height: var(--tw-leading, var(--text-base--line-height)); + } + .lg\:text-lg { + font-size: var(--text-lg); + line-height: var(--tw-leading, var(--text-lg--line-height)); + } + .lg\:text-sm { + font-size: var(--text-sm); + line-height: var(--tw-leading, var(--text-sm--line-height)); + } + .lg\:text-xl { + font-size: var(--text-xl); + line-height: var(--tw-leading, var(--text-xl--line-height)); + } + } + @media (width >= 80rem) { + .xl\:block { + display: block; + } + .xl\:flex { + display: flex; + } + .xl\:grid { + display: grid; + } + .xl\:hidden { + display: none; + } + .xl\:inline-block { + display: inline-block; + } + .xl\:grid-cols-1 { + grid-template-columns: repeat(1, minmax(0, 1fr)); + } + .xl\:grid-cols-2 { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + .xl\:grid-cols-3 { + grid-template-columns: repeat(3, minmax(0, 1fr)); + } + .xl\:grid-cols-4 { + grid-template-columns: repeat(4, minmax(0, 1fr)); + } + } +} +:root { + color-scheme: light dark; + --bg-canvas-top: light-dark(#FDFDFE, #232631); + --bg-canvas-btm: light-dark(#F7F8FB, #1B1D24); + --bg-surface-top: light-dark(#FFFFFF, #2A2D38); + --bg-surface-btm: light-dark(#FAFAFD, #23262F); + --bg-surface-raised-top: light-dark(#FFFFFF, #303440); + --bg-surface-raised-btm: light-dark(#FCFCFE, #292C37); + --bg-surface-sunken-top: light-dark(#F2F3F7, #1D1F26); + --bg-surface-sunken-btm: light-dark(#ECEDF2, #16181E); + --bg-modal-surface-top: light-dark(#FFFFFF, #303440); + --bg-modal-surface-btm: light-dark(#FBFBFE, #292C37); + --bg-modal-overlay: light-dark(rgba(20, 30, 50, 0.40), rgba(0, 0, 0, 0.60)); + --text-primary: light-dark(#1B1D24, #EEEFF3); + --text-secondary: light-dark(#5B5F6B, #B6B9C3); + --text-tertiary: light-dark(#8D9099, #888B95); + --text-disabled: light-dark(#BFC1C6, #525561); + --text-inverse: light-dark(#FCFCFD, #1F2127); + --border-subtle-top: light-dark(rgba(20, 24, 36, 0.05), rgba(255, 255, 255, 0.06)); + --border-subtle-btm: light-dark(rgba(20, 24, 36, 0.09), rgba(0, 0, 0, 0.40)); + --border-default-top: light-dark(rgba(20, 24, 36, 0.08), rgba(255, 255, 255, 0.10)); + --border-default-btm: light-dark(rgba(20, 24, 36, 0.14), rgba(0, 0, 0, 0.50)); + --border-strong-top: light-dark(rgba(20, 24, 36, 0.16), rgba(255, 255, 255, 0.18)); + --border-strong-btm: light-dark(rgba(20, 24, 36, 0.26), rgba(0, 0, 0, 0.60)); + --state-loading: light-dark(#DCDEE3, #3E414C); + --state-loading-hi: light-dark(#EFEFF2, #525561); + --state-error-fg: light-dark(#A8443B, #E08577); + --state-error-edge: light-dark(#C45A50, #C5685A); + --state-success-fg: light-dark(#3F7A55, #88C499); + --state-warning-fg: light-dark(#8C6A1F, #D6B468); + --step-kind-agent: #6AB7FF; + --step-kind-action: #8B9CFF; + --step-kind-human: #F2B95E; + --step-kind-badge-fg: #0B0B0B; + --accent: light-dark(#1F8FA3, #6FC8D6); + --accent-hover: light-dark(#197D90, #88D2DE); + --accent-active: light-dark(#146B7C, #A1DCE6); + --accent-subtle: light-dark(rgba(31, 143, 163, 0.12), rgba(111, 200, 214, 0.20)); + --accent-glow: light-dark(rgba(60, 175, 195, 0.32), rgba(111, 200, 214, 0.32)); + --accent-glow-strong: light-dark(rgba(60, 175, 195, 0.48), rgba(111, 200, 214, 0.48)); + --accent-glow-core: light-dark(rgba(180, 238, 248, 0.60), rgba(210, 245, 250, 0.50)); + --bg: var(--bg-canvas-btm); + --bg-soft: var(--bg-surface-sunken-top); + --bg-elevated: var(--bg-surface-raised-top); + --bg-inverse: light-dark(#1B1D24, #EEEFF3); + --bg-tinted: var(--surface-gradient); + --fg: var(--text-primary); + --fg-strong: var(--text-primary); + --fg-muted: var(--text-secondary); + --fg-subtle: var(--text-tertiary); + --fg-disabled: var(--text-disabled); + --fg-on-dark: var(--text-inverse); + --fg-on-accent: var(--text-inverse); + --accent-fg: var(--text-inverse); + --accent-press: var(--accent-active); + --highlight: var(--accent); + --highlight-fg: var(--accent-fg); + --border: light-dark(rgba(20, 24, 36, 0.10), rgba(255, 255, 255, 0.10)); + --border-strong: light-dark(rgba(20, 24, 36, 0.18), rgba(255, 255, 255, 0.16)); + --divider: light-dark(rgba(20, 24, 36, 0.07), rgba(255, 255, 255, 0.07)); + --success: var(--state-success-fg); + --warning: var(--state-warning-fg); + --danger: var(--state-error-fg); + --danger-edge: var(--state-error-edge); + --info: var(--accent); + --surface-gradient: linear-gradient(180deg, var(--bg-canvas-top) 0%, var(--bg-canvas-btm) 100%); + --surface-gradient-accent: radial-gradient(120% 80% at 0% 0%, + var(--accent-subtle) 0%, var(--bg-soft) 55%, var(--bg) 100%); + --surface-gradient-cyan: var(--surface-gradient-accent); + --paper: var(--bg); + --paper-soft: var(--bg-soft); + --paper-sink: light-dark(#ECEDF2, #16181E); + --ink: var(--fg); + --muted-ink: var(--fg-muted); + --faint-ink: var(--fg-subtle); + --fg-default: var(--fg); + --rule: var(--border); + --rule-strong: var(--border-strong); + --oxblood: var(--accent); + --oxblood-ink: var(--accent-hover); + --forest: var(--success); + --amber: var(--warning); + --sky: var(--accent); + --ok: var(--success); + --warn: var(--warning); + --card: var(--bg-elevated); + --surface: var(--bg-elevated); + --surface-muted: var(--bg-soft); + --bg-subtle: var(--bg-soft); + --bg-hover: light-dark(#F2F3F7, #1D1F26); + --accent-bg: var(--accent-subtle); + --nav-h: 56px; + --font-sans: var(--font-geist), system-ui, -apple-system, 'Segoe UI', sans-serif; + --font-mono: var(--font-geist-mono), ui-monospace, 'SF Mono', Menlo, Consolas, monospace; + --font-serif: var(--font-source-serif), Charter, 'Iowan Old Style', Georgia, serif; + --font-text: var(--font-sans); + --font-display: var(--font-sans); + --space-0: 0; + --space-1: 2px; + --space-2: 4px; + --space-3: 8px; + --space-4: 12px; + --space-5: 16px; + --space-6: 24px; + --space-7: 32px; + --space-8: 48px; + --space-9: 64px; + --radius-0: 0; + --radius-xs: 6px; + --radius-sm: 6px; + --radius-md: 8px; + --radius-lg: 12px; + --radius-pill: 999px; + --radius-circle: 50%; + --shadow-xs: 0 1px 2px light-dark(rgba(20, 24, 36, 0.04), rgba(0, 0, 0, 0.30)); + --shadow-sm: 0 1px 2px light-dark(rgba(20, 24, 36, 0.04), rgba(0, 0, 0, 0.30)); + --shadow-md: 0 1px 2px light-dark(rgba(20, 24, 36, 0.04), rgba(0, 0, 0, 0.30)), + 0 8px 24px light-dark(rgba(20, 24, 36, 0.06), rgba(0, 0, 0, 0.45)); + --shadow-lg: 0 4px 16px light-dark(rgba(20, 24, 36, 0.06), rgba(0, 0, 0, 0.55)), + 0 24px 48px light-dark(rgba(20, 24, 36, 0.12), rgba(0, 0, 0, 0.65)), + 0 0 32px var(--accent-glow-core), + 0 0 96px var(--accent-glow); + --shadow-drag: 0 8px 24px light-dark(rgba(20, 24, 36, 0.16), rgba(0, 0, 0, 0.55)); + --shadow-cta: 0 0 4px var(--accent-glow-core), 0 4px 12px var(--accent-glow); + --shadow-magenta: var(--shadow-cta); + --motion-instant: 0ms; + --motion-quick: 100ms; + --motion-smooth: 200ms; + --motion-deliberate: 320ms; + --motion-condense: 800ms; + --dur-fast: var(--motion-quick); + --dur-base: var(--motion-smooth); + --dur-slow: var(--motion-deliberate); + --easing-standard: cubic-bezier(0.22, 0.61, 0.36, 1.00); + --easing-emphasis: cubic-bezier(0.40, 0.00, 0.20, 1.00); + --easing-linear: linear; + --ease-out: var(--easing-standard); + --ease-in-out: cubic-bezier(0.65, 0, 0.35, 1); +} +:root[data-theme='light'] { + color-scheme: light; +} +:root[data-theme='dark'] { + color-scheme: dark; +} +@property --accent { + syntax: ''; + inherits: true; + initial-value: #1F8FA3; +} +@property --accent-hover { + syntax: ''; + inherits: true; + initial-value: #197D90; +} +@property --accent-active { + syntax: ''; + inherits: true; + initial-value: #146B7C; +} +@property --accent-subtle { + syntax: ''; + inherits: true; + initial-value: rgba(31, 143, 163, 0.12); +} +@property --accent-glow { + syntax: ''; + inherits: true; + initial-value: rgba(60, 175, 195, 0.32); +} +@property --accent-glow-strong { + syntax: ''; + inherits: true; + initial-value: rgba(60, 175, 195, 0.48); +} +@property --accent-glow-core { + syntax: ''; + inherits: true; + initial-value: rgba(180, 238, 248, 0.60); +} +html.lume-xfade { + transition: --accent 200ms cubic-bezier(0.22, 0.61, 0.36, 1), --accent-hover 200ms cubic-bezier(0.22, 0.61, 0.36, 1), --accent-active 200ms cubic-bezier(0.22, 0.61, 0.36, 1), --accent-subtle 200ms cubic-bezier(0.22, 0.61, 0.36, 1), --accent-glow 200ms cubic-bezier(0.22, 0.61, 0.36, 1), --accent-glow-strong 200ms cubic-bezier(0.22, 0.61, 0.36, 1), --accent-glow-core 200ms cubic-bezier(0.22, 0.61, 0.36, 1); +} +:root[data-palette='lagoon'] { + --accent: light-dark(#1F8FA3, #6FC8D6); + --accent-hover: light-dark(#197D90, #88D2DE); + --accent-active: light-dark(#146B7C, #A1DCE6); + --accent-subtle: light-dark(rgba(31, 143, 163, 0.12), rgba(111, 200, 214, 0.20)); + --accent-glow: light-dark(rgba(60, 175, 195, 0.32), rgba(111, 200, 214, 0.32)); + --accent-glow-strong: light-dark(rgba(60, 175, 195, 0.48), rgba(111, 200, 214, 0.48)); + --accent-glow-core: light-dark(rgba(180, 238, 248, 0.60), rgba(210, 245, 250, 0.50)); +} +:root[data-palette='petrol'] { + --accent: light-dark(#0F7AB8, #52B0E2); + --accent-hover: light-dark(#0C6CA8, #74C0E8); + --accent-active: light-dark(#0A5E94, #90CFEE); + --accent-subtle: light-dark(rgba(15, 122, 184, 0.10), rgba(82, 176, 226, 0.16)); + --accent-glow: light-dark(rgba(15, 122, 184, 0.22), rgba(82, 176, 226, 0.28)); + --accent-glow-strong: light-dark(rgba(15, 122, 184, 0.36), rgba(82, 176, 226, 0.44)); + --accent-glow-core: light-dark(rgba(165, 215, 240, 0.55), rgba(197, 229, 245, 0.45)); +} +:root[data-palette='atelier'] { + --accent: light-dark(#B36B2E, #E0A26B); + --accent-hover: light-dark(#9F5C26, #E5B080); + --accent-active: light-dark(#8A4E1F, #EBBE93); + --accent-subtle: light-dark(rgba(179, 107, 46, 0.10), rgba(224, 162, 107, 0.18)); + --accent-glow: light-dark(rgba(179, 107, 46, 0.24), rgba(224, 162, 107, 0.30)); + --accent-glow-strong: light-dark(rgba(179, 107, 46, 0.38), rgba(224, 162, 107, 0.46)); + --accent-glow-core: light-dark(rgba(245, 215, 175, 0.55), rgba(250, 228, 200, 0.45)); +} +@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; + } +} +@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: var(--danger); + @supports (color: color-mix(in lab, red, red)) { + border-color: color-mix(in srgb, var(--danger) 30%, transparent); + } + background: var(--danger); + @supports (color: color-mix(in lab, red, red)) { + background: color-mix(in srgb, var(--danger) 10%, transparent); + } + color: var(--danger); + } + .harness-banner-info { + border-color: var(--accent); + @supports (color: color-mix(in lab, red, red)) { + border-color: color-mix(in srgb, var(--accent) 30%, transparent); + } + background: var(--accent); + @supports (color: color-mix(in lab, red, red)) { + background: color-mix(in srgb, var(--accent) 10%, transparent); + } + color: var(--accent); + } +} +@property --tw-space-y-reverse { + syntax: "*"; + inherits: false; + initial-value: 0; +} +@property --tw-space-x-reverse { + syntax: "*"; + inherits: false; + initial-value: 0; +} +@property --tw-border-style { + syntax: "*"; + inherits: false; + initial-value: solid; +} +@property --tw-leading { + syntax: "*"; + inherits: false; +} +@property --tw-font-weight { + syntax: "*"; + inherits: false; +} +@property --tw-tracking { + syntax: "*"; + inherits: false; +} +@property --tw-ordinal { + syntax: "*"; + inherits: false; +} +@property --tw-slashed-zero { + syntax: "*"; + inherits: false; +} +@property --tw-numeric-figure { + syntax: "*"; + inherits: false; +} +@property --tw-numeric-spacing { + syntax: "*"; + inherits: false; +} +@property --tw-numeric-fraction { + syntax: "*"; + inherits: false; +} +@property --tw-shadow { + syntax: "*"; + inherits: false; + initial-value: 0 0 #0000; +} +@property --tw-shadow-color { + syntax: "*"; + inherits: false; +} +@property --tw-shadow-alpha { + syntax: ""; + inherits: false; + initial-value: 100%; +} +@property --tw-inset-shadow { + syntax: "*"; + inherits: false; + initial-value: 0 0 #0000; +} +@property --tw-inset-shadow-color { + syntax: "*"; + inherits: false; +} +@property --tw-inset-shadow-alpha { + syntax: ""; + inherits: false; + initial-value: 100%; +} +@property --tw-ring-color { + syntax: "*"; + inherits: false; +} +@property --tw-ring-shadow { + syntax: "*"; + inherits: false; + initial-value: 0 0 #0000; +} +@property --tw-inset-ring-color { + syntax: "*"; + inherits: false; +} +@property --tw-inset-ring-shadow { + syntax: "*"; + inherits: false; + initial-value: 0 0 #0000; +} +@property --tw-ring-inset { + syntax: "*"; + inherits: false; +} +@property --tw-ring-offset-width { + syntax: ""; + inherits: false; + initial-value: 0px; +} +@property --tw-ring-offset-color { + syntax: "*"; + inherits: false; + initial-value: #fff; +} +@property --tw-ring-offset-shadow { + syntax: "*"; + inherits: false; + initial-value: 0 0 #0000; +} +@property --tw-duration { + syntax: "*"; + inherits: false; +} +@property --tw-ease { + syntax: "*"; + inherits: false; +} +@keyframes spin { + to { + transform: rotate(360deg); + } +} +@keyframes pulse { + 50% { + opacity: 0.5; + } +} +@layer properties { + @supports ((-webkit-hyphens: none) and (not (margin-trim: inline))) or ((-moz-orient: inline) and (not (color:rgb(from red r g b)))) { + :root, :host { + --accent: #1F8FA3; + --accent-hover: #197D90; + --accent-active: #146B7C; + --accent-subtle: rgba(31, 143, 163, 0.12); + --accent-glow: rgba(60, 175, 195, 0.32); + --accent-glow-strong: rgba(60, 175, 195, 0.48); + --accent-glow-core: rgba(180, 238, 248, 0.60); + } + *, ::before, ::after, ::backdrop { + --tw-space-y-reverse: 0; + --tw-space-x-reverse: 0; + --tw-border-style: solid; + --tw-leading: initial; + --tw-font-weight: initial; + --tw-tracking: initial; + --tw-ordinal: initial; + --tw-slashed-zero: initial; + --tw-numeric-figure: initial; + --tw-numeric-spacing: initial; + --tw-numeric-fraction: initial; + --tw-shadow: 0 0 #0000; + --tw-shadow-color: initial; + --tw-shadow-alpha: 100%; + --tw-inset-shadow: 0 0 #0000; + --tw-inset-shadow-color: initial; + --tw-inset-shadow-alpha: 100%; + --tw-ring-color: initial; + --tw-ring-shadow: 0 0 #0000; + --tw-inset-ring-color: initial; + --tw-inset-ring-shadow: 0 0 #0000; + --tw-ring-inset: initial; + --tw-ring-offset-width: 0px; + --tw-ring-offset-color: #fff; + --tw-ring-offset-shadow: 0 0 #0000; + --tw-duration: initial; + --tw-ease: initial; + } + } +} + +/* -------------------------------------------------------------------------- + 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. + -------------------------------------------------------------------------- */ +:root { + --font-geist: system-ui, -apple-system, 'Segoe UI', sans-serif; + --font-geist-mono: ui-monospace, 'SF Mono', Menlo, Consolas, monospace; + --font-source-serif: Charter, 'Iowan Old Style', Georgia, serif; +} diff --git a/middleware/src/admin-ui/harness-admin-css.ts b/middleware/src/admin-ui/harness-admin-css.ts deleted file mode 100644 index 43cfc2c6..00000000 --- a/middleware/src/admin-ui/harness-admin-css.ts +++ /dev/null @@ -1,345 +0,0 @@ -/** - * Harness Platform — Admin-UI baseline stylesheet. - * - * Embedded as a TS string so the build pipeline (`tsc --outDir dist`) ships - * it cleanly without an asset-copy step. Tokens mirror - * `web-ui/app/_lib/theme.css`; keep the two roughly in sync when the - * design system changes. - * - * Served by `routes/harnessAdminUi.ts` at GET /api/_harness/admin-ui.css. - * Plugin admin-UIs link it via: - * - * - * Decisions captured in docs/harness-platform/PLAN-admin-ui-theming.md: - * - Global body styling (no scope class) — all plugins follow our UI. - * - Light + dark mode via `@media (prefers-color-scheme: dark)`. - * - `.harness-*` prefix on helper classes to avoid plugin-side collisions. - * - Layout-constraint custom-properties so plugin CSS can opt-in. - */ -export const HARNESS_ADMIN_CSS = String.raw`/* Harness Platform — Admin-UI baseline. Source of truth: - middleware/src/admin-ui/harness-admin-css.ts. */ - -:root { - color-scheme: light dark; - - /* === byte5 brand tokens (mirrors web-ui/app/_lib/theme.css) === */ - --b5-blau: #009FE3; - --b5-blau-dunkel: #004B73; - --b5-weiss: #FFFFFF; - --b5-magenta: #EA5172; - - --gray-50: #F4F7FA; - --gray-100: #E8EEF3; - --gray-200: #D3DDE5; - --gray-300: #B4C2CD; - --gray-400: #8A99A6; - --gray-500: #5F6E7B; - --gray-600: #3F4D59; - --gray-700: #283540; - --gray-800: #15212B; - --gray-900: #0A1420; - - --bg: var(--b5-weiss); - --bg-soft: var(--gray-50); - --bg-elevated: var(--b5-weiss); - - --fg: var(--b5-blau-dunkel); - --fg-strong: var(--b5-blau-dunkel); - --fg-muted: var(--gray-500); - --fg-subtle: var(--gray-400); - - --accent: var(--b5-blau); - --accent-fg: var(--b5-weiss); - --accent-hover: #0086C0; - --accent-press: #006C9C; - - --highlight: var(--b5-magenta); - - --border: var(--gray-200); - --border-strong: var(--gray-300); - --divider: rgba(0, 75, 115, 0.12); - - --success: #15A06B; - --warning: #E0A82E; - --danger: #D9354C; - --info: var(--b5-blau); - - /* COMPAT aliases — same names the web-ui components use, so plugin - authors copying web-ui classnames find what they expect. */ - --paper: var(--bg); - --ink: var(--fg); - --muted-ink: var(--fg-muted); - --faint-ink: var(--fg-subtle); - --rule: var(--border); - --rule-strong: var(--border-strong); - - --font-text: 'Nunito Sans', ui-sans-serif, system-ui, -apple-system, - 'Segoe UI', sans-serif; - --font-display: 'Days One', 'Nunito Sans', system-ui, sans-serif; - --font-mono: 'JetBrains Mono', ui-monospace, 'SF Mono', Menlo, monospace; - - --radius-xs: 4px; - --radius-sm: 8px; - --radius-md: 14px; - --radius-pill: 9999px; - - --shadow-xs: 0 1px 2px rgba(0, 75, 115, 0.06); - --shadow-sm: 0 2px 6px rgba(0, 75, 115, 0.08); - --shadow-md: 0 8px 24px rgba(0, 75, 115, 0.10); - - --dur-fast: 140ms; - - /* === Layout constraints — admin-UI iframe sizing in web-ui hostframe. - Source: web-ui/app/store/[id]/page.tsx grid lg:grid-cols-[1fr_340px] - within max-w-[1280px] minus padding. Plugin CSS may reference these - to size tables / cards responsively. === */ - --harness-iframe-max: 812px; - --harness-iframe-min: 320px; - --harness-iframe-height: 1000px; -} - -@media (prefers-color-scheme: dark) { - :root { - --bg: #0B1A24; - --bg-soft: #102533; - --bg-elevated: #15303F; - - --fg: #E8F2F8; - --fg-strong: #FFFFFF; - --fg-muted: #A4B4C2; - --fg-subtle: #768796; - - --border: rgba(255, 255, 255, 0.10); - --border-strong: rgba(255, 255, 255, 0.18); - --divider: rgba(255, 255, 255, 0.10); - - --accent-hover: #33B0E8; - --accent-press: #66C1ED; - } -} - -/* === Element baseline =================================================== */ - -*, *::before, *::after { box-sizing: border-box; } - -html, body { height: 100%; } - -body { - margin: 0; - padding: 1.25rem; - background: var(--bg); - color: var(--fg); - font-family: var(--font-text); - font-size: 14px; - line-height: 1.5; - -webkit-font-smoothing: antialiased; - text-rendering: optimizeLegibility; -} - -h1, h2, h3, h4, h5, h6 { - font-family: var(--font-display); - color: var(--fg-strong); - margin: 0 0 0.5rem; - line-height: 1.2; -} - -h1 { font-size: 1.5rem; } -h2 { font-size: 1.25rem; } -h3 { font-size: 1.05rem; } -h4, h5, h6 { font-size: 1rem; } - -p { - margin: 0 0 0.75rem; - color: var(--fg); -} - -a { - color: var(--accent); - text-decoration: underline; - text-decoration-color: var(--border-strong); - text-underline-offset: 2px; -} -a:hover { color: var(--accent-hover); } - -code, pre, kbd, samp { - font-family: var(--font-mono); - font-size: 0.92em; -} - -code { - background: var(--bg-soft); - border: 1px solid var(--border); - border-radius: var(--radius-xs); - padding: 0.05em 0.35em; -} - -pre { - padding: 0.75rem; - background: var(--bg-soft); - border: 1px solid var(--border); - border-radius: var(--radius-sm); - overflow-x: auto; -} - -hr { - border: 0; - height: 1px; - background: var(--divider); - margin: 1rem 0; -} - -/* === Tables ============================================================= */ - -table { - width: 100%; - border-collapse: collapse; - font-size: 0.92rem; -} - -th, td { - padding: 0.55rem 0.7rem; - text-align: left; - vertical-align: top; - border-bottom: 1px solid var(--divider); -} - -th { - font-weight: 600; - font-size: 0.78rem; - letter-spacing: 0.04em; - text-transform: uppercase; - color: var(--fg-muted); - background: var(--bg-soft); - border-bottom: 1px solid var(--border); -} - -tbody tr:hover { background: var(--bg-soft); } - -/* === Form controls ====================================================== */ - -input, select, textarea, button { - font: inherit; - color: var(--fg); -} - -input, select, textarea { - width: 100%; - max-width: 100%; - padding: 0.45rem 0.65rem; - background: var(--bg-elevated); - color: var(--fg); - border: 1px solid var(--border-strong); - border-radius: var(--radius-sm); - transition: border-color var(--dur-fast), box-shadow var(--dur-fast); -} - -input[type='checkbox'], input[type='radio'] { - width: auto; - accent-color: var(--accent); -} - -input:focus, select:focus, textarea:focus { - outline: none; - border-color: var(--accent); - box-shadow: 0 0 0 3px rgba(0, 159, 227, 0.18); -} - -button { - cursor: pointer; - padding: 0.5rem 1rem; - background: var(--bg-soft); - border: 1px solid var(--border-strong); - color: var(--fg); - border-radius: var(--radius-pill); - font-weight: 600; - letter-spacing: 0.02em; - transition: background-color var(--dur-fast), border-color var(--dur-fast), - color var(--dur-fast); -} - -button:hover { - background: var(--bg-elevated); - border-color: var(--accent); - color: var(--accent); -} - -button:disabled { opacity: 0.55; cursor: not-allowed; } - -/* === Helper classes (.harness-*) ======================================== */ - -.harness-subtitle { - color: var(--fg-muted); - font-size: 0.92rem; - margin: 0.25rem 0 1.25rem; -} - -.harness-empty { - font-style: italic; - color: var(--fg-subtle); -} - -.harness-btn { - display: inline-flex; - align-items: center; - justify-content: center; - gap: 0.4rem; - padding: 0.5rem 1rem; - background: var(--bg-soft); - color: var(--fg); - border: 1px solid var(--border-strong); - border-radius: var(--radius-pill); - font-weight: 600; - cursor: pointer; - transition: background-color var(--dur-fast), border-color var(--dur-fast), - color var(--dur-fast); -} - -.harness-btn:hover { - background: var(--bg-elevated); - border-color: var(--accent); - color: var(--accent); -} - -.harness-btn--primary { - background: var(--accent); - color: var(--accent-fg); - border-color: var(--accent); -} - -.harness-btn--primary:hover { - background: var(--accent-hover); - border-color: var(--accent-hover); - color: var(--accent-fg); -} - -.harness-input { - width: 100%; - max-width: 100%; -} - -.harness-table { - width: 100%; - border-collapse: collapse; -} - -.harness-banner-error, -.harness-banner-info { - padding: 0.65rem 0.85rem; - border-radius: var(--radius-sm); - margin-bottom: 0.85rem; - font-size: 0.92rem; - border: 1px solid transparent; -} - -.harness-banner-error { - background: color-mix(in srgb, var(--danger) 10%, transparent); - color: var(--danger); - border-color: color-mix(in srgb, var(--danger) 30%, transparent); -} - -.harness-banner-info { - background: color-mix(in srgb, var(--info) 10%, transparent); - color: var(--info); - border-color: color-mix(in srgb, var(--info) 30%, transparent); -} -`; diff --git a/middleware/src/index.ts b/middleware/src/index.ts index 10f4785d..8b044eda 100644 --- a/middleware/src/index.ts +++ b/middleware/src/index.ts @@ -95,6 +95,7 @@ import type { TopicClusteringService, } from '@omadia/plugin-api'; import { createHarnessAdminUiRouter } from './routes/harnessAdminUi.js'; +import { createPluginUiStaticRouter } from './routes/pluginUiStatic.js'; import { createStoreRouter } from './routes/store.js'; import { createInstallRouter } from './routes/install.js'; import { createAdminRegistriesRouter } from './routes/adminRegistries.js'; @@ -2673,11 +2674,34 @@ async function main(): Promise { : `[middleware] MCP client-ID metadata document at GET ${CIMD_METADATA_PATH} answers 501 — FLOW_PUBLIC_BASE_URL unset, so CIMD is off and issuers use the manual client path`, ); - // Harness shared assets — currently the admin-UI baseline stylesheet - // that plugin-bundled admin UIs `` into their HTML. No auth: the - // CSS is static and operator-agnostic. See PLAN-admin-ui-theming.md. - app.use('/api/_harness', createHarnessAdminUiRouter()); - console.log('[middleware] harness admin-ui assets ready at /api/_harness/admin-ui.css'); + // Shared assets for plugin-authored UI: the generated design-system + // stylesheet (epic #470 C8) plus any `@font-face` sources. No auth — the + // bytes are static and operator-agnostic. `admin-ui.css` remains an alias + // so shipped plugin admin UIs keep resolving. + app.use('/api/_harness', await createHarnessAdminUiRouter()); + console.log( + '[middleware] plugin UI stylesheet ready at /api/_harness/plugin-ui.css (alias: /admin-ui.css)', + ); + + // Static serving for a plugin's compiled SPA bundle, at + // `/p//ui/...` — under the plugin's own prefix, so the nav + // contribution API, the `publicPaths` entry and the web-ui `/p/*` proxy all + // apply unchanged. Mounted BEFORE the plugin route flush so core owns the + // `ui/` segment; every other path under `/p` falls through to the plugin's + // own router. Read-only, extension-allowlisted, traversal-checked — and the + // allowlist has no `.css` in it, which is what keeps the Tailwind + // vocabulary the only styling channel a plugin has. + app.use( + '/p', + createPluginUiStaticRouter({ + resolvePackageRoot: (pluginId) => { + const entry = pluginCatalog.get(pluginId); + if (!entry) return undefined; + return path.dirname(entry.source_path); + }, + }), + ); + console.log('[middleware] plugin UI bundles served at /p//ui/'); const agentResolver = createAgentResolver({ dynamicRuntime: dynamicAgentRuntime }); // Phase A — Chat router resolves per-Agent via the registry. Falls diff --git a/middleware/src/platform/assets.ts b/middleware/src/platform/assets.ts index ce0230eb..3f487dfd 100644 --- a/middleware/src/platform/assets.ts +++ b/middleware/src/platform/assets.ts @@ -118,6 +118,14 @@ export const ASSETS = { 'COPY middleware/assets/entity-registry.v1.yaml ./entity-registry.v1.yaml (Dockerfile)', kind: 'file', }), + /** middleware/assets/plugin-ui/ — generated plugin stylesheet + fonts. */ + pluginUi: resolveAssetBundle({ + id: 'pluginUi', + envVar: 'PLUGIN_UI_ASSETS_DIR', + devFallback: path.join(MIDDLEWARE_ROOT, 'assets', 'plugin-ui'), + prodHint: 'COPY middleware/assets/plugin-ui ./plugin-ui (Dockerfile)', + kind: 'directory', + }), /** middleware/packages/agent-reference-maximum — Builder pattern source. */ referencePackage: resolveAssetBundle({ id: 'referencePackage', @@ -178,6 +186,13 @@ export function _rebuildAssetsForTests(): void { 'COPY middleware/assets/entity-registry.v1.yaml ./entity-registry.v1.yaml (Dockerfile)', kind: 'file', }), + pluginUi: resolveAssetBundle({ + id: 'pluginUi', + envVar: 'PLUGIN_UI_ASSETS_DIR', + devFallback: path.join(MIDDLEWARE_ROOT, 'assets', 'plugin-ui'), + prodHint: 'COPY middleware/assets/plugin-ui ./plugin-ui (Dockerfile)', + kind: 'directory', + }), referencePackage: resolveAssetBundle({ id: 'referencePackage', envVar: 'BUILDER_REFERENCE_PACKAGE_DIR', @@ -193,5 +208,6 @@ export function _rebuildAssetsForTests(): void { }; (ASSETS as unknown as Record).boilerplate = next.boilerplate; (ASSETS as unknown as Record).entityRegistry = next.entityRegistry; + (ASSETS as unknown as Record).pluginUi = next.pluginUi; (ASSETS as unknown as Record).referencePackage = next.referencePackage; } diff --git a/middleware/src/plugins/packageUploadService.ts b/middleware/src/plugins/packageUploadService.ts index 7ae54702..9953257e 100644 --- a/middleware/src/plugins/packageUploadService.ts +++ b/middleware/src/plugins/packageUploadService.ts @@ -19,6 +19,10 @@ import { type UploadedPackage, } from './uploadedPackageStore.js'; import { extractZipToDir, ZipExtractionError } from './zipExtractor.js'; +import { + formatArbitraryValueOffenders, + scanForArbitraryTailwindValues, +} from './tailwindArbitraryValueScan.js'; /** * Accepts an uploaded zip, validates it and registers the contained agent @@ -237,6 +241,27 @@ export class PackageUploadService { ); } + // --- 8b. UI bundle: no arbitrary Tailwind values ---------------------- + // Epic #470 C8. A plugin's `ui/` bundle styles itself exclusively from + // the stylesheet core generates and serves, which carries a finite, + // documented vocabulary. A class outside it renders unstyled and says + // nothing about why, so an arbitrary value is rejected here, where the + // answer can name the file and the line. `tailwindArbitraryValueScan.ts` + // documents the two patterns and their known limits. + const uiOffenders = scanForArbitraryTailwindValues( + await readUiBundleScripts(packageRoot), + ); + if (uiOffenders.length > 0) { + return fail( + 'package.ui_arbitrary_tailwind_value', + 'The ui/ bundle uses Tailwind arbitrary values. Plugins may only use the ' + + 'vocabulary core pre-generates (see plugin-ui-vocabulary.md in the ' + + 'epic #470 spec directory); an undeclared class renders unstyled.\n' + + formatArbitraryValueOffenders(uiOffenders), + { offenders: uiOffenders }, + ); + } + // --- 9. ID-Konflikt-Check -------------------------------------------- const existingUploaded = this.deps.store.get(plugin.id); const catalogEntry = this.deps.catalog.get(plugin.id); @@ -505,6 +530,61 @@ async function resolvePackageRoot(stagingRoot: string): Promise { return null; } +/** + * Reads every `.js` / `.mjs` below the staged package's `ui/` directory for + * the arbitrary-value scan. Bounded on purpose: a bundle past the cap is left + * unscanned rather than allowed to turn ingest into an OOM. `node_modules` + * and dot-directories are skipped — the served bundle never reaches into them + * (`pluginUiStatic.ts` serves the `ui/` tree only, and the extractor rejects + * symlinks outright). + */ +const UI_SCAN_MAX_FILES = 200; +const UI_SCAN_MAX_BYTES = 8 * 1024 * 1024; + +async function readUiBundleScripts( + packageRoot: string, +): Promise> { + const uiRoot = path.join(packageRoot, 'ui'); + const out: Array<{ path: string; content: string }> = []; + let budget = UI_SCAN_MAX_BYTES; + + const walk = async (dir: string): Promise => { + if (out.length >= UI_SCAN_MAX_FILES || budget <= 0) return; + let entries: Dirent[]; + try { + entries = await fs.readdir(dir, { withFileTypes: true }); + } catch { + return; + } + for (const entry of entries) { + if (out.length >= UI_SCAN_MAX_FILES || budget <= 0) return; + if (entry.name.startsWith('.') || entry.name === 'node_modules') continue; + const abs = path.join(dir, entry.name); + if (entry.isDirectory()) { + await walk(abs); + continue; + } + if (!entry.isFile()) continue; + const ext = path.extname(entry.name).toLowerCase(); + if (ext !== '.js' && ext !== '.mjs') continue; + let content: string; + try { + content = await fs.readFile(abs, 'utf-8'); + } catch { + continue; + } + budget -= Buffer.byteLength(content); + out.push({ + path: path.relative(packageRoot, abs).split(path.sep).join('/'), + content, + }); + } + }; + + await walk(uiRoot); + return out; +} + async function readSubdirs(dir: string): Promise { try { const entries = await fs.readdir(dir, { withFileTypes: true }); diff --git a/middleware/src/plugins/tailwindArbitraryValueScan.ts b/middleware/src/plugins/tailwindArbitraryValueScan.ts new file mode 100644 index 00000000..90d8473a --- /dev/null +++ b/middleware/src/plugins/tailwindArbitraryValueScan.ts @@ -0,0 +1,145 @@ +/** + * Ingest gate for Tailwind arbitrary values in a plugin's compiled UI bundle + * (epic #470 C8 / plan.md §4.3a). + * + * WHY. Plugins ship no stylesheet — `.css` is absent from the ZIP extension + * allowlist and stays absent, because that absence is what forces every + * plugin onto the one stylesheet core serves. That sheet is generated from a + * finite, documented vocabulary (`plugin-ui-vocabulary.md`, in the epic + * #470 spec directory). An *exact* arbitrary value can be + * pre-generated — `@source inline("w-[137px]")` emits it — but the universe + * of them cannot, so a class core never saw silently renders unstyled. Silent + * is the worst failure mode available: the package installs, the page loads, + * and the layout is subtly wrong on the operator's screen only. + * + * So the check runs at ingest, where it can say no with a filename and a line. + * + * WHAT IT SEES. Not JSX. By the time a package reaches us the UI is compiled + * Vite/Rollup output, so the class list survives as string literals and + * template chunks inside `.js`. That is why this scans text rather than + * parsing attributes: `className={cn('p-4', wide && 'w-[137px]')}` has become + * `"p-4"` and `"w-[137px]"` in the bundle, and both are plain substrings. + * + * THE TWO PATTERNS + * + * 1. `utility-[value]` — `w-[137px]`, `bg-[#abc]`, `grid-cols-[1fr_2fr]`, + * including variant prefixes: `md:hover:w-[137px]`. + * 2. `[&…]` variants — `[&>tr]:border`, `[&_p]:mt-2`. These are + * arbitrary *variants* rather than values; same problem, different + * syntax, so they get their own pattern. + * + * KNOWN LIMITS — stated rather than papered over, because a scanner whose + * blind spots are undocumented gets trusted past its competence: + * + * - FALSE POSITIVES are possible. The patterns match text, and text in a + * bundle is not only class names. `pick-[a]` inside a regex literal, a + * CSS-in-JS string, or a user-facing message would be reported. The + * mitigation is the report, not the regex: the offender list carries the + * file, the 1-based line and the matched token, so an author can see in + * one glance that the hit is not a class. Two narrowings keep the rate + * low in practice: the utility head must be lower-case-and-dashes (so + * `getFoo[0]` and `arr[i]` never match), and the bracket body may not + * contain whitespace, quotes or backticks (so most prose and most + * expressions drop out). + * - FALSE NEGATIVES are possible too, and they are the safer direction. A + * bundle that assembles a class at runtime (`'w-[' + n + 'px]'`) defeats + * any static check, and so do unicode/hex-escaped brackets + * (`"w-\u005b10px\u005d"`). Nothing here claims otherwise: the vocabulary + * is the contract, this is the cheap enforcement of it, and a plugin + * determined to route around it merely ends up with an unstyled element. + * - Only files the caller passes are examined. The caller scans + * `ui/**\/*.js`; a plugin that puts its bundle elsewhere is not covered + * by this gate (nor is it served by `pluginUiStatic.ts`). + */ + +/** + * `w-[137px]`, `md:hover:bg-[#abc]`, `group-hover:w-[137px]`, + * `data-[state=open]:!w-[137px]`, `lg:-mt-[3px]`. + * + * (?tr]:border`, `[&_p]:mt-2` — arbitrary variants. */ +const ARBITRARY_VARIANT = /(\[&[^\]\s"'`]*\](?::[a-z0-9[\]&_>-]+)?)/g; + +export interface ArbitraryValueOffender { + /** Package-relative path, as handed in by the caller. */ + readonly file: string; + /** 1-based line number inside that file. */ + readonly line: number; + /** The matched token, truncated to keep the API response bounded. */ + readonly token: string; + readonly kind: 'arbitrary-value' | 'arbitrary-variant'; +} + +export interface ScanInputFile { + readonly path: string; + readonly content: string; +} + +/** Hard cap so a hostile bundle cannot make the error payload the attack. */ +const MAX_OFFENDERS = 25; +const MAX_TOKEN_LENGTH = 60; + +/** + * Scans compiled bundle text for arbitrary Tailwind values. Returns at most + * {@link MAX_OFFENDERS} offenders, deduplicated on file+line+token. + */ +export function scanForArbitraryTailwindValues( + files: readonly ScanInputFile[], +): ArbitraryValueOffender[] { + const offenders: ArbitraryValueOffender[] = []; + const seen = new Set(); + + for (const file of files) { + const lines = file.content.split('\n'); + for (let i = 0; i < lines.length; i += 1) { + const text = lines[i] ?? ''; + collect(text, ARBITRARY_VALUE, 'arbitrary-value', file.path, i + 1, offenders, seen); + collect(text, ARBITRARY_VARIANT, 'arbitrary-variant', file.path, i + 1, offenders, seen); + if (offenders.length >= MAX_OFFENDERS) return offenders; + } + } + return offenders; +} + +function collect( + text: string, + pattern: RegExp, + kind: ArbitraryValueOffender['kind'], + file: string, + line: number, + out: ArbitraryValueOffender[], + seen: Set, +): void { + // Fresh lastIndex per line — the module-level regexes are /g and stateful. + pattern.lastIndex = 0; + let match: RegExpExecArray | null; + while ((match = pattern.exec(text)) !== null) { + if (out.length >= MAX_OFFENDERS) return; + const token = (match[1] ?? match[0]).slice(0, MAX_TOKEN_LENGTH); + const key = `${file}:${String(line)}:${token}`; + if (seen.has(key)) continue; + seen.add(key); + out.push({ file, line, token, kind }); + } +} + +/** One-line-per-offender rendering for the ingest error message. */ +export function formatArbitraryValueOffenders( + offenders: readonly ArbitraryValueOffender[], +): string { + return offenders + .map((o) => ` ${o.file}:${String(o.line)} — ${o.token} (${o.kind})`) + .join('\n'); +} diff --git a/middleware/src/plugins/zipExtractor.ts b/middleware/src/plugins/zipExtractor.ts index 30799a72..fffdb2e1 100644 --- a/middleware/src/plugins/zipExtractor.ts +++ b/middleware/src/plugins/zipExtractor.ts @@ -50,6 +50,24 @@ const EXTENSION_ALLOWLIST: ReadonlySet = new Set([ const DECL_EXTENSIONS: ReadonlySet = new Set(['.ts', '.mts', '.cts']); +/** + * Extensions accepted ONLY below a `ui/` directory (epic #470 C8). + * + * A plugin's compiled SPA bundle may carry web fonts; nothing else in a + * package has any business shipping a binary font, so the grant is scoped to + * the one place it is needed rather than widened globally. + * + * `.css` is deliberately NOT here, and must never be added — see + * `implementation.md` §1 row 3. Plugins link the stylesheet core generates + * and serves; the moment one can ship its own, "plugins inherit the design + * system by construction" stops being true, and the arbitrary-value ingest + * check becomes theatre because a plugin could simply write the rule itself. + */ +const UI_BUNDLE_EXTENSIONS: ReadonlySet = new Set(['.woff2']); + +/** Matches `ui/...` at the root or below a single wrapper directory. */ +const UI_BUNDLE_PATH = /(?:^|\/)ui\//; + export interface ExtractLimits { maxEntries: number; maxExtractedBytes: number; @@ -167,9 +185,12 @@ export async function extractZipToDir( baseName === 'NOTICE' || baseName === 'README' || baseName === '.npmignore'; + const uiBundleAllowed = + UI_BUNDLE_EXTENSIONS.has(ext) && UI_BUNDLE_PATH.test(relativeName); if ( !EXTENSION_ALLOWLIST.has(ext) && !DECL_EXTENSIONS.has(ext) && + !uiBundleAllowed && !isTopLevelLike ) { throw new ZipExtractionError( diff --git a/middleware/src/routes/harnessAdminUi.ts b/middleware/src/routes/harnessAdminUi.ts index 29af545b..38cc7dd0 100644 --- a/middleware/src/routes/harnessAdminUi.ts +++ b/middleware/src/routes/harnessAdminUi.ts @@ -1,36 +1,115 @@ import { createHash } from 'node:crypto'; +import { promises as fs } from 'node:fs'; +import path from 'node:path'; + import { Router, type Request, type Response } from 'express'; -import { HARNESS_ADMIN_CSS } from '../admin-ui/harness-admin-css.js'; +import { ASSETS } from '../platform/assets.js'; /** - * Harness Admin-UI shared assets. Today: a single baseline stylesheet that - * plugin-bundled admin UIs `` into their HTML so they inherit byte5 - * tokens, typography, and form styling without each plugin re-implementing - * it. See docs/harness-platform/PLAN-admin-ui-theming.md. + * Shared assets for plugin-authored UI surfaces. + * + * GET /api/_harness/plugin-ui.css the design-system stylesheet + * GET /api/_harness/admin-ui.css legacy alias for the same bytes + * GET /api/_harness/plugin-ui/fonts/ `@font-face` sources, if any + * + * Browsers reach these through the web-ui proxy at `/bot-api/_harness/...`. + * + * Epic #470 C8. Until this change the stylesheet was + * `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". It is now generated from `web-ui/app/_lib/theme.css` + * plus the shared `_lib/tailwind-bridge.css` by + * `web-ui/scripts/build-plugin-ui-css.mjs`, committed to + * `middleware/assets/plugin-ui/plugin-ui.css`, and diffed in CI. The sync + * obligation is gone rather than restated. * - * Mounted at `/api/_harness` from middleware/src/index.ts. Plugins reach - * the asset through the web-ui rewrite at `/bot-api/_harness/admin-ui.css`. + * `admin-ui.css` stays as an alias because shipped plugin admin UIs (and the + * boilerplate template) already `` it; the generated sheet carries the + * same `.harness-*` helper classes, now token-driven. * - * No auth: stylesheet is public, content has no operator-specific data. + * The file is read once at construction and held in memory: it is ~70 KB and + * changes only on deploy. A missing file is a boot failure, not a runtime + * 404 — `ASSETS.pluginUi` is part of `verifyAssetBundles()`. + * + * No auth: the stylesheet and fonts carry no operator-specific data, and the + * plugin surfaces that link them are themselves unauthenticated documents. */ -export function createHarnessAdminUiRouter(): Router { + +const STYLESHEET_FILE = 'plugin-ui.css'; +const FONTS_DIR = 'fonts'; +/** Fonts are content-hashed by nobody, so cache modestly and revalidate. */ +const FONT_CACHE_CONTROL = 'public, max-age=86400, must-revalidate'; +const CSS_CACHE_CONTROL = 'public, max-age=300, must-revalidate'; + +export interface HarnessAdminUiRouterOptions { + /** Override the asset root. Tests point this at a fixture directory. */ + assetsRoot?: string; +} + +export async function createHarnessAdminUiRouter( + options: HarnessAdminUiRouterOptions = {}, +): Promise { + const root = options.assetsRoot ?? ASSETS.pluginUi.root; + const css = await fs.readFile(path.join(root, STYLESHEET_FILE), 'utf-8'); + const etag = `"${createHash('sha256').update(css).digest('hex').slice(0, 16)}"`; + const router = Router(); - const etag = `"${createHash('sha256') - .update(HARNESS_ADMIN_CSS) - .digest('hex') - .slice(0, 16)}"`; - router.get('/admin-ui.css', (req: Request, res: Response) => { + const serveCss = (req: Request, res: Response): void => { if (req.headers['if-none-match'] === etag) { res.status(304).end(); return; } res.set('Content-Type', 'text/css; charset=utf-8'); res.set('ETag', etag); - res.set('Cache-Control', 'public, max-age=300, must-revalidate'); - res.send(HARNESS_ADMIN_CSS); + res.set('Cache-Control', CSS_CACHE_CONTROL); + res.set('X-Content-Type-Options', 'nosniff'); + res.send(css); + }; + + router.get(`/${STYLESHEET_FILE}`, serveCss); + // Legacy alias — shipped plugin admin UIs link this path. + router.get('/admin-ui.css', serveCss); + + router.get('/plugin-ui/fonts/:file', (req: Request, res: Response) => { + void serveFont(root, req, res); }); return router; } + +/** + * Font files are addressed by bare basename. `:file` cannot contain a `/` + * (Express never matches one into a single param) but it CAN contain `..`, + * so containment is re-checked after resolution rather than assumed. + */ +async function serveFont( + root: string, + req: Request, + res: Response, +): Promise { + const raw = req.params['file']; + const name = typeof raw === 'string' ? raw : ''; + if (!/^[A-Za-z0-9._-]+\.woff2$/.test(name) || name.includes('..')) { + res.status(404).end(); + return; + } + const fontsRoot = path.resolve(root, FONTS_DIR); + const abs = path.resolve(fontsRoot, name); + if (!abs.startsWith(fontsRoot + path.sep)) { + res.status(404).end(); + return; + } + let body: Buffer; + try { + body = await fs.readFile(abs); + } catch { + res.status(404).end(); + return; + } + res.set('Content-Type', 'font/woff2'); + res.set('Cache-Control', FONT_CACHE_CONTROL); + res.set('X-Content-Type-Options', 'nosniff'); + res.send(body); +} diff --git a/middleware/src/routes/pluginUiStatic.ts b/middleware/src/routes/pluginUiStatic.ts new file mode 100644 index 00000000..f17ff66c --- /dev/null +++ b/middleware/src/routes/pluginUiStatic.ts @@ -0,0 +1,236 @@ +import { createHash } from 'node:crypto'; +import { promises as fs } from 'node:fs'; +import path from 'node:path'; + +import { Router, type Request, type Response } from 'express'; + +/** + * Static serving for a plugin's compiled SPA bundle (epic #470 C8 / G7). + * + * A distributed plugin ships its UI as a `ui/` directory inside its package + * ZIP — `ui/index.html` plus hashed JS and assets. Core serves that directory + * read-only at + * + * GET /p//ui/ → ui/index.html + * GET /p//ui/ → ui/ + * + * i.e. under the plugin's own `/p/` prefix, so nav entries, the + * `publicPaths` entry and the web-ui `/p/*` proxy all keep working unchanged. + * The router is mounted at `/p` by core and passes every non-`/ui` request + * through with `next()`, so a plugin's own Express router still owns the rest + * of its prefix. + * + * WHY CORE SERVES THIS AND NOT THE PLUGIN. The plugin contract has no static + * middleware, and handing plugins `express.static` would put the traversal, + * content-type and caching decisions in thirty repositories instead of one. + * It also keeps the rule that makes the Tailwind vocabulary enforceable: this + * handler serves an extension allowlist that contains no `.css`, so even a + * plugin that smuggled a stylesheet past the ZIP extractor could not get it + * served. + * + * SECURITY PROPERTIES, each covered by a test in + * `test/pluginUiStaticServing.test.ts`: + * + * - No traversal. The URL path is decoded, rejected if it still contains a + * `..` segment or a NUL, resolved, and then re-checked for containment + * inside `/ui` — belt and braces, because `path.resolve` + * normalising `..` away is exactly what makes the naive check pass. + * - No directory listing and no directory fallthrough: a request that + * resolves to a directory is a 404, except the bundle root which serves + * `index.html`. + * - No symlink escape: the resolved real path is containment-checked too, + * so a symlink that survived extraction cannot point out of the bundle. + * (The extractor rejects symlinks; this does not rely on that.) + * - Extension allowlist, and the `Content-Type` comes from that same table + * rather than from sniffing, with `X-Content-Type-Options: nosniff`. + * - `Content-Security-Policy` on `index.html` confines the document: no + * inline script beyond its own bootstrap hash-free `script-src 'self'`, + * stylesheets only from core, `frame-ancestors 'self'` so it can be + * iframed by the shell and by nobody else. + * + * CACHING. A file whose basename carries a build hash (`app-4f2a9c1e.js`) + * gets `immutable, max-age=1y`; everything else, `index.html` above all, gets + * `no-cache` with an ETag, so an upgrade is visible on the next reload. + */ + +/** Extension → Content-Type. Deliberately no `.css` — see the header. */ +const CONTENT_TYPES: ReadonlyMap = new Map([ + ['.html', 'text/html; charset=utf-8'], + ['.js', 'text/javascript; charset=utf-8'], + ['.mjs', 'text/javascript; charset=utf-8'], + ['.map', 'application/json; charset=utf-8'], + ['.json', 'application/json; charset=utf-8'], + ['.svg', 'image/svg+xml'], + ['.png', 'image/png'], + ['.jpg', 'image/jpeg'], + ['.jpeg', 'image/jpeg'], + ['.woff2', 'font/woff2'], + ['.txt', 'text/plain; charset=utf-8'], +]); + +/** The directory inside a package root that holds the bundle. */ +export const UI_BUNDLE_DIR = 'ui'; + +const IMMUTABLE_CACHE = 'public, max-age=31536000, immutable'; +const REVALIDATE_CACHE = 'no-cache'; + +/** + * `name-.ext` where the candidate hash is 8+ base36-ish characters and + * contains at least one digit. The safe failure direction is "revalidate" for + * an unusual hash, not "freeze an ordinary dashed filename for a year". + */ +const HASHED_BASENAME = /-(?=[A-Za-z0-9_]{8,}\.[A-Za-z0-9]+$)(?=[A-Za-z0-9_]*\d)[A-Za-z0-9_]+\.[A-Za-z0-9]+$/; + +const CSP = [ + "default-src 'none'", + "script-src 'self'", + "style-src 'self' 'unsafe-inline'", + "img-src 'self' data:", + "font-src 'self'", + "connect-src 'self'", + "form-action 'none'", + "base-uri 'none'", + "frame-ancestors 'self'", +].join('; '); + +/** + * SVG is served as an image asset, not as an interactive document. A stricter + * policy than the HTML shell is therefore correct, and the safe failure + * direction is to make a directly navigated SVG inert rather than same-origin. + */ +const IMAGE_CSP = "default-src 'none'; style-src 'unsafe-inline'; sandbox"; + +export interface PluginUiStaticOptions { + /** + * Resolves a plugin id to its package root (the directory holding + * `manifest.yaml`), or `undefined` when the id is unknown. Injected so the + * router does not reach into the catalog, the uploaded store and the + * built-in store itself. + */ + resolvePackageRoot(pluginId: string): string | undefined; +} + +export function createPluginUiStaticRouter( + options: PluginUiStaticOptions, +): Router { + // `mergeParams` is irrelevant here; the router owns both params itself. + const router = Router(); + + router.get('/:pluginId/ui', (req, res) => { + void serve(options, req, res, 'index.html'); + }); + router.get('/:pluginId/ui/{*assetPath}', (req, res) => { + const raw = req.params['assetPath']; + const joined = Array.isArray(raw) ? raw.join('/') : (raw ?? ''); + void serve(options, req, res, joined === '' ? 'index.html' : joined); + }); + + return router; +} + +async function serve( + options: PluginUiStaticOptions, + req: Request, + res: Response, + rawRelative: string, +): Promise { + const rawId = req.params['pluginId']; + const pluginId = typeof rawId === 'string' ? rawId : ''; + const packageRoot = options.resolvePackageRoot(pluginId); + if (!packageRoot) { + res.status(404).json({ error: 'plugin_ui_not_found' }); + return; + } + + const relative = safeRelativePath(rawRelative); + if (relative === null) { + res.status(400).json({ error: 'plugin_ui_bad_path' }); + return; + } + + const bundleRoot = path.resolve(packageRoot, UI_BUNDLE_DIR); + const abs = path.resolve(bundleRoot, relative); + if (!isContained(bundleRoot, abs)) { + res.status(400).json({ error: 'plugin_ui_bad_path' }); + return; + } + + const ext = path.extname(abs).toLowerCase(); + const contentType = CONTENT_TYPES.get(ext); + if (!contentType) { + // No listing, no sniffing, no `.css`: an extension we do not name is + // indistinguishable from a file that is not there. + res.status(404).json({ error: 'plugin_ui_not_found' }); + return; + } + + let body: Buffer; + let realPath: string; + try { + // realpath first: a symlink that survived extraction must not escape. + // The ROOT is realpath'd too, or every containment check fails wherever + // the packages directory itself sits behind a symlink — /var → /private/var + // on macOS, /tmp likewise on several distros. Comparing a resolved path + // against an unresolved root is the classic false negative here. + realPath = await fs.realpath(abs); + const realRoot = await fs.realpath(bundleRoot); + if (!isContained(realRoot, realPath)) { + res.status(400).json({ error: 'plugin_ui_bad_path' }); + return; + } + const stat = await fs.stat(realPath); + if (!stat.isFile()) { + res.status(404).json({ error: 'plugin_ui_not_found' }); + return; + } + body = await fs.readFile(realPath); + } catch { + res.status(404).json({ error: 'plugin_ui_not_found' }); + return; + } + + const etag = `"${createHash('sha256').update(body).digest('hex').slice(0, 16)}"`; + const hashed = HASHED_BASENAME.test(path.basename(abs)); + const responseCsp = ext === '.svg' ? IMAGE_CSP : CSP; + + res.set('Content-Type', contentType); + res.set('X-Content-Type-Options', 'nosniff'); + res.set('Referrer-Policy', 'no-referrer'); + res.set('Cache-Control', hashed ? IMMUTABLE_CACHE : REVALIDATE_CACHE); + res.set('ETag', etag); + res.set('Content-Security-Policy', responseCsp); + + if (req.headers['if-none-match'] === etag) { + res.status(304).end(); + return; + } + res.send(body); +} + +/** + * Decode and validate one URL path. Returns `null` for anything that must not + * reach the filesystem. Kept separate (and exported) so the traversal tests + * can hit it directly as well as through the router. + */ +export function safeRelativePath(raw: string): string | null { + let decoded: string; + try { + decoded = decodeURIComponent(raw); + } catch { + return null; + } + if (decoded.includes('\0')) return null; + // Windows separators would survive `path.posix` reasoning on a POSIX host + // and be a separator on a Windows one — normalise before judging. + const normalised = decoded.replace(/\\/g, '/'); + if (normalised.startsWith('/')) return null; + const segments = normalised.split('/'); + for (const segment of segments) { + if (segment === '..') return null; + } + return segments.filter((s) => s !== '' && s !== '.').join('/'); +} + +function isContained(root: string, candidate: string): boolean { + return candidate === root || candidate.startsWith(root + path.sep); +} diff --git a/middleware/test/_helpers/httpInvoke.ts b/middleware/test/_helpers/httpInvoke.ts index 536cf44c..a5e5f8cc 100644 --- a/middleware/test/_helpers/httpInvoke.ts +++ b/middleware/test/_helpers/httpInvoke.ts @@ -27,15 +27,32 @@ export interface InvokeResult { type EndArgs = readonly unknown[]; +export interface InvokeOptions { + /** + * Request headers, lower-cased on the way in. Needed by any test that + * asserts conditional-request behaviour (`If-None-Match` → 304): without + * them the handler only ever sees an unconditional GET, and a test that + * expects a 304 would pass or fail for the wrong reason. + */ + readonly headers?: Readonly>; +} + export function invoke( app: Express, method: string, url: string, + options: InvokeOptions = {}, ): Promise { const socket = new Socket(); const req = new IncomingMessage(socket); req.method = method; req.url = url; + for (const [name, value] of Object.entries(options.headers ?? {})) { + // `IncomingMessage.headers` is the object Express reads; populate both it + // and `rawHeaders` so anything reaching for either sees the same request. + req.headers[name.toLowerCase()] = value; + req.rawHeaders.push(name, value); + } const res = new ServerResponse(req); const chunks: Buffer[] = []; diff --git a/middleware/test/builder/codegen.test.ts b/middleware/test/builder/codegen.test.ts index 06ee1cbc..baedb6db 100644 --- a/middleware/test/builder/codegen.test.ts +++ b/middleware/test/builder/codegen.test.ts @@ -1170,6 +1170,29 @@ describe('codegen.generate', () => { assert.match(html, /1000\s*px\s*fixed/i); }); + it('points the shipped admin-ui boilerplate docs at the generated plugin UI stylesheet source of truth', async () => { + const boilerplateRoot = path.join( + HERE, + '..', + '..', + 'assets', + 'boilerplate', + 'agent-integration', + 'assets', + 'admin-ui', + ); + const html = readFileSync(path.join(boilerplateRoot, 'index.html'), 'utf-8'); + const authoringGuide = readFileSync(path.join(boilerplateRoot, 'CLAUDE.md'), 'utf-8'); + + assert.match(html, /web-ui\/scripts\/build-plugin-ui-css\.mjs/); + assert.match(html, /middleware\/assets\/plugin-ui\/plugin-ui\.css/); + assert.match(html, /\/bot-api\/_harness\/admin-ui\.css/); + + assert.match(authoringGuide, /web-ui\/scripts\/build-plugin-ui-css\.mjs/); + assert.match(authoringGuide, /middleware\/assets\/plugin-ui\/plugin-ui\.css/); + assert.match(authoringGuide, /\/bot-api\/_harness\/plugin-ui\.css/); + }); + it('throws CodegenError for reserved tool id', async () => { const { slots } = loadFixture(); const spec = parseAgentSpec({ diff --git a/middleware/test/fixtures/plugin-ui-proof/README.md b/middleware/test/fixtures/plugin-ui-proof/README.md new file mode 100644 index 00000000..2139ba83 --- /dev/null +++ b/middleware/test/fixtures/plugin-ui-proof/README.md @@ -0,0 +1,22 @@ +# plugin-ui-proof + +The throwaway SPA that proves the epic #470 C8 contract end to end. It is a +test fixture, not a shipped plugin — `pluginUiProof.test.ts` zips it, pushes it +through `PackageUploadService.ingest`, mounts the resulting package on a real +Express app and fetches it back. + +What it demonstrates, in one artifact: + +| Claim | How the fixture shows it | +|---|---| +| A plugin can ship a multi-file SPA | `ui/index.html` + `ui/assets/app-7c1f4b2e.js` survive the ZIP allowlist | +| A plugin ships no CSS | there is no `.css` file; the HTML links `/bot-api/_harness/plugin-ui.css` | +| A plugin *cannot* ship CSS | the test re-zips with `ui/theme.css` and asserts `zip.forbidden_extension` | +| Hashed assets are cached immutably | `app-7c1f4b2e.js` comes back `max-age=31536000, immutable`; `index.html` does not | +| Arbitrary values are rejected | the test re-zips with `w-[137px]` and asserts `package.ui_arbitrary_tailwind_value` | +| The theme crosses the iframe | the inline bootstrap mirrors `?theme=&palette=&locale=` onto `` | + +The JS is hand-written in the shape a Vite build emits — hashed basename, ESM, +class names surviving as plain string literals — because the ingest scanner +sees compiled bundles, never JSX, and a fixture that ignored that would prove +the wrong thing. diff --git a/middleware/test/fixtures/plugin-ui-proof/ui/assets/app-7c1f4b2e.js b/middleware/test/fixtures/plugin-ui-proof/ui/assets/app-7c1f4b2e.js new file mode 100644 index 00000000..2852c97e --- /dev/null +++ b/middleware/test/fixtures/plugin-ui-proof/ui/assets/app-7c1f4b2e.js @@ -0,0 +1,72 @@ +// Throwaway proof bundle for epic #470 C8 — hand-written in the shape a Vite +// build emits (hashed basename, ESM, class names surviving as string +// literals), so the ingest scanner and the static server are exercised +// against realistic input rather than a convenient one. +// +// Every class below is in the documented vocabulary +// (plugin-ui-vocabulary.md, in the epic #470 spec directory). Not one arbitrary +// value appears, which is the whole point: put a bracketed pixel width in +// here and `packageUploadService` rejects the package at ingest. (This +// comment used to spell one out, and the scanner rejected the fixture — the +// documented prose false positive, caught by its own proof.) + +const CARD = 'rounded-md border border-border bg-bg-elevated p-4 shadow-sm'; +const HEADING = 'text-lg font-semibold text-fg-strong'; +const MUTED = 'text-sm text-fg-muted'; +const ROW = 'flex items-center justify-between gap-4 py-2'; +const BADGE_OK = 'rounded-full bg-success px-2 text-xs text-bg'; +const BADGE_WARN = 'rounded-full bg-warning px-2 text-xs text-bg'; +const BUTTON = + 'rounded-sm border border-accent bg-accent px-4 py-2 text-bg transition-colors hover:bg-accent-hover disabled:opacity-50 disabled:cursor-not-allowed'; + +const STATUS = [ + { label: 'Stylesheet', ok: true }, + { label: 'Theme bridge', ok: true }, + { label: 'Arbitrary values', ok: false }, +]; + +function row(item) { + const el = document.createElement('div'); + el.className = ROW; + const label = document.createElement('span'); + label.className = MUTED; + label.textContent = item.label; + const badge = document.createElement('span'); + badge.className = item.ok ? BADGE_OK : BADGE_WARN; + badge.textContent = item.ok ? 'ok' : 'none'; + el.append(label, badge); + return el; +} + +function render(root) { + const grid = document.createElement('div'); + grid.className = 'grid grid-cols-1 gap-4 md:grid-cols-2 max-w-4xl mx-auto'; + + const card = document.createElement('section'); + card.className = CARD; + const h = document.createElement('h2'); + h.className = HEADING; + h.textContent = 'Plugin UI proof'; + const p = document.createElement('p'); + p.className = MUTED; + p.textContent = + 'Styled entirely by the stylesheet core serves. No CSS shipped in this package.'; + card.append(h, p); + STATUS.forEach((item) => card.append(row(item))); + + const actions = document.createElement('div'); + actions.className = CARD; + const button = document.createElement('button'); + button.className = BUTTON; + button.textContent = 'Does nothing'; + button.disabled = true; + actions.append(button); + + grid.append(card, actions); + root.replaceChildren(grid); +} + +const root = document.getElementById('root'); +if (root) render(root); + +export { render }; diff --git a/middleware/test/fixtures/plugin-ui-proof/ui/index.html b/middleware/test/fixtures/plugin-ui-proof/ui/index.html new file mode 100644 index 00000000..a961fafe --- /dev/null +++ b/middleware/test/fixtures/plugin-ui-proof/ui/index.html @@ -0,0 +1,41 @@ + + + + + + Plugin UI proof + + + + + +
+ + + diff --git a/middleware/test/pluginUiProof.test.ts b/middleware/test/pluginUiProof.test.ts new file mode 100644 index 00000000..371fa718 --- /dev/null +++ b/middleware/test/pluginUiProof.test.ts @@ -0,0 +1,235 @@ +/** + * Epic #470 C8 end to end: a plugin ships a multi-file SPA, core serves it, + * and the two things that must be impossible stay impossible. + * + * This is the abandonment-checkpoint proof. It takes the throwaway bundle in + * `test/fixtures/plugin-ui-proof/`, zips it, pushes it through the REAL + * `PackageUploadService.ingest` (extractor guardrails, manifest validation, + * entry-point check, the new arbitrary-value scan), then mounts the resulting + * package directory on a real Express app and fetches it back over the real + * router — including the stylesheet the bundle links instead of shipping. + * + * The two negative cases are the point of the design, not extras: + * - a `.css` file inside `ui/` is rejected at extraction. The inability to + * ship CSS IS the enforcement for the Tailwind vocabulary + * (`implementation.md` §1 row 3); + * - an arbitrary value inside the bundle is rejected at ingest, because it + * would otherwise render unstyled with no error anywhere. + */ + +import { after, before, describe, it } from 'node:test'; +import { strict as assert } from 'node:assert'; +import { promises as fs } from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import express, { type Express } from 'express'; + +import { PackageUploadService } from '../src/plugins/packageUploadService.js'; +import { createHarnessAdminUiRouter } from '../src/routes/harnessAdminUi.js'; +import { createPluginUiStaticRouter } from '../src/routes/pluginUiStatic.js'; +import { + buildZip, + fakeCatalog, + fakeStore, + manifestYaml, +} from './_helpers/pluginPackageZip.js'; +import { invoke } from './_helpers/httpInvoke.js'; + +const HERE = path.dirname(fileURLToPath(import.meta.url)); +const FIXTURE = path.join(HERE, 'fixtures', 'plugin-ui-proof'); +const MIDDLEWARE_ROOT = path.resolve(HERE, '..'); +const PLUGIN_UI_ASSETS = path.join(MIDDLEWARE_ROOT, 'assets', 'plugin-ui'); + +const PLUGIN_ID = 'plugin-ui-proof'; +const VERSION = '1.0.0'; + +const LIMITS = { maxBytes: 4_000_000, maxExtractedBytes: 4_000_000, maxEntries: 200 }; + +let indexHtml: string; +let appJs: string; + +async function fixtureFiles(): Promise> { + return { + 'manifest.yaml': manifestYaml(PLUGIN_ID, VERSION), + 'package.json': JSON.stringify({ name: PLUGIN_ID, version: VERSION }, null, 2), + 'dist/plugin.js': 'module.exports = { activate() {} };\n', + 'ui/index.html': indexHtml, + 'ui/assets/app-7c1f4b2e.js': appJs, + }; +} + +function service(packagesDir: string): PackageUploadService { + return new PackageUploadService({ + store: fakeStore(), + catalog: fakeCatalog(), + packagesDir, + limits: LIMITS, + hostDependencies: {}, + log: () => {}, + }); +} + +before(async () => { + indexHtml = await fs.readFile(path.join(FIXTURE, 'ui', 'index.html'), 'utf-8'); + appJs = await fs.readFile( + path.join(FIXTURE, 'ui', 'assets', 'app-7c1f4b2e.js'), + 'utf-8', + ); +}); + +describe('plugin UI proof — install through the package store path', () => { + let packagesDir: string; + let app: Express; + let packageRoot: string; + + before(async () => { + packagesDir = await fs.mkdtemp(path.join(os.tmpdir(), 'plugin-ui-proof-')); + const zip = await buildZip(await fixtureFiles()); + const result = await service(packagesDir).ingest({ + fileBuffer: zip, + originalFilename: 'plugin-ui-proof.zip', + uploadedBy: 'test', + }); + assert.equal( + result.ok, + true, + `ingest failed: ${result.ok ? '' : `${result.code} ${result.message}`}`, + ); + assert.ok(result.ok); + packageRoot = result.package.path; + + app = express(); + app.use('/api/_harness', await createHarnessAdminUiRouter({ + assetsRoot: PLUGIN_UI_ASSETS, + })); + app.use( + '/p', + createPluginUiStaticRouter({ + resolvePackageRoot: (id) => (id === PLUGIN_ID ? packageRoot : undefined), + }), + ); + }); + + after(async () => { + await fs.rm(packagesDir, { recursive: true, force: true }); + }); + + it('lands the multi-file ui/ bundle on disk', async () => { + await fs.access(path.join(packageRoot, 'ui', 'index.html')); + await fs.access(path.join(packageRoot, 'ui', 'assets', 'app-7c1f4b2e.js')); + }); + + it('serves index.html as HTML', async () => { + const res = await invoke(app, 'GET', `/p/${PLUGIN_ID}/ui/index.html`); + assert.equal(res.status, 200); + assert.equal(res.headers['content-type'], 'text/html; charset=utf-8'); + assert.match(res.text, /Plugin UI proof/); + }); + + it('serves the hashed JS bundle immutably', 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.headers['cache-control'], 'public, max-age=31536000, immutable'); + }); + + it('ships no stylesheet of its own — it links the one core serves', () => { + assert.ok(!indexHtml.includes('.css"') || indexHtml.includes('_harness/plugin-ui.css')); + assert.match(indexHtml, /\/bot-api\/_harness\/plugin-ui\.css/); + }); + + it('serves that stylesheet, with the Lume utilities the bundle uses', async () => { + const res = await invoke(app, 'GET', '/api/_harness/plugin-ui.css'); + assert.equal(res.status, 200); + assert.equal(res.headers['content-type'], 'text/css; charset=utf-8'); + for (const utility of [ + '.bg-accent', + '.text-fg-muted', + '.border-border', + '.rounded-md', + '.grid-cols-1', + ]) { + assert.ok(res.text.includes(utility), `missing ${utility} in plugin-ui.css`); + } + }); + + it('keeps the legacy admin-ui.css alias resolving to the same bytes', async () => { + const generated = await invoke(app, 'GET', '/api/_harness/plugin-ui.css'); + const alias = await invoke(app, 'GET', '/api/_harness/admin-ui.css'); + assert.equal(alias.status, 200); + assert.equal(alias.text, generated.text); + }); + + it('still carries the .harness-* helpers shipped plugin admin UIs use', async () => { + const res = await invoke(app, 'GET', '/api/_harness/admin-ui.css'); + for (const cls of ['.harness-admin', '.harness-btn', '.harness-banner-error']) { + assert.ok(res.text.includes(cls), `missing ${cls} — would restyle shipped plugins`); + } + }); +}); + +describe('plugin UI proof — what the contract forbids', () => { + let packagesDir: string; + + before(async () => { + packagesDir = await fs.mkdtemp(path.join(os.tmpdir(), 'plugin-ui-proof-neg-')); + }); + + after(async () => { + await fs.rm(packagesDir, { recursive: true, force: true }); + }); + + it('rejects a .css file inside ui/ — the inability to ship CSS IS the rule', async () => { + const files = await fixtureFiles(); + files['ui/assets/theme.css'] = 'body { color: #ff00ff }'; + const result = await service(packagesDir).ingest({ + fileBuffer: await buildZip(files), + originalFilename: 'css.zip', + uploadedBy: 'test', + }); + assert.equal(result.ok, false); + assert.ok(!result.ok); + assert.equal(result.code, 'zip.forbidden_extension'); + assert.match(result.message, /theme\.css/); + }); + + it('rejects an arbitrary Tailwind value in the bundle, naming the offender', async () => { + const files = await fixtureFiles(); + files['ui/assets/app-7c1f4b2e.js'] = + `${appJs}\nconst BAD = "flex w-[137px] bg-[#abc]";\nexport { BAD };\n`; + const result = await service(packagesDir).ingest({ + fileBuffer: await buildZip(files), + originalFilename: 'arbitrary.zip', + uploadedBy: 'test', + }); + assert.equal(result.ok, false); + assert.ok(!result.ok); + assert.equal(result.code, 'package.ui_arbitrary_tailwind_value'); + assert.match(result.message, /w-\[137px\]/); + assert.match(result.message, /plugin-ui-vocabulary\.md/); + }); + + it('accepts a .woff2 inside ui/ but not outside it', async () => { + const withFont = await fixtureFiles(); + withFont['ui/assets/inter.woff2'] = 'not-a-real-font'; + const ok = await service(packagesDir).ingest({ + fileBuffer: await buildZip(withFont), + originalFilename: 'font-ok.zip', + uploadedBy: 'test', + }); + assert.equal(ok.ok, true, ok.ok ? '' : `${ok.code}: ${ok.message}`); + + const outside = await fixtureFiles(); + outside['fonts/inter.woff2'] = 'not-a-real-font'; + const bad = await service(packagesDir).ingest({ + fileBuffer: await buildZip(outside), + originalFilename: 'font-bad.zip', + uploadedBy: 'test', + }); + assert.equal(bad.ok, false); + assert.ok(!bad.ok); + assert.equal(bad.code, 'zip.forbidden_extension'); + }); +}); diff --git a/middleware/test/pluginUiStaticServing.test.ts b/middleware/test/pluginUiStaticServing.test.ts new file mode 100644 index 00000000..47090730 --- /dev/null +++ b/middleware/test/pluginUiStaticServing.test.ts @@ -0,0 +1,262 @@ +/** + * Static serving of a plugin's compiled SPA bundle (epic #470 C8 / G7). + * + * `/p//ui/...` is reachable without an operator session — the + * `publicPaths` allowlist has covered `/p/*` since plugin UI surfaces were + * first iframed by Teams. That makes every property below load-bearing rather + * than defence in depth: this handler is the boundary. + * + * Driven through `app.handle` (see `_helpers/httpInvoke.ts`) so routing, + * params and headers run for real without holding a port. + */ + +import { after, before, describe, it } from 'node:test'; +import { strict as assert } from 'node:assert'; +import { promises as fs } from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; + +import express, { type Express } from 'express'; + +import { + createPluginUiStaticRouter, + safeRelativePath, +} from '../src/routes/pluginUiStatic.js'; +import { invoke } from './_helpers/httpInvoke.js'; + +const PLUGIN_ID = 'proof-plugin'; + +let root: string; +let packageRoot: string; +let app: Express; + +before(async () => { + root = await fs.mkdtemp(path.join(os.tmpdir(), 'plugin-ui-static-')); + packageRoot = path.join(root, 'packages', PLUGIN_ID, '1.0.0'); + const ui = path.join(packageRoot, 'ui'); + await fs.mkdir(path.join(ui, 'assets'), { recursive: true }); + await fs.mkdir(path.join(ui, 'empty-dir'), { recursive: true }); + await fs.writeFile(path.join(ui, 'index.html'), '

hi

'); + 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 ( +