+ Reference Agent +
++ Plugin-served UI Surface — PoC 2026-05-15 +
+Recent notes
+ ${notes.length === 0 + ? safe('No notes yet.
') + : html`- ${noteItems}
+ Plugin-served UI Surface — PoC 2026-05-15 +
+No notes yet.
') + : html`No notes yet.
') + : html` +${inner}
` // works — inner ist HtmlFragment, kein String +html`${safe('bold')}
` // explicit opt-out, vorsicht +``` + +## iframe-Safe CSP + +`renderRoute()` setzt automatisch: + +``` +Content-Security-Policy: + default-src 'self' https: data: blob:; + img-src 'self' https: data: blob:; + style-src 'self' 'unsafe-inline' https:; + script-src 'self' 'unsafe-inline' https:; + frame-ancestors 'self' + https://*.teams.microsoft.com + https://teams.microsoft.com + https://*.office.com + https://*.microsoft365.com +X-Content-Type-Options: nosniff +``` + +`'unsafe-inline'` in `style-src` ist nötig, weil Tailwind via CDN-Script +JIT-CSS-Klassen zur Laufzeit emittiert. Für Production mit gebautem +Tailwind-Bundle kann man später strikter werden. + +## Self-Filling-Tabs + +`refreshSeconds: 30` emittiert einen ``-Tag. +Der Browser reloadet die Seite alle N Sekunden — der SSR-Handler läuft +frisch gegen die aktuelle Datenlage, kein Client-JS nötig. + +Reicht für **„zeig mir die neuesten Daten"**-Use-Cases. Für richer +Scenarios (Sub-Sekunde-Updates, Form-Inputs überleben, kein +Scroll-Reset) später Polling-Fetch + DOM-Swap einbauen — aber für eine +MVP-Tab-Surface ist die 1-Zeilen-Lösung perfekt. + +## Was NICHT drin ist + +- Keine Komponenten-Library (Card, Button, Table) — Plugins composen aus + Tailwind-Klassen direkt. Wenn ein gemeinsamer Bedarf entsteht: separates + `@omadia/plugin-ui-components`-Paket dazu. +- Kein Client-Side-State / Reactivity. Pure SSR. Interaktion via Forms + + Server-Side-Handler. +- Kein i18n. Plugins können das selbst lösen (z.B. `next-intl`-style mit + einem `t(key)`-Helper aus dem ctx). +- Kein Auth innerhalb des Helpers. Plugin-Routes laufen ohne Session-Cookie + (siehe `requireAuth` publicPaths in Notion-Doku 14); wer sensible Daten + ausgibt, validiert den Teams-SSO-Token im eigenen Handler. + +## Versionierung + +Backwards-compatible Add-ons (neue optionale `htmlDoc`-Option, neuer Export) +sind Patch-Bumps. Breaking changes an der `renderRoute`/`html`-Surface +würden Minor-Bumps. Major bleibt für API-Reshape (z.B. Wechsel auf JSX/React). diff --git a/middleware/packages/harness-ui-helpers/package.json b/middleware/packages/harness-ui-helpers/package.json new file mode 100644 index 000000000..b95bee9d8 --- /dev/null +++ b/middleware/packages/harness-ui-helpers/package.json @@ -0,0 +1,34 @@ +{ + "name": "@omadia/plugin-ui-helpers", + "version": "0.2.0", + "private": true, + "type": "module", + "main": "dist/index.js", + "types": "dist/index.d.ts", + "description": "UI-helpers for plugin-served HTML/React routes. Tailwind-CDN HTML wrapper + iframe-safe CSP + library templates (list-card, kpi-tiles) + React-SSR helper.", + "license": "MIT", + "scripts": { + "build": "tsc", + "typecheck": "tsc --noEmit", + "test": "vitest run" + }, + "peerDependencies": { + "express": "^5.1.0", + "react": "^18.3.1", + "react-dom": "^18.3.1" + }, + "peerDependenciesMeta": { + "react": { "optional": true }, + "react-dom": { "optional": true } + }, + "devDependencies": { + "@types/express": "^5.0.0", + "@types/react": "^18.3.12", + "@types/react-dom": "^18.3.1", + "react": "^18.3.1", + "react-dom": "^18.3.1" + }, + "engines": { + "node": ">=20" + } +} diff --git a/middleware/packages/harness-ui-helpers/src/document.ts b/middleware/packages/harness-ui-helpers/src/document.ts new file mode 100644 index 000000000..5eab600b8 --- /dev/null +++ b/middleware/packages/harness-ui-helpers/src/document.ts @@ -0,0 +1,66 @@ +import { escapeHtml, type HtmlFragment } from './html.js'; + +export interface HtmlDocOptions { + title: string; + body: HtmlFragment; + lang?: string; + /** Inline ` + : ''; + const refresh = + typeof options.refreshSeconds === 'number' && options.refreshSeconds > 0 + ? `` + : ''; + return [ + '', + ``, + '', + '', + '', + refresh, + `{ + /** Props passed to the Component when SSR renders. */ + readonly props: P; + /** `
( + Component: ComponentType
, + opts: RenderReactRouteOptions
,
+): RouteHandler {
+ return async ({ res }) => {
+ withIframeSafeHeaders(res);
+ const { createElement, renderToString } = await loadReact();
+ const element: ReactElement = createElement(
+ Component as ComponentType (
+ hydration: NonNullable (
+ innerHtml: string,
+ opts: RenderReactRouteOptions ,
+): string {
+ const lang = opts.lang ?? 'en';
+ const title = escapeHtml(opts.pageTitle);
+ // B.13 — meta-refresh and client-side hydration are mutually exclusive:
+ // a meta-refresh blows away any hydrated React state every N seconds.
+ // When `hydration` is set, the client owns refresh semantics (SWR /
+ // event-driven re-render). The refresh setting is silently dropped.
+ const refresh =
+ !opts.hydration &&
+ typeof opts.refreshSeconds === 'number' &&
+ opts.refreshSeconds > 0
+ ? ``
+ : '';
+ const tailwind = opts.tailwind ?? 'cdn';
+ const tailwindTag =
+ tailwind === 'cdn' ? `` : '';
+ const cssLink = opts.cssHref
+ ? ``
+ : '';
+ const hydrationBlock = opts.hydration
+ ? buildHydrationScripts(opts.hydration, opts.props)
+ : '';
+ return [
+ '',
+ ``,
+ '